feat(frontend): persist model pricing sources

This commit is contained in:
ZheFox
2026-09-03 20:49:42 +08:00
parent 89b57464d2
commit 7b612b8b5a
8 changed files with 1288 additions and 146 deletions
+470 -113
View File
@@ -8,9 +8,18 @@
<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">
<!-- 左侧:标题 -->
<h3 class="text-sm sm:text-base font-semibold shrink-0">
模型管理
</h3>
<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">
@@ -28,13 +37,18 @@
<!-- 操作按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="快速筛选与批量操作"
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
@@ -57,12 +71,26 @@
<Table class="hidden xl:table">
<TableHeader>
<TableRow>
<TableHead class="w-[240px]">
模型名称
<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-[170px] text-center">
{{ t('models.pricingSource.label') }}
</TableHead>
<TableHead class="w-[80px] text-center">
提供商
</TableHead>
@@ -80,7 +108,7 @@
<TableBody>
<TableRow v-if="loading">
<TableCell
colspan="6"
colspan="7"
class="text-center py-8"
>
<Loader2 class="w-6 h-6 animate-spin mx-auto" />
@@ -88,7 +116,7 @@
</TableRow>
<TableRow v-else-if="filteredGlobalModels.length === 0">
<TableCell
colspan="6"
colspan="7"
class="text-center py-8 text-muted-foreground"
>
没有找到匹配的模型
@@ -99,23 +127,34 @@
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>
<div class="font-medium">
{{ model.display_name }}
</div>
<div class="text-xs text-muted-foreground flex items-center gap-1">
<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 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>
@@ -157,6 +196,24 @@
</div>
</div>
</TableCell>
<TableCell
class="text-center"
@mousedown.stop
@click.stop
>
<GlobalModelPricingSourceSelect
class="mx-auto max-w-[165px]"
:model-id="model.id"
:source="getModelPricingSource(model)"
:candidates="getModelPricingCandidates(model)"
:loading="batchManageOnlineLoading"
:syncing="isModelPricingSourceSyncing(model.id)"
:local-only="isModelPricingSourceLocalOnly(model)"
@open="handleModelPricingSourceOpen(model, $event)"
@select="syncModelPricingSource(model, $event)"
@resync="resyncModelPricingSource(model)"
/>
</TableCell>
<TableCell class="text-center">
<Badge variant="secondary">
{{ model.active_provider_count || 0 }}/{{ model.provider_count || 0 }}
@@ -224,28 +281,39 @@
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-1 min-w-0">
<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 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
@@ -290,6 +358,26 @@
${{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }}/${{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}
</span>
</div>
<div
class="flex items-center gap-2"
@mousedown.stop
@click.stop
>
<span class="shrink-0 text-xs text-muted-foreground">{{ t('models.pricingSource.label') }}</span>
<GlobalModelPricingSourceSelect
class="min-w-0 flex-1"
:model-id="model.id"
:source="getModelPricingSource(model)"
:candidates="getModelPricingCandidates(model)"
:loading="batchManageOnlineLoading"
:syncing="isModelPricingSourceSyncing(model.id)"
:local-only="isModelPricingSourceLocalOnly(model)"
@open="handleModelPricingSourceOpen(model, $event)"
@select="syncModelPricingSource(model, $event)"
@resync="resyncModelPricingSource(model)"
/>
</div>
</div>
</div>
@@ -472,8 +560,8 @@
<!-- 批量管理全局模型对话框 -->
<Dialog
:model-value="batchManageDialogOpen"
title="快速筛选与批量操作"
description="默认按每个模型上次选择的在线来源同步,也可手动指定统一来源"
:title="t('models.management.batch.title')"
:description="t('models.management.batch.description')"
:icon="ListChecks"
size="2xl"
@update:model-value="batchManageDialogOpen = $event"
@@ -495,11 +583,11 @@
:disabled="batchManageOnlineLoading"
>
<SelectTrigger class="h-9 text-xs">
<SelectValue placeholder="选择在线价格来源" />
<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"
@@ -634,7 +722,7 @@
<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="selectedBatchPriceSyncPlan.syncable.length === 0 || submittingBatchManage"
:disabled="selectedBatchPriceSyncEntries.length === 0 || submittingBatchManage"
@click="confirmBatchSyncPrices"
>
<Loader2
@@ -645,7 +733,9 @@
v-else
class="w-4 h-4 mr-1"
/>
{{ batchManageAction === 'sync-prices' ? '同步中...' : `同步在线价格 (${selectedBatchPriceSyncPlan.syncable.length})` }}
{{ 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"
@@ -692,6 +782,7 @@ import {
} from 'lucide-vue-next'
import ModelDetailDrawer from '@/features/models/components/ModelDetailDrawer.vue'
import GlobalModelFormDialog from '@/features/models/components/GlobalModelFormDialog.vue'
import GlobalModelPricingSourceSelect from '@/features/models/components/GlobalModelPricingSourceSelect.vue'
import ExternalModelsAccessControl from '@/features/models/components/ExternalModelsAccessControl.vue'
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
import type { Model } from '@/api/endpoints'
@@ -699,11 +790,13 @@ 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,
@@ -738,8 +831,14 @@ import { getModelsDevList, type ModelsDevModelItem } from '@/api/models-dev'
import {
buildGlobalModelPriceSyncPlan,
cloneTieredPricingConfig,
type GlobalModelPriceSyncEntry,
} from '@/features/models/components/global-model-form-helpers'
import { useModelsDevPricingSources } from '@/features/models/composables/useModelsDevPricingSources'
import {
getModelsDevPricingSourceFromConfig,
modelsDevPricingSourcesEqual,
useModelsDevPricingSources,
withModelsDevPricingSource,
} from '@/features/models/composables/useModelsDevPricingSources'
interface ModelProviderDisplay {
@@ -765,8 +864,13 @@ interface ModelProviderDisplay {
}
const { success, error: showError } = useToast()
const { t, locale } = useI18n()
const { copyToClipboard } = useClipboard()
const { getSource: getModelsDevPricingSource, setSource: setModelsDevPricingSource } = useModelsDevPricingSources()
const {
getSource: getModelsDevPricingSource,
getLocalSource: getLocalModelsDevPricingSource,
setSource: setModelsDevPricingSource,
} = useModelsDevPricingSources()
const REMEMBERED_PRICING_PROVIDER_ID = '__remembered__'
// 状态
@@ -785,11 +889,14 @@ const batchManageModels = ref<GlobalModelResponse[]>([])
const batchManageLoading = ref(false)
const batchManageOnlineModels = ref<ModelsDevModelItem[]>([])
const batchManageOnlineLoading = ref(false)
const modelPricingSourceSyncingIds = ref<Set<string>>(new Set())
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
@@ -1097,6 +1204,32 @@ const filteredGlobalModels = computed(() => {
// 模型目录分页计算
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
@@ -1133,6 +1266,7 @@ async function loadGlobalModels(options: { cacheTtlMs?: number } = {}) {
globalModels.value = pageModels
totalGlobalModels.value = total
migrateLegacyModelsDevPricingSources(pageModels)
} catch (err: unknown) {
if (requestId !== globalModelsRequestId) return
log.error('加载模型失败:', err)
@@ -1175,6 +1309,11 @@ async function loadBatchManageModels() {
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)
@@ -1369,6 +1508,164 @@ const filteredBatchManageModels = computed(() => {
})
})
const onlinePricingCandidatesByModelName = computed(() => {
const candidatesByName = new Map<string, ModelsDevModelItem[]>()
for (const onlineModel of batchManageOnlineModels.value) {
const normalizedName = onlineModel.modelId.trim().toLowerCase()
const existing = candidatesByName.get(normalizedName) ?? []
const normalizedProviderId = onlineModel.providerId.trim().toLowerCase()
if (!existing.some(candidate => (
candidate.providerId.trim().toLowerCase() === normalizedProviderId
))) {
existing.push(onlineModel)
candidatesByName.set(normalizedName, existing)
}
}
for (const candidates of candidatesByName.values()) {
candidates.sort((left, right) => (
Number(right.official === true) - Number(left.official === true)
|| left.providerName.localeCompare(right.providerName)
|| left.providerId.localeCompare(right.providerId)
))
}
return candidatesByName
})
function getModelPricingCandidates(model: GlobalModelResponse): ModelsDevModelItem[] {
return onlinePricingCandidatesByModelName.value.get(model.name.trim().toLowerCase()) ?? []
}
function getModelPricingSource(model: GlobalModelResponse) {
return getModelsDevPricingSource(model.id, model.config)
}
function isModelPricingSourceLocalOnly(model: GlobalModelResponse): boolean {
return !getModelsDevPricingSourceFromConfig(model.config)
&& !!getLocalModelsDevPricingSource(model.id)
}
function isModelPricingSourceSyncing(modelId: string): boolean {
return modelPricingSourceSyncingIds.value.has(modelId)
}
function setModelPricingSourceSyncing(modelId: string, syncing: boolean) {
const nextIds = new Set(modelPricingSourceSyncingIds.value)
if (syncing) nextIds.add(modelId)
else nextIds.delete(modelId)
modelPricingSourceSyncingIds.value = nextIds
}
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 () => {
setModelPricingSourceSyncing(model.id, true)
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)
} finally {
setModelPricingSourceSyncing(model.id, false)
}
}]
})
if (tasks.length > 0) void runBatchTasksWithConcurrency(tasks, 4)
}
async function handleModelPricingSourceOpen(_model: GlobalModelResponse, open: boolean) {
if (open) await loadBatchManageOnlineModels()
}
async function syncModelPricingSource(model: GlobalModelResponse, providerId: string) {
if (!providerId || providerId.startsWith('__') || isModelPricingSourceSyncing(model.id)) return
if (batchManageOnlineModels.value.length === 0) await loadBatchManageOnlineModels()
const normalizedProviderId = providerId.trim().toLowerCase()
const candidate = getModelPricingCandidates(model).find(item => (
item.providerId.trim().toLowerCase() === normalizedProviderId
))
if (!candidate) {
showError(
t('models.pricingSource.sourceGone'),
t('models.pricingSource.cannotSync'),
)
return
}
if (candidate.pricingUnsupportedFields?.length) {
showError(
t('models.pricingSource.unsupported', { fields: formatBatchUnsupportedPricingFields(candidate) }),
t('models.pricingSource.incompatible'),
)
return
}
if (!candidate.tieredPricing?.tiers?.length) {
showError(
t('models.pricingSource.noUsablePrice'),
t('models.pricingSource.cannotSync'),
)
return
}
const source = {
provider_id: candidate.providerId,
provider_name: candidate.providerName,
}
const pricing = cloneTieredPricingConfig(candidate.tieredPricing)
const nextConfig = withModelsDevPricingSource(model.config, source)
setModelPricingSourceSyncing(model.id, true)
try {
const updatedModel = await updateGlobalModel(model.id, {
default_tiered_pricing: pricing,
config: nextConfig,
})
setModelsDevPricingSource(model.id, source)
applyGlobalModelUpdate({
...updatedModel,
default_tiered_pricing: pricing,
config: nextConfig,
})
success(t('models.pricingSource.selectedAndSynced', { provider: candidate.providerName }))
} catch (err: unknown) {
log.error('更新模型价格来源失败:', err)
showError(
parseApiError(err, t('models.pricingSource.updateFailed')),
t('models.pricingSource.syncFailed'),
)
} finally {
setModelPricingSourceSyncing(model.id, false)
}
}
async function resyncModelPricingSource(model: GlobalModelResponse) {
const source = getModelPricingSource(model)
if (!source) return
await loadBatchManageOnlineModels()
await syncModelPricingSource(model, source.provider_id)
}
const batchPricingProviderOptions = computed(() => {
const existingModelNames = new Set(batchManageModels.value.map(model => model.name.trim().toLowerCase()))
const providers = new Map<string, {
@@ -1415,7 +1712,7 @@ 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)
const source = getModelsDevPricingSource(model.id, model.config)
if (source) providerIds.set(model.id, source.provider_id)
}
return providerIds
@@ -1429,10 +1726,25 @@ const batchPriceSyncPlan = computed(() => (
)
))
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' | 'unchanged' | 'unsupported' | 'unavailable'>()
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, 'unchanged')
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
@@ -1450,11 +1762,25 @@ const selectedBatchPriceSyncPlan = computed(() => (
)
))
const selectedBatchPriceSyncEntries = computed(() => [
...selectedBatchPriceSyncPlan.value.syncable,
...selectedBatchPriceSyncPlan.value.unchanged.filter(doesBatchEntryNeedSourcePersistence),
])
const batchManageSelectionSummary = computed(() => {
const selectedCount = selectedBatchManageModelIds.value.size
if (selectedCount === 0) return '选择模型后执行批量操作'
if (selectedCount === 0) return t('models.management.batch.selectionEmpty')
const plan = selectedBatchPriceSyncPlan.value
return `已选择 ${selectedCount} 个 · 可更新 ${plan.syncable.length} · 已一致 ${plan.unchanged.length} · 不兼容 ${plan.unsupported.length} · 无在线价格 ${plan.unavailable.length}`
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) {
@@ -1463,10 +1789,11 @@ function getBatchPricingState(model: GlobalModelResponse) {
function getBatchPricingStateLabel(model: GlobalModelResponse): string {
const state = getBatchPricingState(model)
if (state === 'syncable') return '价格可更新'
if (state === 'unchanged') return '价格一致'
if (state === 'unsupported') return '计价不兼容'
return '无在线价格'
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 {
@@ -1480,14 +1807,17 @@ function getBatchPricingStateDescription(model: GlobalModelResponse): string {
function getBatchPricingSourceLabel(model: GlobalModelResponse): string {
if (batchPricingProviderId.value !== REMEMBERED_PRICING_PROVIDER_ID) {
return selectedBatchPricingProvider.value?.providerName ?? '未选择来源'
return selectedBatchPricingProvider.value?.providerName
?? t('models.management.batch.source.noneSelected')
}
return getModelsDevPricingSource(model.id)?.provider_name ?? '未记录来源'
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'
@@ -1496,6 +1826,7 @@ function getBatchPricingStateClass(model: GlobalModelResponse): string {
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'
@@ -1503,11 +1834,13 @@ function getBatchPricingStateDotClass(model: GlobalModelResponse): string {
function formatBatchUnsupportedPricingFields(model: ModelsDevModelItem): string {
const labels = {
reasoning: '推理 Token',
input_audio: '输入音频 Token',
output_audio: '输出音频 Token',
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('、')
return (model.pricingUnsupportedFields ?? [])
.map(field => labels[field])
.join(locale.value === 'zh-CN' ? '、' : ', ')
}
// 批量管理 - 快捷筛选定义
@@ -1524,10 +1857,11 @@ const batchManageShortcuts = computed(() => {
filter: (m: GlobalModelResponse) => boolean
emphasis?: boolean
}[] = [
{ label: '价格可更新', description: '当前价格与所选供应商在线价格不同', filter: m => getBatchPricingState(m) === 'syncable', emphasis: true },
{ label: '价格一致', description: '当前价格与所选供应商在线价格一致', filter: m => getBatchPricingState(m) === 'unchanged' },
{ label: '计价不兼容', description: '在线来源包含当前计费引擎无法独立结算的价格维度', filter: m => getBatchPricingState(m) === 'unsupported' },
{ label: '无在线价格', description: '所选供应商没有该模型的在线价格', filter: m => getBatchPricingState(m) === 'unavailable' },
{ 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 },
@@ -1578,21 +1912,27 @@ function toggleAllBatchManageModels() {
function openBatchManageDialog() {
batchManageSearchQuery.value = ''
batchPricingProviderId.value = REMEMBERED_PRICING_PROVIDER_ID
selectedBatchManageModelIds.value = new Set()
batchManageDialogOpen.value = true
void Promise.all([loadBatchManageModels(), loadBatchManageOnlineModels()])
}
async function loadBatchManageOnlineModels() {
batchManageOnlineLoading.value = true
try {
batchManageOnlineModels.value = await getModelsDevList(false)
} catch (err: unknown) {
log.error('加载在线模型价格失败:', err)
showError(parseApiError(err, '加载在线模型价格失败'), '加载失败')
} finally {
batchManageOnlineLoading.value = false
}
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(
@@ -1614,15 +1954,29 @@ async function runBatchTasksWithConcurrency(
async function confirmBatchSyncPrices() {
const plan = selectedBatchPriceSyncPlan.value
if (plan.syncable.length === 0) return
const entries = selectedBatchPriceSyncEntries.value
if (entries.length === 0) return
const providerName = batchPricingProviderId.value === REMEMBERED_PRICING_PROVIDER_ID
? '各模型上次选择的提供商'
: selectedBatchPricingProvider.value?.providerName || '所选供应商'
const skippedCount = plan.unchanged.length + plan.unsupported.length + plan.unavailable.length
? 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: '批量同步模型价格',
message: `将根据 ${providerName} 的在线定价更新 ${plan.syncable.length} 个模型。${skippedCount > 0 ? `另有 ${skippedCount} 个模型因价格一致、计价不兼容或无在线价格而跳过。` : ''}\n\n仅更新模型价格,不修改名称、能力或其他配置。`,
confirmText: '同步价格',
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
@@ -1633,18 +1987,27 @@ async function confirmBatchSyncPrices() {
const failureMessages: string[] = []
let successCount = 0
try {
const tasks = plan.syncable.map(entry => async () => {
const tasks = entries.map(entry => async () => {
try {
const onlinePricing = entry.onlineModel.tieredPricing
if (!onlinePricing) {
throw new Error('在线目录未提供价格配置')
throw new Error(t('models.management.batch.catalogMissingPrice'))
}
const pricing = cloneTieredPricingConfig(onlinePricing)
await updateGlobalModel(entry.model.id, { default_tiered_pricing: pricing })
entry.model.default_tiered_pricing = pricing
setModelsDevPricingSource(entry.model.id, {
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) {
@@ -1654,9 +2017,17 @@ async function confirmBatchSyncPrices() {
})
await runBatchTasksWithConcurrency(tasks)
if (successCount > 0) success(`成功同步 ${successCount} 个模型价格`)
if (successCount > 0) {
success(t('models.management.batch.success', { count: successCount }))
}
if (failureMessages.length > 0) {
showError(`${failureMessages.length} 个模型同步失败:${failureMessages.slice(0, 2).join(';')}`, '部分失败')
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
@@ -1705,12 +2076,6 @@ async function confirmBatchDeleteModels() {
}
}
watch(batchPricingProviderId, (value, previousValue) => {
if (previousValue && value !== previousValue) {
selectedBatchManageModelIds.value = new Set()
}
})
watch([batchPricingProviderOptions, batchManageDialogOpen], ([options, dialogOpen]) => {
if (!dialogOpen) return
if (
@@ -1828,20 +2193,11 @@ async function editModel(model: GlobalModelResponse) {
}
function handleModelPricingSynced(model: GlobalModelResponse) {
const updatePricing = (models: GlobalModelResponse[]) => {
const current = models.find(entry => entry.id === model.id)
if (current) {
current.default_tiered_pricing = cloneTieredPricingConfig(model.default_tiered_pricing)
}
}
updatePricing(globalModels.value)
updatePricing(batchManageModels.value)
if (editingModel.value?.id === model.id) {
editingModel.value.default_tiered_pricing = cloneTieredPricingConfig(model.default_tiered_pricing)
}
if (selectedModel.value?.id === model.id) {
selectedModel.value.default_tiered_pricing = cloneTieredPricingConfig(model.default_tiered_pricing)
}
applyGlobalModelUpdate({
...model,
default_tiered_pricing: cloneTieredPricingConfig(model.default_tiered_pricing),
config: model.config ? { ...model.config } : model.config,
})
}
async function deleteModel(model: GlobalModelResponse) {
@@ -1857,6 +2213,7 @@ async function deleteModel(model: GlobalModelResponse) {
if (selectedModel.value?.id === model.id) {
selectedModel.value = null
}
setBatchManageModelSelection(model.id, false)
await loadGlobalModels()
} catch (err: unknown) {
showError(parseApiError(err, '删除失败'), '删除失败')
@@ -0,0 +1,296 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, type App } from 'vue'
import type { GlobalModelResponse } from '@/api/global-models'
import type { ModelsDevModelItem } from '@/api/models-dev'
import { setI18nLocale } from '@/i18n'
import ModelManagement from '@/views/admin/ModelManagement.vue'
const apiMocks = vi.hoisted(() => ({
listGlobalModels: vi.fn(),
getGlobalModel: vi.fn(),
updateGlobalModel: vi.fn(),
deleteGlobalModel: vi.fn(),
batchDeleteGlobalModels: vi.fn(),
batchAssignToProviders: vi.fn(),
getGlobalModelProviders: vi.fn(),
getModelsDevList: vi.fn(),
getProvidersSummary: vi.fn(),
}))
const interactionMocks = vi.hoisted(() => ({
confirm: vi.fn(),
confirmDanger: vi.fn(),
success: vi.fn(),
error: vi.fn(),
}))
vi.mock('@/api/global-models', () => ({
listGlobalModels: apiMocks.listGlobalModels,
getGlobalModel: apiMocks.getGlobalModel,
updateGlobalModel: apiMocks.updateGlobalModel,
deleteGlobalModel: apiMocks.deleteGlobalModel,
batchDeleteGlobalModels: apiMocks.batchDeleteGlobalModels,
batchAssignToProviders: apiMocks.batchAssignToProviders,
getGlobalModelProviders: apiMocks.getGlobalModelProviders,
}))
vi.mock('@/api/models-dev', () => ({
getModelsDevList: apiMocks.getModelsDevList,
}))
vi.mock('@/api/endpoints/providers', () => ({
getProvidersSummary: apiMocks.getProvidersSummary,
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => ({
confirm: interactionMocks.confirm,
confirmDanger: interactionMocks.confirmDanger,
}),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: interactionMocks.success,
error: interactionMocks.error,
}),
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({ copyToClipboard: vi.fn() }),
}))
vi.mock('@/features/models/components/GlobalModelFormDialog.vue', async () => {
const { defineComponent } = await import('vue')
return {
default: defineComponent({
name: 'ChildStub',
setup: () => () => null,
}),
}
})
vi.mock('@/features/models/components/ModelDetailDrawer.vue', async () => {
const { defineComponent } = await import('vue')
return {
default: defineComponent({
name: 'ChildStub',
setup: () => () => null,
}),
}
})
vi.mock('@/features/models/components/ExternalModelsAccessControl.vue', async () => {
const { defineComponent } = await import('vue')
return {
default: defineComponent({
name: 'ChildStub',
setup: () => () => null,
}),
}
})
vi.mock('@/features/providers/components/ProviderModelFormDialog.vue', async () => {
const { defineComponent } = await import('vue')
return {
default: defineComponent({
name: 'ChildStub',
setup: () => () => null,
}),
}
})
const pricing = {
tiers: [{
up_to: null,
input_price_per_1m: 1,
output_price_per_1m: 2,
}],
}
const onlineModel: ModelsDevModelItem = {
providerId: 'openai',
providerName: 'OpenAI',
modelId: 'test-model',
modelName: 'Test Model',
official: true,
inputPrice: 1,
outputPrice: 2,
tieredPricing: pricing,
}
let mountedApp: App | null = null
let mountedRoot: HTMLElement | null = null
let persistedModel: GlobalModelResponse
function cloneModel(model: GlobalModelResponse): GlobalModelResponse {
return JSON.parse(JSON.stringify(model)) as GlobalModelResponse
}
async function settle() {
for (let index = 0; index < 8; index += 1) {
await Promise.resolve()
await nextTick()
}
}
function findButton(text: string): HTMLButtonElement {
const button = [...document.body.querySelectorAll('button')]
.find(candidate => candidate.textContent?.trim().includes(text))
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Missing button containing: ${text}`)
}
return button
}
function mountView() {
mountedRoot = document.createElement('div')
document.body.appendChild(mountedRoot)
mountedApp = createApp(ModelManagement)
mountedApp.mount(mountedRoot)
}
beforeEach(() => {
persistedModel = {
id: 'model-1',
name: 'test-model',
display_name: 'Test Model',
is_active: true,
default_tiered_pricing: pricing,
config: {
streaming: true,
models_dev_pricing_source: {
provider_id: 'openai',
provider_name: 'Old OpenAI label',
},
},
provider_count: 1,
active_provider_count: 1,
usage_count: 0,
created_at: '2026-09-03T00:00:00Z',
}
for (const mock of Object.values(apiMocks)) mock.mockReset()
for (const mock of Object.values(interactionMocks)) mock.mockReset()
apiMocks.listGlobalModels.mockImplementation(async () => ({
models: [cloneModel(persistedModel)],
total: 1,
}))
apiMocks.updateGlobalModel.mockImplementation(async (
_modelId: string,
payload: Partial<GlobalModelResponse>,
) => {
persistedModel = { ...persistedModel, ...payload }
return cloneModel(persistedModel)
})
apiMocks.getModelsDevList.mockResolvedValue([onlineModel])
apiMocks.getGlobalModelProviders.mockResolvedValue({ providers: [] })
apiMocks.getProvidersSummary.mockResolvedValue({ items: [] })
interactionMocks.confirm.mockResolvedValue(true)
interactionMocks.confirmDanger.mockResolvedValue(true)
})
afterEach(() => {
mountedApp?.unmount()
mountedRoot?.remove()
mountedApp = null
mountedRoot = null
document.body.innerHTML = ''
})
describe('ModelManagement pricing-source workflow', () => {
it('keeps list selection in batch management and refreshes stale source metadata when prices match', async () => {
mountView()
await settle()
const desktopCheckbox = document.body.querySelector<HTMLInputElement>(
'[data-testid="model-select-desktop-model-1"]',
)
const mobileCheckbox = document.body.querySelector<HTMLInputElement>(
'[data-testid="model-select-mobile-model-1"]',
)
expect(desktopCheckbox).not.toBeNull()
expect(mobileCheckbox).not.toBeNull()
desktopCheckbox!.checked = true
desktopCheckbox!.dispatchEvent(new Event('change', { bubbles: true }))
await settle()
expect(mobileCheckbox!.checked).toBe(true)
expect(document.body.textContent).toContain('已选 1 个')
findButton('批量操作 (1)').click()
await settle()
expect(document.body.textContent).toContain('已选择 1 个')
expect(document.body.textContent).toContain('来源待保存')
findButton('同步价格与来源 (1)').click()
await settle()
expect(interactionMocks.confirm).toHaveBeenCalledOnce()
expect(apiMocks.updateGlobalModel).toHaveBeenCalledWith('model-1', {
default_tiered_pricing: pricing,
config: {
streaming: true,
models_dev_pricing_source: {
provider_id: 'openai',
provider_name: 'OpenAI',
},
},
})
expect(persistedModel.config).toEqual({
streaming: true,
models_dev_pricing_source: {
provider_id: 'openai',
provider_name: 'OpenAI',
},
})
})
it('renders the new source and selection controls in English', async () => {
mountView()
await settle()
setI18nLocale('en-US')
await settle()
expect(document.body.textContent).toContain('Price source')
expect(document.body.textContent).toContain('Batch manage')
expect(document.body.querySelector(
'[aria-label="Select model Test Model"]',
)).not.toBeNull()
expect(document.body.querySelector(
'[data-testid="model-pricing-source-model-1"]',
)?.getAttribute('aria-label')).toContain('Current source: Old OpenAI label')
})
it('migrates a legacy browser-only source into the model database config', async () => {
persistedModel.config = { streaming: true }
localStorage.setItem('aether:models-dev-pricing-sources:v1', JSON.stringify({
version: 1,
models: {
'model-1': {
provider_id: 'openai',
provider_name: 'OpenAI',
},
},
}))
mountView()
await settle()
expect(apiMocks.updateGlobalModel).toHaveBeenCalledWith('model-1', {
config: {
streaming: true,
models_dev_pricing_source: {
provider_id: 'openai',
provider_name: 'OpenAI',
},
},
})
expect(persistedModel.config).toHaveProperty(
'models_dev_pricing_source.provider_id',
'openai',
)
})
})