mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 14:10:19 +08:00
feat(models): sync global model prices from online catalog
This commit is contained in:
@@ -99,7 +99,7 @@
|
||||
v-for="item in expandedProviderGroup.models"
|
||||
:key="item.modelId"
|
||||
type="button"
|
||||
class="group relative flex min-h-[152px] min-w-0 flex-col rounded-xl border bg-card p-4 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-md"
|
||||
class="group relative flex min-h-[152px] min-w-0 flex-col rounded-lg border bg-card p-4 text-left shadow-sm transition-[border-color,box-shadow,transform,background-color] duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:shadow-md active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
:class="selectedModel?.modelId === item.modelId && selectedModel?.providerId === item.providerId
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border/70'"
|
||||
@@ -122,10 +122,25 @@
|
||||
<span class="block truncate text-sm font-semibold leading-5">{{ item.modelName }}</span>
|
||||
<span class="block truncate font-mono text-[10px] text-muted-foreground">{{ item.modelId }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="item.family"
|
||||
class="max-w-[88px] shrink-0 truncate rounded-md bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>{{ item.family }}</span>
|
||||
<span class="flex shrink-0 flex-col items-end gap-1">
|
||||
<span
|
||||
v-if="getExistingModel(item)"
|
||||
class="inline-flex h-5 items-center gap-1.5 text-[10px] font-medium text-muted-foreground"
|
||||
:title="`已添加 · ${getPricingSyncLabel(item)}`"
|
||||
>
|
||||
<CircleCheck class="h-3 w-3 text-foreground/50" />
|
||||
<span>已添加</span>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
:class="getPricingSyncIndicatorClass(item)"
|
||||
/>
|
||||
<span>{{ getPricingSyncLabel(item) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="item.family"
|
||||
class="max-w-[88px] truncate rounded-md bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>{{ item.family }}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="mt-2 flex min-h-5 flex-wrap gap-1">
|
||||
@@ -175,7 +190,7 @@
|
||||
</span>
|
||||
<span
|
||||
v-if="item.inputPrice !== undefined || item.outputPrice !== undefined"
|
||||
class="shrink-0 text-right font-medium text-foreground/70"
|
||||
class="shrink-0 text-right font-medium tabular-nums text-foreground/70"
|
||||
>
|
||||
<span class="block">输入 ${{ formatModelPrice(item.inputPrice) }}/M</span>
|
||||
<span class="block">输出 ${{ formatModelPrice(item.outputPrice) }}/M</span>
|
||||
@@ -255,12 +270,80 @@
|
||||
返回选择模型
|
||||
</Button>
|
||||
</div>
|
||||
<section
|
||||
v-if="selectedExistingModel"
|
||||
class="mb-4 space-y-3 rounded-lg border border-sky-500/20 bg-sky-500/5 p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">
|
||||
同步在线价格
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
仅更新该模型的价格配置,不修改名称、能力或其他设置。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Label class="text-xs font-normal">自动应用在线价格</Label>
|
||||
<Switch
|
||||
:model-value="autoApplyOnlinePricing"
|
||||
:disabled="!selectedModel?.tieredPricing && !autoApplyOnlinePricing"
|
||||
aria-label="选择已有模型时自动应用在线价格"
|
||||
@update:model-value="setAutoApplyOnlinePricing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!selectedModel?.tieredPricing"
|
||||
class="rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
在线目录未提供该模型的价格,暂时无法同步。
|
||||
</div>
|
||||
<div
|
||||
v-else-if="onlinePricingMatchesExisting"
|
||||
class="rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-800 dark:text-emerald-200"
|
||||
>
|
||||
当前价格与在线目录一致,无需更新。
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 gap-2 sm:grid-cols-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border px-3 py-2.5 text-left transition-colors"
|
||||
:class="pricingSource === 'current'
|
||||
? 'border-primary bg-background ring-1 ring-primary'
|
||||
: 'border-border/70 bg-background/60 hover:border-border'"
|
||||
@click="restoreExistingPricing"
|
||||
>
|
||||
<span class="block text-xs font-medium">保留当前价格</span>
|
||||
<span class="mt-1 block text-[11px] text-muted-foreground">{{ formatPricingSummary(selectedExistingModel.default_tiered_pricing) }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border px-3 py-2.5 text-left transition-colors"
|
||||
:class="pricingSource === 'online'
|
||||
? 'border-primary bg-background ring-1 ring-primary'
|
||||
: 'border-border/70 bg-background/60 hover:border-border'"
|
||||
@click="applyOnlinePricing"
|
||||
>
|
||||
<span class="block text-xs font-medium">使用在线价格</span>
|
||||
<span class="mt-1 block text-[11px] text-muted-foreground">{{ formatPricingSummary(selectedModel.tieredPricing) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
开启后会记住当前在线来源,下次选择该模型时自动载入价格;仍需点击保存后写入。
|
||||
</p>
|
||||
</section>
|
||||
<form
|
||||
class="space-y-5"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<section
|
||||
v-if="!selectedExistingModel"
|
||||
ref="basicInfoSection"
|
||||
class="space-y-3 rounded-lg border bg-card p-4"
|
||||
>
|
||||
@@ -366,7 +449,35 @@
|
||||
</section>
|
||||
|
||||
<!-- 价格配置 -->
|
||||
<section class="space-y-3 rounded-lg border bg-card p-4">
|
||||
<section
|
||||
v-if="selectedExistingModel"
|
||||
class="space-y-3 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">
|
||||
{{ pricingSource === 'online' ? '在线价格预览' : '当前价格' }}
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
可在同步前检查阶梯价格;在线价格应用后仍可微调。
|
||||
</p>
|
||||
</div>
|
||||
<TieredPricingEditor
|
||||
ref="tieredPricingEditorRef"
|
||||
v-model="tieredPricing"
|
||||
:auto-fill-missing-cache-prices="false"
|
||||
:show-token-pricing="true"
|
||||
:show-image-pricing="tieredPricingHasImageOutputPricing(tieredPricing)"
|
||||
:show-image-editor="tieredPricingHasImageOutputPricing(tieredPricing)"
|
||||
:show-processing-tier-controls="false"
|
||||
:show-processing-tier-multiplier-controls="true"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- 价格配置 -->
|
||||
<section
|
||||
v-else
|
||||
class="space-y-3 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<h4 class="font-medium text-sm">
|
||||
选择计费模式
|
||||
</h4>
|
||||
@@ -551,14 +662,14 @@
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isEditMode || presetPanelCollapsed"
|
||||
:disabled="submitting || !form.name || !form.display_name"
|
||||
:disabled="submitting || !form.name || !form.display_name || (!!selectedExistingModel && !canSubmitPriceSync)"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Loader2
|
||||
v-if="submitting"
|
||||
class="w-4 h-4 mr-2 animate-spin"
|
||||
/>
|
||||
{{ isEditMode ? '保存' : '添加' }}
|
||||
{{ isEditMode ? '保存' : selectedExistingModel ? priceSyncSubmitLabel : '添加' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedModel && !isEditMode && presetPanelCollapsed"
|
||||
@@ -577,10 +688,10 @@ import { ref, computed, nextTick, watch } from 'vue'
|
||||
import {
|
||||
Loader2, Layers, SquarePen,
|
||||
Search, ChevronLeft, ChevronRight, Plus, Trash2, Check,
|
||||
BrainCircuit, Eye, Wrench, Braces, Database, PackageOpen
|
||||
BrainCircuit, Eye, Wrench, Braces, Database, PackageOpen, CircleCheck
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
Dialog, Button, Input, Label, Checkbox,
|
||||
Dialog, Button, Input, Label, Checkbox, Switch,
|
||||
Tabs, TabsContent, TabsList, TabsTrigger,
|
||||
} from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -596,6 +707,7 @@ import {
|
||||
} from '@/api/models-dev'
|
||||
import {
|
||||
createGlobalModel,
|
||||
listGlobalModels,
|
||||
updateGlobalModel,
|
||||
type GlobalModelResponse,
|
||||
} from '@/api/global-models'
|
||||
@@ -605,6 +717,10 @@ import {
|
||||
buildGlobalModelCreatePayload,
|
||||
buildGlobalModelUpdatePayload,
|
||||
cloneTieredPricingConfig,
|
||||
findGlobalModelByName,
|
||||
mergeModelsDevPricingPreference,
|
||||
readModelsDevPricingPreference,
|
||||
tieredPricingConfigsEqual,
|
||||
} from './global-model-form-helpers'
|
||||
import { tieredPricingHasImageOutputPricing } from '../utils/tiered-pricing'
|
||||
|
||||
@@ -627,11 +743,88 @@ const basicInfoSection = ref<HTMLElement | null>(null)
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const allModelsCache = ref<ModelsDevModelItem[]>([]) // 全部模型(缓存)
|
||||
const existingModelsCache = ref<GlobalModelResponse[]>([])
|
||||
const selectedModel = ref<ModelsDevModelItem | null>(null)
|
||||
const expandedProvider = ref<string | null>(null)
|
||||
const providerLogoScroller = ref<HTMLElement | null>(null)
|
||||
const presetPanelCollapsed = ref(false)
|
||||
const billingMode = ref('token')
|
||||
const autoApplyOnlinePricing = ref(false)
|
||||
const pricingSource = ref<'current' | 'online'>('current')
|
||||
|
||||
function getExistingModel(model: ModelsDevModelItem): GlobalModelResponse | undefined {
|
||||
return findGlobalModelByName(existingModelsCache.value, model.modelId)
|
||||
}
|
||||
|
||||
type PricingSyncState = 'same' | 'different' | 'unavailable'
|
||||
|
||||
function getPricingSyncState(model: ModelsDevModelItem): PricingSyncState {
|
||||
const existingModel = getExistingModel(model)
|
||||
if (!existingModel || !model.tieredPricing) return 'unavailable'
|
||||
return tieredPricingConfigsEqual(existingModel.default_tiered_pricing, model.tieredPricing)
|
||||
? 'same'
|
||||
: 'different'
|
||||
}
|
||||
|
||||
function getPricingSyncLabel(model: ModelsDevModelItem): string {
|
||||
const state = getPricingSyncState(model)
|
||||
if (state === 'same') return '价格一致'
|
||||
if (state === 'different') return '价格可更新'
|
||||
return '无在线价格'
|
||||
}
|
||||
|
||||
function getPricingSyncIndicatorClass(model: ModelsDevModelItem): string {
|
||||
const state = getPricingSyncState(model)
|
||||
if (state === 'same') return 'bg-emerald-500'
|
||||
if (state === 'different') return 'bg-amber-500'
|
||||
return 'bg-muted-foreground/45'
|
||||
}
|
||||
|
||||
const selectedExistingModel = computed(() => (
|
||||
selectedModel.value ? getExistingModel(selectedModel.value) : undefined
|
||||
))
|
||||
|
||||
const onlinePricingMatchesExisting = computed(() => (
|
||||
!!selectedExistingModel.value
|
||||
&& !!selectedModel.value?.tieredPricing
|
||||
&& tieredPricingConfigsEqual(
|
||||
selectedExistingModel.value.default_tiered_pricing,
|
||||
selectedModel.value.tieredPricing,
|
||||
)
|
||||
))
|
||||
|
||||
const selectedPricingPreference = computed(() => (
|
||||
readModelsDevPricingPreference(selectedExistingModel.value?.config)
|
||||
))
|
||||
|
||||
const pricingPreferenceChanged = computed(() => {
|
||||
if (!selectedExistingModel.value || !selectedModel.value) return false
|
||||
const currentPreference = selectedPricingPreference.value
|
||||
if (!autoApplyOnlinePricing.value) return currentPreference !== null
|
||||
return currentPreference?.provider_id !== selectedModel.value.providerId
|
||||
})
|
||||
|
||||
const canSubmitPriceSync = computed(() => (
|
||||
!!selectedExistingModel.value
|
||||
&& (
|
||||
pricingPreferenceChanged.value
|
||||
|| (
|
||||
!!selectedModel.value?.tieredPricing
|
||||
&& !onlinePricingMatchesExisting.value
|
||||
&& pricingSource.value === 'online'
|
||||
)
|
||||
)
|
||||
))
|
||||
|
||||
const priceSyncSubmitLabel = computed(() => {
|
||||
if (pricingPreferenceChanged.value) {
|
||||
return autoApplyOnlinePricing.value ? '保存并同步价格' : '关闭自动应用'
|
||||
}
|
||||
if (!selectedModel.value?.tieredPricing) return '暂无在线价格'
|
||||
if (onlinePricingMatchesExisting.value) return '价格已是最新'
|
||||
if (pricingSource.value !== 'online') return '请选择在线价格'
|
||||
return '同步价格'
|
||||
})
|
||||
|
||||
function formatTokenLimit(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
@@ -650,6 +843,12 @@ function formatModelPrice(value?: number): string {
|
||||
return value.toFixed(precision).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
function formatPricingSummary(pricing?: TieredPricingConfig | null): string {
|
||||
const firstTier = pricing?.tiers?.[0]
|
||||
if (!firstTier) return '未配置 Token 价格'
|
||||
return `输入 $${formatModelPrice(firstTier.input_price_per_1m)}/M · 输出 $${formatModelPrice(firstTier.output_price_per_1m)}/M`
|
||||
}
|
||||
|
||||
// 当前显示的模型列表:有搜索词时用全部,否则只用官方
|
||||
const allModels = computed(() => {
|
||||
if (searchQuery.value) {
|
||||
@@ -1043,18 +1242,31 @@ function fillVideoResolutionPricePreset(preset: 'common' | 'sora' | 'veo') {
|
||||
}
|
||||
|
||||
|
||||
// 加载模型列表
|
||||
async function loadExistingModels() {
|
||||
const models: GlobalModelResponse[] = []
|
||||
let total = 0
|
||||
do {
|
||||
const response = await listGlobalModels({ skip: models.length, limit: 1000 })
|
||||
models.push(...response.models)
|
||||
total = response.total
|
||||
if (response.models.length === 0) break
|
||||
} while (models.length < total)
|
||||
existingModelsCache.value = models
|
||||
}
|
||||
|
||||
// 加载在线目录和已有模型列表
|
||||
async function loadModels() {
|
||||
if (allModelsCache.value.length > 0) return
|
||||
loading.value = true
|
||||
try {
|
||||
// 只加载一次全部模型,过滤在 computed 中完成
|
||||
allModelsCache.value = await getModelsDevList(false)
|
||||
} catch (err) {
|
||||
log.error('Failed to load models:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
await Promise.all([
|
||||
allModelsCache.value.length > 0
|
||||
? Promise.resolve()
|
||||
: getModelsDevList(false)
|
||||
.then(models => { allModelsCache.value = models })
|
||||
.catch(err => log.error('Failed to load online models:', err)),
|
||||
loadExistingModels()
|
||||
.catch(err => log.error('Failed to load existing models:', err)),
|
||||
])
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 打开对话框时加载数据
|
||||
@@ -1073,6 +1285,20 @@ function selectModel(model: ModelsDevModelItem) {
|
||||
selectedModel.value = model
|
||||
expandedProvider.value = model.providerId
|
||||
|
||||
const existingModel = getExistingModel(model)
|
||||
if (existingModel) {
|
||||
populateFormFromGlobalModel(existingModel)
|
||||
pricingSource.value = 'current'
|
||||
const savedPreference = readModelsDevPricingPreference(existingModel.config)
|
||||
autoApplyOnlinePricing.value = savedPreference?.provider_id === model.providerId
|
||||
if (autoApplyOnlinePricing.value && model.tieredPricing && !onlinePricingMatchesExisting.value) {
|
||||
applyOnlinePricing()
|
||||
}
|
||||
presetPanelCollapsed.value = true
|
||||
scrollToBasicInformation()
|
||||
return
|
||||
}
|
||||
|
||||
// 构建 config
|
||||
const config: Record<string, unknown> = {
|
||||
streaming: model.supportsEmbedding ? false : true,
|
||||
@@ -1121,6 +1347,27 @@ function selectModel(model: ModelsDevModelItem) {
|
||||
scrollToBasicInformation()
|
||||
}
|
||||
|
||||
function applyOnlinePricing() {
|
||||
if (!selectedModel.value?.tieredPricing) return
|
||||
tieredPricing.value = cloneTieredPricingConfig(selectedModel.value.tieredPricing)
|
||||
pricingSource.value = 'online'
|
||||
}
|
||||
|
||||
function restoreExistingPricing() {
|
||||
if (!selectedExistingModel.value) return
|
||||
tieredPricing.value = cloneTieredPricingConfig(selectedExistingModel.value.default_tiered_pricing)
|
||||
pricingSource.value = 'current'
|
||||
}
|
||||
|
||||
function setAutoApplyOnlinePricing(enabled: boolean) {
|
||||
autoApplyOnlinePricing.value = enabled
|
||||
if (enabled && selectedExistingModel.value && !onlinePricingMatchesExisting.value) {
|
||||
applyOnlinePricing()
|
||||
} else if (!enabled) {
|
||||
restoreExistingPricing()
|
||||
}
|
||||
}
|
||||
|
||||
// 清除选择(手动填写)
|
||||
function clearSelection() {
|
||||
imageGenerationExplicitOverride.value = null
|
||||
@@ -1129,6 +1376,7 @@ function clearSelection() {
|
||||
tieredPricing.value = null
|
||||
videoResolutionPrices.value = []
|
||||
billingMode.value = 'token'
|
||||
pricingSource.value = 'current'
|
||||
}
|
||||
|
||||
// Logo 加载失败处理
|
||||
@@ -1148,35 +1396,28 @@ function resetForm() {
|
||||
expandedProvider.value = null
|
||||
presetPanelCollapsed.value = false
|
||||
billingMode.value = 'token'
|
||||
autoApplyOnlinePricing.value = false
|
||||
pricingSource.value = 'current'
|
||||
}
|
||||
|
||||
// 加载模型数据(编辑模式)
|
||||
function loadModelData() {
|
||||
if (!props.model) return
|
||||
function populateFormFromGlobalModel(model: GlobalModelResponse) {
|
||||
imageGenerationExplicitOverride.value = null
|
||||
// 先重置创建模式的残留状态
|
||||
selectedModel.value = null
|
||||
searchQuery.value = ''
|
||||
expandedProvider.value = null
|
||||
presetPanelCollapsed.value = false
|
||||
|
||||
const modelTieredPricing = props.model.default_tiered_pricing
|
||||
? JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
|
||||
const modelTieredPricing = model.default_tiered_pricing
|
||||
? cloneTieredPricingConfig(model.default_tiered_pricing)
|
||||
: null
|
||||
const supportedCapabilities = new Set(props.model.supported_capabilities || [])
|
||||
const supportedCapabilities = new Set(model.supported_capabilities || [])
|
||||
if (tieredPricingHasImageOutputPricing(modelTieredPricing)) {
|
||||
supportedCapabilities.add('image_generation')
|
||||
}
|
||||
|
||||
form.value = {
|
||||
name: props.model.name,
|
||||
display_name: props.model.display_name,
|
||||
default_price_per_request: props.model.default_price_per_request,
|
||||
name: model.name,
|
||||
display_name: model.display_name,
|
||||
default_price_per_request: model.default_price_per_request,
|
||||
supported_capabilities: [...supportedCapabilities],
|
||||
config: props.model.config ? { ...props.model.config } : { streaming: true },
|
||||
is_active: props.model.is_active,
|
||||
config: model.config ? { ...model.config } : { streaming: true },
|
||||
is_active: model.is_active,
|
||||
}
|
||||
// 确保 tieredPricing 也被正确设置或重置
|
||||
tieredPricing.value = modelTieredPricing
|
||||
loadVideoPricingFromConfig()
|
||||
if (videoResolutionPrices.value.length > 0) {
|
||||
@@ -1190,6 +1431,18 @@ function loadModelData() {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载模型数据(编辑模式)
|
||||
function loadModelData() {
|
||||
if (!props.model) return
|
||||
// 先重置创建模式的残留状态
|
||||
selectedModel.value = null
|
||||
searchQuery.value = ''
|
||||
expandedProvider.value = null
|
||||
presetPanelCollapsed.value = false
|
||||
pricingSource.value = 'current'
|
||||
populateFormFromGlobalModel(props.model)
|
||||
}
|
||||
|
||||
// 使用 useFormDialog 统一处理对话框逻辑
|
||||
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
isOpen: () => props.open,
|
||||
@@ -1235,13 +1488,43 @@ async function handleSubmit() {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEditMode.value && props.model) {
|
||||
const existingModel = selectedExistingModel.value
|
||||
if (existingModel) {
|
||||
if (!canSubmitPriceSync.value) {
|
||||
showError('请先选择使用在线价格')
|
||||
return
|
||||
}
|
||||
const preferenceWillChange = pricingPreferenceChanged.value
|
||||
const updateData: Parameters<typeof updateGlobalModel>[1] = {
|
||||
default_tiered_pricing: finalTieredPricing,
|
||||
}
|
||||
if (preferenceWillChange && selectedModel.value) {
|
||||
updateData.config = mergeModelsDevPricingPreference(
|
||||
existingModel.config,
|
||||
autoApplyOnlinePricing.value
|
||||
? {
|
||||
enabled: true,
|
||||
provider_id: selectedModel.value.providerId,
|
||||
provider_name: selectedModel.value.providerName,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
}
|
||||
await updateGlobalModel(existingModel.id, updateData)
|
||||
existingModel.default_tiered_pricing = cloneTieredPricingConfig(finalTieredPricing)
|
||||
if ('config' in updateData) existingModel.config = updateData.config
|
||||
success(preferenceWillChange ? '模型在线价格设置已保存' : '模型价格同步成功')
|
||||
reopenPresetPanel()
|
||||
emit('success')
|
||||
return
|
||||
} else if (isEditMode.value && props.model) {
|
||||
const updateData = buildGlobalModelUpdatePayload(form.value, finalTieredPricing)
|
||||
await updateGlobalModel(props.model.id, updateData)
|
||||
success('模型更新成功')
|
||||
} else {
|
||||
const createData = buildGlobalModelCreatePayload(form.value, finalTieredPricing)
|
||||
await createGlobalModel(createData)
|
||||
const createdModel = await createGlobalModel(createData)
|
||||
existingModelsCache.value.unshift(createdModel)
|
||||
success('模型创建成功')
|
||||
clearSelection()
|
||||
emit('success')
|
||||
|
||||
+117
@@ -9,6 +9,7 @@ import {
|
||||
} from 'vue'
|
||||
|
||||
import type { ModelsDevModelItem } from '@/api/models-dev'
|
||||
import type { GlobalModelResponse } from '@/api/global-models'
|
||||
import GlobalModelFormDialog from '../GlobalModelFormDialog.vue'
|
||||
|
||||
const modelsDevMocks = vi.hoisted(() => ({
|
||||
@@ -17,6 +18,7 @@ const modelsDevMocks = vi.hoisted(() => ({
|
||||
|
||||
const globalModelMocks = vi.hoisted(() => ({
|
||||
createGlobalModel: vi.fn(),
|
||||
listGlobalModels: vi.fn(),
|
||||
updateGlobalModel: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -27,6 +29,7 @@ vi.mock('@/api/models-dev', () => ({
|
||||
|
||||
vi.mock('@/api/global-models', () => ({
|
||||
createGlobalModel: globalModelMocks.createGlobalModel,
|
||||
listGlobalModels: globalModelMocks.listGlobalModels,
|
||||
updateGlobalModel: globalModelMocks.updateGlobalModel,
|
||||
}))
|
||||
|
||||
@@ -89,6 +92,24 @@ const freshPreset: ModelsDevModelItem = {
|
||||
},
|
||||
}
|
||||
|
||||
function buildExistingStaleModel(): GlobalModelResponse {
|
||||
return {
|
||||
id: 'global-stale-model',
|
||||
name: 'stale-model',
|
||||
display_name: 'Configured Stale Model',
|
||||
is_active: true,
|
||||
default_tiered_pricing: {
|
||||
tiers: [{
|
||||
up_to: null,
|
||||
input_price_per_1m: 9,
|
||||
output_price_per_1m: 18,
|
||||
}],
|
||||
},
|
||||
config: { streaming: true },
|
||||
created_at: '2026-07-23T00:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
function mountDialog() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
@@ -144,7 +165,10 @@ beforeEach(() => {
|
||||
modelsDevMocks.getModelsDevList.mockResolvedValue([stalePreset, freshPreset])
|
||||
globalModelMocks.createGlobalModel.mockReset()
|
||||
globalModelMocks.createGlobalModel.mockResolvedValue({})
|
||||
globalModelMocks.listGlobalModels.mockReset()
|
||||
globalModelMocks.listGlobalModels.mockResolvedValue({ models: [], total: 0 })
|
||||
globalModelMocks.updateGlobalModel.mockReset()
|
||||
globalModelMocks.updateGlobalModel.mockResolvedValue({})
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
value: vi.fn(),
|
||||
configurable: true,
|
||||
@@ -278,4 +302,97 @@ describe('GlobalModelFormDialog preset replacement', () => {
|
||||
})
|
||||
expect(payload.default_tiered_pricing.processing_tiers).not.toHaveProperty('standard')
|
||||
})
|
||||
|
||||
it('marks an existing model and updates only its online pricing after confirmation', async () => {
|
||||
const existingStaleModel = buildExistingStaleModel()
|
||||
globalModelMocks.listGlobalModels.mockResolvedValue({
|
||||
models: [existingStaleModel],
|
||||
total: 1,
|
||||
})
|
||||
mountDialog()
|
||||
await settle()
|
||||
|
||||
expect(document.body.textContent).toContain('已添加')
|
||||
expect(document.body.textContent).toContain('价格可更新')
|
||||
|
||||
findButton('Stale Model').click()
|
||||
await settle()
|
||||
|
||||
expect(document.body.textContent).toContain('仅更新该模型的价格配置')
|
||||
expect(document.body.querySelector<HTMLInputElement>('[data-testid="tier-input-price"]')?.value).toBe('9')
|
||||
expect(findExactButton('请选择在线价格').disabled).toBe(true)
|
||||
|
||||
findButton('使用在线价格').click()
|
||||
await settle()
|
||||
|
||||
expect(document.body.querySelector<HTMLInputElement>('[data-testid="tier-input-price"]')?.value).toBe('1')
|
||||
findExactButton('同步价格').click()
|
||||
await settle()
|
||||
|
||||
expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledOnce()
|
||||
expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith(
|
||||
existingStaleModel.id,
|
||||
{ default_tiered_pricing: stalePreset.tieredPricing },
|
||||
)
|
||||
expect(globalModelMocks.createGlobalModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('auto-applies online pricing without submitting it', async () => {
|
||||
const existingStaleModel = buildExistingStaleModel()
|
||||
globalModelMocks.listGlobalModels.mockResolvedValue({
|
||||
models: [existingStaleModel],
|
||||
total: 1,
|
||||
})
|
||||
mountDialog()
|
||||
await settle()
|
||||
|
||||
findButton('Stale Model').click()
|
||||
await settle()
|
||||
const autoApplySwitch = document.body.querySelector<HTMLButtonElement>(
|
||||
'[role="switch"][aria-label="选择已有模型时自动应用在线价格"]',
|
||||
)
|
||||
if (!autoApplySwitch) throw new Error('Missing automatic online pricing switch')
|
||||
autoApplySwitch.click()
|
||||
await settle()
|
||||
|
||||
expect(document.body.querySelector<HTMLInputElement>('[data-testid="tier-input-price"]')?.value).toBe('1')
|
||||
expect(findExactButton('保存并同步价格').disabled).toBe(false)
|
||||
expect(globalModelMocks.updateGlobalModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists the selected online pricing source when automatic apply is enabled', async () => {
|
||||
const existingStaleModel = buildExistingStaleModel()
|
||||
globalModelMocks.listGlobalModels.mockResolvedValue({
|
||||
models: [existingStaleModel],
|
||||
total: 1,
|
||||
})
|
||||
mountDialog()
|
||||
await settle()
|
||||
|
||||
findButton('Stale Model').click()
|
||||
await settle()
|
||||
const autoApplySwitch = document.body.querySelector<HTMLButtonElement>(
|
||||
'[role="switch"][aria-label="选择已有模型时自动应用在线价格"]',
|
||||
)
|
||||
if (!autoApplySwitch) throw new Error('Missing automatic online pricing switch')
|
||||
autoApplySwitch.click()
|
||||
await settle()
|
||||
findExactButton('保存并同步价格').click()
|
||||
await settle()
|
||||
|
||||
expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith(
|
||||
existingStaleModel.id,
|
||||
{
|
||||
default_tiered_pricing: stalePreset.tieredPricing,
|
||||
config: {
|
||||
streaming: true,
|
||||
models_dev_pricing: {
|
||||
enabled: true,
|
||||
provider_id: 'openai',
|
||||
provider_name: 'OpenAI',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,9 +3,14 @@ import { reactive } from 'vue'
|
||||
|
||||
import {
|
||||
EMBEDDING_API_FORMATS,
|
||||
buildGlobalModelPriceSyncPlan,
|
||||
buildGlobalModelCreatePayload,
|
||||
buildGlobalModelUpdatePayload,
|
||||
cloneTieredPricingConfig,
|
||||
findGlobalModelByName,
|
||||
mergeModelsDevPricingPreference,
|
||||
readModelsDevPricingPreference,
|
||||
tieredPricingConfigsEqual,
|
||||
} from '../global-model-form-helpers'
|
||||
import type { TieredPricingConfig } from '@/api/endpoints/types'
|
||||
|
||||
@@ -89,4 +94,92 @@ describe('global model form pricing presets', () => {
|
||||
cloned.tiers[0].input_price_per_1m = 9
|
||||
expect(pricing.tiers[0].input_price_per_1m).toBe(3)
|
||||
})
|
||||
|
||||
it('matches existing models by normalized model ID', () => {
|
||||
const existingModel = { id: 'model-1', name: ' Claude-Sonnet-5 ' }
|
||||
|
||||
expect(findGlobalModelByName([existingModel], 'claude-sonnet-5')).toBe(existingModel)
|
||||
expect(findGlobalModelByName([existingModel], 'claude-opus-5')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('compares pricing independently of object key order', () => {
|
||||
const currentPricing = {
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 2 },
|
||||
},
|
||||
tiers: [{
|
||||
output_price_per_1m: 15,
|
||||
input_price_per_1m: 3,
|
||||
up_to: null,
|
||||
}],
|
||||
} as TieredPricingConfig
|
||||
const onlinePricing = {
|
||||
tiers: [{
|
||||
up_to: null,
|
||||
input_price_per_1m: 3,
|
||||
output_price_per_1m: 15,
|
||||
}],
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 2 },
|
||||
},
|
||||
} as TieredPricingConfig
|
||||
|
||||
expect(tieredPricingConfigsEqual(currentPricing, onlinePricing)).toBe(true)
|
||||
onlinePricing.tiers[0].output_price_per_1m = 16
|
||||
expect(tieredPricingConfigsEqual(currentPricing, onlinePricing)).toBe(false)
|
||||
})
|
||||
|
||||
it('groups models by their selected provider pricing sync state', () => {
|
||||
const makeGlobalModel = (id: string, name: string, inputPrice: number) => ({
|
||||
id,
|
||||
name,
|
||||
display_name: name,
|
||||
is_active: true,
|
||||
default_tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: inputPrice, output_price_per_1m: 10 }],
|
||||
},
|
||||
created_at: '2026-07-23T00:00:00Z',
|
||||
})
|
||||
const makeOnlineModel = (modelId: string, inputPrice?: number) => ({
|
||||
providerId: 'anthropic',
|
||||
providerName: 'Anthropic',
|
||||
modelId,
|
||||
modelName: modelId,
|
||||
tieredPricing: inputPrice === undefined
|
||||
? undefined
|
||||
: { tiers: [{ up_to: null, input_price_per_1m: inputPrice, output_price_per_1m: 10 }] },
|
||||
})
|
||||
const currentModel = makeGlobalModel('current', 'current-model', 2)
|
||||
const staleModel = makeGlobalModel('stale', 'stale-model', 3)
|
||||
const unavailableModel = makeGlobalModel('missing', 'missing-model', 4)
|
||||
|
||||
const plan = buildGlobalModelPriceSyncPlan(
|
||||
[currentModel, staleModel, unavailableModel],
|
||||
[makeOnlineModel('current-model', 2), makeOnlineModel('stale-model', 5)],
|
||||
)
|
||||
|
||||
expect(plan.unchanged.map(entry => entry.model.id)).toEqual(['current'])
|
||||
expect(plan.syncable.map(entry => entry.model.id)).toEqual(['stale'])
|
||||
expect(plan.unavailable.map(model => model.id)).toEqual(['missing'])
|
||||
})
|
||||
|
||||
it('persists and removes the online pricing preference without replacing other config', () => {
|
||||
const config = {
|
||||
streaming: true,
|
||||
billing: { video: { price_per_second_by_resolution: { '720p': 0.04 } } },
|
||||
}
|
||||
const enabledConfig = mergeModelsDevPricingPreference(config, {
|
||||
enabled: true,
|
||||
provider_id: 'anthropic',
|
||||
provider_name: 'Anthropic',
|
||||
})
|
||||
|
||||
expect(readModelsDevPricingPreference(enabledConfig)).toEqual({
|
||||
enabled: true,
|
||||
provider_id: 'anthropic',
|
||||
provider_name: 'Anthropic',
|
||||
})
|
||||
expect(enabledConfig).toMatchObject(config)
|
||||
expect(mergeModelsDevPricingPreference(enabledConfig, null)).toEqual(config)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GlobalModelCreate, GlobalModelUpdate } from '@/api/global-models'
|
||||
import type { GlobalModelCreate, GlobalModelResponse, GlobalModelUpdate } from '@/api/global-models'
|
||||
import type { TieredPricingConfig } from '@/api/endpoints/types'
|
||||
import type { ModelsDevModelItem } from '@/api/models-dev'
|
||||
|
||||
export const EMBEDDING_API_FORMATS = [
|
||||
'openai:embedding',
|
||||
@@ -31,6 +32,116 @@ export function cloneTieredPricingConfig(
|
||||
return JSON.parse(JSON.stringify(pricing)) as TieredPricingConfig
|
||||
}
|
||||
|
||||
export function findGlobalModelByName<T extends { name: string }>(
|
||||
models: T[],
|
||||
modelName: string,
|
||||
): T | undefined {
|
||||
const normalizedName = modelName.trim().toLowerCase()
|
||||
return models.find(model => model.name.trim().toLowerCase() === normalizedName)
|
||||
}
|
||||
|
||||
function normalizeJsonValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalizeJsonValue)
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, entryValue]) => entryValue !== undefined)
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||||
.map(([key, entryValue]) => [key, normalizeJsonValue(entryValue)]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function tieredPricingConfigsEqual(
|
||||
currentPricing: TieredPricingConfig | null | undefined,
|
||||
onlinePricing: TieredPricingConfig | null | undefined,
|
||||
): boolean {
|
||||
return JSON.stringify(normalizeJsonValue(currentPricing ?? null))
|
||||
=== JSON.stringify(normalizeJsonValue(onlinePricing ?? null))
|
||||
}
|
||||
|
||||
export interface GlobalModelPriceSyncEntry {
|
||||
model: GlobalModelResponse
|
||||
onlineModel: ModelsDevModelItem
|
||||
}
|
||||
|
||||
export interface GlobalModelPriceSyncPlan {
|
||||
syncable: GlobalModelPriceSyncEntry[]
|
||||
unchanged: GlobalModelPriceSyncEntry[]
|
||||
unavailable: GlobalModelResponse[]
|
||||
}
|
||||
|
||||
export interface ModelsDevPricingPreference {
|
||||
enabled: true
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
}
|
||||
|
||||
const MODELS_DEV_PRICING_CONFIG_KEY = 'models_dev_pricing'
|
||||
|
||||
export function readModelsDevPricingPreference(
|
||||
config: Record<string, unknown> | null | undefined,
|
||||
): ModelsDevPricingPreference | null {
|
||||
const value = config?.[MODELS_DEV_PRICING_CONFIG_KEY]
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
const preference = value as Record<string, unknown>
|
||||
if (preference.enabled !== true) return null
|
||||
if (typeof preference.provider_id !== 'string' || !preference.provider_id.trim()) return null
|
||||
return {
|
||||
enabled: true,
|
||||
provider_id: preference.provider_id.trim(),
|
||||
provider_name: typeof preference.provider_name === 'string' && preference.provider_name.trim()
|
||||
? preference.provider_name.trim()
|
||||
: preference.provider_id.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeModelsDevPricingPreference(
|
||||
config: Record<string, unknown> | null | undefined,
|
||||
preference: ModelsDevPricingPreference | null,
|
||||
): Record<string, unknown> | null {
|
||||
const mergedConfig = { ...(config || {}) }
|
||||
if (preference) {
|
||||
mergedConfig[MODELS_DEV_PRICING_CONFIG_KEY] = preference
|
||||
} else {
|
||||
delete mergedConfig[MODELS_DEV_PRICING_CONFIG_KEY]
|
||||
}
|
||||
return Object.keys(mergedConfig).length > 0 ? mergedConfig : null
|
||||
}
|
||||
|
||||
export function buildGlobalModelPriceSyncPlan(
|
||||
models: GlobalModelResponse[],
|
||||
onlineModels: ModelsDevModelItem[],
|
||||
): GlobalModelPriceSyncPlan {
|
||||
const onlineModelsByName = new Map<string, ModelsDevModelItem>()
|
||||
for (const onlineModel of onlineModels) {
|
||||
const normalizedName = onlineModel.modelId.trim().toLowerCase()
|
||||
if (!onlineModelsByName.has(normalizedName)) {
|
||||
onlineModelsByName.set(normalizedName, onlineModel)
|
||||
}
|
||||
}
|
||||
|
||||
const plan: GlobalModelPriceSyncPlan = {
|
||||
syncable: [],
|
||||
unchanged: [],
|
||||
unavailable: [],
|
||||
}
|
||||
for (const model of models) {
|
||||
const onlineModel = onlineModelsByName.get(model.name.trim().toLowerCase())
|
||||
if (!onlineModel?.tieredPricing) {
|
||||
plan.unavailable.push(model)
|
||||
} else if (tieredPricingConfigsEqual(model.default_tiered_pricing, onlineModel.tieredPricing)) {
|
||||
plan.unchanged.push({ model, onlineModel })
|
||||
} else {
|
||||
plan.syncable.push({ model, onlineModel })
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
function cleanGlobalModelConfig(form: GlobalModelFormPayloadState): Record<string, unknown> | undefined {
|
||||
return form.config && Object.keys(form.config).length > 0 ? form.config : undefined
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="批量管理"
|
||||
title="快速筛选与批量操作"
|
||||
@click="openBatchManageDialog"
|
||||
>
|
||||
<ListChecks class="w-3.5 h-3.5" />
|
||||
@@ -103,8 +103,16 @@
|
||||
>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
{{ model.display_name }}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ model.display_name }}</span>
|
||||
<span
|
||||
v-if="getModelPricingPreference(model)"
|
||||
class="inline-flex h-5 shrink-0 items-center gap-1 rounded-md bg-primary/8 px-1.5 text-[10px] font-medium text-primary"
|
||||
:title="getModelPricingPreferenceTitle(model)"
|
||||
>
|
||||
<RefreshCw class="h-2.5 w-2.5" />
|
||||
自动价格
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<span>{{ model.name }}</span>
|
||||
@@ -246,6 +254,14 @@
|
||||
<Copy class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="getModelPricingPreference(model)"
|
||||
class="mt-1 inline-flex items-center gap-1 text-[10px] font-medium text-primary"
|
||||
:title="getModelPricingPreferenceTitle(model)"
|
||||
>
|
||||
<RefreshCw class="h-2.5 w-2.5" />
|
||||
自动价格 · {{ getModelPricingPreference(model)?.provider_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-0.5 shrink-0"
|
||||
@@ -469,17 +485,16 @@
|
||||
<!-- 批量管理全局模型对话框 -->
|
||||
<Dialog
|
||||
:model-value="batchManageDialogOpen"
|
||||
title="批量管理模型"
|
||||
description="选择要删除的全局模型"
|
||||
:icon="Trash2"
|
||||
icon-class="bg-destructive/10"
|
||||
title="快速筛选与批量操作"
|
||||
description="按在线定价来源快速筛选,并批量同步价格或删除模型"
|
||||
:icon="ListChecks"
|
||||
size="2xl"
|
||||
@update:model-value="batchManageDialogOpen = $event"
|
||||
>
|
||||
<template #default>
|
||||
<div class="space-y-4">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 搜索与在线价格来源 -->
|
||||
<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
|
||||
@@ -488,6 +503,24 @@
|
||||
class="pl-8 h-9"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
v-model="batchPricingProviderId"
|
||||
:disabled="batchManageOnlineLoading || batchPricingProviderOptions.length === 0"
|
||||
>
|
||||
<SelectTrigger class="h-9 text-xs">
|
||||
<SelectValue placeholder="选择在线价格来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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>
|
||||
|
||||
<!-- 快捷选中 -->
|
||||
@@ -497,7 +530,8 @@
|
||||
v-for="shortcut in batchManageShortcuts"
|
||||
:key="shortcut.label"
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded-md border border-border/60 hover:bg-muted transition-colors"
|
||||
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)"
|
||||
>
|
||||
@@ -509,7 +543,7 @@
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<div
|
||||
v-if="batchManageLoading"
|
||||
v-if="batchManageLoading || batchManageOnlineLoading"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||
@@ -556,6 +590,17 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<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"
|
||||
@@ -590,19 +635,33 @@
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ selectedBatchManageModelIds.size > 0 ? `已选择 ${selectedBatchManageModelIds.size} 个模型` : '' }}
|
||||
{{ batchManageSelectionSummary }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:disabled="selectedBatchPriceSyncPlan.syncable.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' ? '同步中...' : `同步在线价格 (${selectedBatchPriceSyncPlan.syncable.length})` }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
:disabled="selectedBatchManageModelIds.size === 0 || submittingBatchManage"
|
||||
@click="confirmBatchDeleteModels"
|
||||
>
|
||||
<Loader2
|
||||
v-if="submittingBatchManage"
|
||||
v-if="batchManageAction === 'delete'"
|
||||
class="w-4 h-4 mr-1 animate-spin"
|
||||
/>
|
||||
{{ submittingBatchManage ? '删除中...' : '删除选中' }}
|
||||
{{ batchManageAction === 'delete' ? '删除中...' : '删除选中' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -632,6 +691,7 @@ import {
|
||||
Server,
|
||||
Check,
|
||||
ListChecks,
|
||||
RefreshCw,
|
||||
} from 'lucide-vue-next'
|
||||
import ModelDetailDrawer from '@/features/models/components/ModelDetailDrawer.vue'
|
||||
import GlobalModelFormDialog from '@/features/models/components/GlobalModelFormDialog.vue'
|
||||
@@ -657,6 +717,11 @@ import {
|
||||
Dialog,
|
||||
Pagination,
|
||||
RefreshButton,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import {
|
||||
listGlobalModels,
|
||||
@@ -671,6 +736,12 @@ import {
|
||||
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,
|
||||
readModelsDevPricingPreference,
|
||||
} from '@/features/models/components/global-model-form-helpers'
|
||||
|
||||
|
||||
interface ModelProviderDisplay {
|
||||
@@ -712,6 +783,8 @@ 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
|
||||
@@ -746,8 +819,10 @@ const editingProvider = ref<ModelProviderDisplay | null>(null)
|
||||
// 批量管理全局模型
|
||||
const batchManageDialogOpen = ref(false)
|
||||
const batchManageSearchQuery = ref('')
|
||||
const batchPricingProviderId = ref('')
|
||||
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>(() => {
|
||||
@@ -774,7 +849,18 @@ const editingProviderModel = computed<Model | null>(() => {
|
||||
})
|
||||
|
||||
// 使用全局确认对话框
|
||||
const { confirmDanger } = useConfirm()
|
||||
const { confirm, confirmDanger } = useConfirm()
|
||||
|
||||
function getModelPricingPreference(model: GlobalModelResponse) {
|
||||
return readModelsDevPricingPreference(model.config)
|
||||
}
|
||||
|
||||
function getModelPricingPreferenceTitle(model: GlobalModelResponse): string {
|
||||
const preference = getModelPricingPreference(model)
|
||||
return preference
|
||||
? `自动应用在线价格 · ${preference.provider_name}`
|
||||
: ''
|
||||
}
|
||||
|
||||
// 从 GlobalModel 的 default_tiered_pricing 获取第一阶梯价格
|
||||
function getFirstTierPrice(model: GlobalModelResponse, type: 'input' | 'output'): number | null {
|
||||
@@ -1294,6 +1380,102 @@ const filteredBatchManageModels = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
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 && 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(() => (
|
||||
batchManageOnlineModels.value.filter(model => model.providerId === batchPricingProviderId.value)
|
||||
))
|
||||
|
||||
const batchPriceSyncPlan = computed(() => (
|
||||
buildGlobalModelPriceSyncPlan(batchManageModels.value, batchPricingProviderModels.value)
|
||||
))
|
||||
|
||||
const batchPricingStateByModelId = computed(() => {
|
||||
const states = new Map<string, 'syncable' | 'unchanged' | '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 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)
|
||||
))
|
||||
|
||||
const batchManageSelectionSummary = computed(() => {
|
||||
const selectedCount = selectedBatchManageModelIds.value.size
|
||||
if (selectedCount === 0) return batchPricingProviderId.value ? '选择模型后执行批量操作' : '请先选择在线价格来源'
|
||||
const plan = selectedBatchPriceSyncPlan.value
|
||||
return `已选择 ${selectedCount} 个 · 可更新 ${plan.syncable.length} · 已一致 ${plan.unchanged.length} · 无在线价格 ${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 '价格可更新'
|
||||
if (state === 'unchanged') return '价格一致'
|
||||
return '无在线价格'
|
||||
}
|
||||
|
||||
function getBatchPricingStateDescription(model: GlobalModelResponse): string {
|
||||
const providerName = selectedBatchPricingProvider.value?.providerName
|
||||
return providerName
|
||||
? `${providerName} · ${getBatchPricingStateLabel(model)}`
|
||||
: '请选择在线价格来源'
|
||||
}
|
||||
|
||||
function getBatchPricingStateClass(model: GlobalModelResponse): string {
|
||||
const state = getBatchPricingState(model)
|
||||
if (state === 'syncable') return 'text-amber-700 dark:text-amber-300'
|
||||
if (state === 'unchanged') return 'text-emerald-700 dark:text-emerald-300'
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
|
||||
function getBatchPricingStateDotClass(model: GlobalModelResponse): string {
|
||||
const state = getBatchPricingState(model)
|
||||
if (state === 'syncable') return 'bg-amber-500'
|
||||
if (state === 'unchanged') return 'bg-emerald-500'
|
||||
return 'bg-muted-foreground/45'
|
||||
}
|
||||
|
||||
// 批量管理 - 快捷筛选定义
|
||||
function hasNoPrice(m: GlobalModelResponse): boolean {
|
||||
return !getFirstTierPrice(m, 'input') && !getFirstTierPrice(m, 'output')
|
||||
@@ -1302,7 +1484,15 @@ function hasNoPrice(m: GlobalModelResponse): boolean {
|
||||
|
||||
const batchManageShortcuts = computed(() => {
|
||||
const models = batchManageModels.value
|
||||
const defs: { label: string; description: string; filter: (m: GlobalModelResponse) => boolean }[] = [
|
||||
const defs: {
|
||||
label: string
|
||||
description: string
|
||||
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) === '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 },
|
||||
@@ -1352,9 +1542,87 @@ function toggleAllBatchManageModels() {
|
||||
// 打开批量管理对话框
|
||||
function openBatchManageDialog() {
|
||||
batchManageSearchQuery.value = ''
|
||||
batchPricingProviderId.value = ''
|
||||
selectedBatchManageModelIds.value = new Set()
|
||||
batchManageDialogOpen.value = true
|
||||
loadBatchManageModels()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (plan.syncable.length === 0) return
|
||||
const providerName = selectedBatchPricingProvider.value?.providerName || '所选供应商'
|
||||
const skippedCount = plan.unchanged.length + plan.unavailable.length
|
||||
const confirmed = await confirm({
|
||||
title: '批量同步模型价格',
|
||||
message: `将根据 ${providerName} 的在线定价更新 ${plan.syncable.length} 个模型。${skippedCount > 0 ? `另有 ${skippedCount} 个模型因价格一致或无在线价格而跳过。` : ''}\n\n仅更新模型价格,不修改名称、能力或其他配置。`,
|
||||
confirmText: '同步价格',
|
||||
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 = plan.syncable.map(entry => async () => {
|
||||
try {
|
||||
const onlinePricing = entry.onlineModel.tieredPricing
|
||||
if (!onlinePricing) {
|
||||
throw new Error('在线目录未提供价格配置')
|
||||
}
|
||||
const pricing = cloneTieredPricingConfig(onlinePricing)
|
||||
await updateGlobalModel(entry.model.id, { default_tiered_pricing: pricing })
|
||||
entry.model.default_tiered_pricing = pricing
|
||||
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(`成功同步 ${successCount} 个模型价格`)
|
||||
if (failureMessages.length > 0) {
|
||||
showError(`${failureMessages.length} 个模型同步失败:${failureMessages.slice(0, 2).join(';')}`, '部分失败')
|
||||
}
|
||||
await Promise.all([loadGlobalModels(), loadBatchManageModels()])
|
||||
selectedBatchManageModelIds.value = failedIds
|
||||
} finally {
|
||||
batchManageAction.value = null
|
||||
submittingBatchManage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 确认批量删除模型
|
||||
@@ -1369,6 +1637,7 @@ async function confirmBatchDeleteModels() {
|
||||
if (!confirmed) return
|
||||
|
||||
submittingBatchManage.value = true
|
||||
batchManageAction.value = 'delete'
|
||||
try {
|
||||
const ids = Array.from(selectedBatchManageModelIds.value)
|
||||
const result = await batchDeleteGlobalModels(ids)
|
||||
@@ -1390,10 +1659,24 @@ async function confirmBatchDeleteModels() {
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '批量删除失败'), '错误')
|
||||
} finally {
|
||||
batchManageAction.value = null
|
||||
submittingBatchManage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(batchPricingProviderId, (value, previousValue) => {
|
||||
if (previousValue && value !== previousValue) {
|
||||
selectedBatchManageModelIds.value = new Set()
|
||||
}
|
||||
})
|
||||
|
||||
watch([batchPricingProviderOptions, batchManageDialogOpen], ([options, dialogOpen]) => {
|
||||
if (!dialogOpen) return
|
||||
if (!options.some(provider => provider.providerId === batchPricingProviderId.value)) {
|
||||
batchPricingProviderId.value = options[0]?.providerId ?? ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 抽屉控制函数
|
||||
function handleDrawerOpenChange(value: boolean) {
|
||||
if (!value && !hasBlockingDialogOpen.value) {
|
||||
|
||||
Reference in New Issue
Block a user