Files
Aether/frontend/src/features/providers/components/BatchAssignModelsDialog.vue

389 lines
12 KiB
Vue
Raw Normal View History

2025-12-10 20:52:44 +08:00
<template>
<Dialog
:model-value="open"
:title="providerName ? `批量管理模型 - ${providerName}` : '批量管理模型'"
description="选中的模型将被关联到提供商,取消选中将移除关联"
2025-12-10 20:52:44 +08:00
:icon="Layers"
size="2xl"
@update:model-value="handleDialogUpdate"
2025-12-10 20:52:44 +08:00
>
<template #default>
<div class="space-y-4">
<!-- 搜索栏 -->
<div class="flex items-center gap-2">
<div class="flex-1 relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
v-model="searchQuery"
placeholder="搜索模型..."
class="pl-8 h-9"
/>
2025-12-10 20:52:44 +08:00
</div>
</div>
<!-- 模型列表 -->
<div class="border rounded-lg overflow-hidden">
<div class="max-h-96 overflow-y-auto">
<div
v-if="loadingGlobalModels"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" />
2025-12-10 20:52:44 +08:00
</div>
<template v-else>
<!-- 全局模型列表 -->
<div v-if="filteredGlobalModels.length > 0">
2025-12-10 20:52:44 +08:00
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10"
2025-12-10 20:52:44 +08:00
>
<div class="flex items-center gap-2">
<span class="text-xs font-medium">全局模型</span>
<span class="text-xs text-muted-foreground">({{ filteredGlobalModels.length }})</span>
2025-12-10 20:52:44 +08:00
</div>
<button
v-if="filteredGlobalModels.length > 0"
type="button"
class="text-xs text-primary hover:underline shrink-0"
@click.stop="toggleAllGlobalModels"
>
{{ isAllGlobalModelsSelected ? '取消全选' : '全选' }}
</button>
</div>
<div class="space-y-1 p-2">
<div
v-for="model in filteredGlobalModels"
:key="model.id"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
@click="toggleGlobalModelSelection(model.id)"
2025-12-10 20:52:44 +08:00
>
<div
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="isGlobalModelSelected(model.id) ? 'bg-primary border-primary' : ''"
>
<Check
v-if="isGlobalModelSelected(model.id)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">
{{ model.display_name }}
</p>
<p class="text-xs text-muted-foreground truncate font-mono">
{{ model.name }}
</p>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-if="filteredGlobalModels.length === 0"
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
2025-12-10 20:52:44 +08:00
<Layers class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">
{{ searchQuery ? '无匹配结果' : '暂无可用全局模型' }}
</p>
<p class="text-xs mt-1">
请先前往"模型目录"页面创建全局模型
</p>
2025-12-10 20:52:44 +08:00
</div>
</template>
2025-12-10 20:52:44 +08:00
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex items-center justify-between w-full">
<p class="text-xs text-muted-foreground">
{{ hasChanges ? `${pendingChangesCount} 项更改待保存` : '' }}
</p>
<div class="flex items-center gap-2">
<Button
:disabled="!hasChanges || saving"
@click="handleSave"
>
<Loader2
v-if="saving"
class="w-4 h-4 mr-1 animate-spin"
/>
{{ saving ? '保存中...' : '保存' }}
</Button>
<Button
variant="outline"
@click="handleClose"
>
关闭
</Button>
</div>
</div>
2025-12-10 20:52:44 +08:00
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Layers, Loader2, Search, Check } from 'lucide-vue-next'
2025-12-10 20:52:44 +08:00
import Dialog from '@/components/ui/dialog/Dialog.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
2025-12-10 20:52:44 +08:00
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
2025-12-10 20:52:44 +08:00
import { parseApiError } from '@/utils/errorParser'
import {
getGlobalModels,
type GlobalModelResponse
} from '@/api/endpoints/global-models'
import {
getProviderModels,
batchAssignModelsToProvider,
deleteModel,
type Model
} from '@/api/endpoints'
2025-12-10 20:52:44 +08:00
const props = defineProps<{
open: boolean
providerId: string
providerName?: string
2025-12-10 20:52:44 +08:00
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'changed': []
}>()
const { error: showError, success } = useToast()
const { confirmWarning } = useConfirm()
2025-12-10 20:52:44 +08:00
// 状态
const loadingGlobalModels = ref(false)
const saving = ref(false)
2025-12-10 20:52:44 +08:00
// 数据
const allGlobalModels = ref<GlobalModelResponse[]>([])
const existingModels = ref<Model[]>([])
// 选择状态(本地状态,保存时才提交)
const selectedGlobalModelIds = ref<Set<string>>(new Set())
// 初始状态(用于计算变更)
const initialGlobalModelIds = ref<Set<string>>(new Set())
// 搜索状态
const searchQuery = ref('')
// 已关联的全局模型 ID 集合(从已有数据计算)
const existingGlobalModelIds = computed(() => {
return new Set(
2025-12-10 20:52:44 +08:00
existingModels.value
.map(m => m.global_model_id)
)
})
// 过滤后的全局模型
const filteredGlobalModels = computed(() => {
const query = searchQuery.value.toLowerCase().trim()
return allGlobalModels.value.filter(m => {
if (query && !m.name.toLowerCase().includes(query) && !m.display_name.toLowerCase().includes(query)) {
return false
}
return true
})
})
// 全局模型是否全选
const isAllGlobalModelsSelected = computed(() => {
if (filteredGlobalModels.value.length === 0) return false
return filteredGlobalModels.value.every(m => isGlobalModelSelected(m.id))
})
// 检查全局模型是否已选中
function isGlobalModelSelected(globalModelId: string): boolean {
return selectedGlobalModelIds.value.has(globalModelId)
}
// 计算待添加的全局模型
const globalModelsToAdd = computed(() => {
const toAdd: string[] = []
for (const id of selectedGlobalModelIds.value) {
if (!initialGlobalModelIds.value.has(id)) {
toAdd.push(id)
}
}
return toAdd
})
// 计算待移除的全局模型
const globalModelsToRemove = computed(() => {
const toRemove: string[] = []
for (const id of initialGlobalModelIds.value) {
if (!selectedGlobalModelIds.value.has(id)) {
toRemove.push(id)
}
}
return toRemove
})
// 是否有变更
const hasChanges = computed(() => {
return globalModelsToAdd.value.length > 0 ||
globalModelsToRemove.value.length > 0
})
// 待变更数量
const pendingChangesCount = computed(() => {
return globalModelsToAdd.value.length +
globalModelsToRemove.value.length
})
// 切换全局模型选择
function toggleGlobalModelSelection(id: string) {
if (selectedGlobalModelIds.value.has(id)) {
selectedGlobalModelIds.value.delete(id)
} else {
selectedGlobalModelIds.value.add(id)
}
selectedGlobalModelIds.value = new Set(selectedGlobalModelIds.value)
}
// 全选/取消全选全局模型
function toggleAllGlobalModels() {
const allIds = filteredGlobalModels.value.map(m => m.id)
if (isAllGlobalModelsSelected.value) {
for (const id of allIds) {
selectedGlobalModelIds.value.delete(id)
}
} else {
for (const id of allIds) {
selectedGlobalModelIds.value.add(id)
}
}
selectedGlobalModelIds.value = new Set(selectedGlobalModelIds.value)
}
// 处理关闭
async function handleClose() {
if (hasChanges.value) {
const confirmed = await confirmWarning('有未保存的更改,确定要关闭吗?', '放弃更改')
if (!confirmed) return
}
emit('update:open', false)
}
// 处理对话框状态变更
async function handleDialogUpdate(value: boolean) {
if (!value && hasChanges.value) {
const confirmed = await confirmWarning('有未保存的更改,确定要关闭吗?', '放弃更改')
if (!confirmed) return
}
emit('update:open', value)
}
// 保存变更
async function handleSave() {
if (!hasChanges.value || saving.value) return
saving.value = true
let hasAnyOperation = false
try {
let totalSuccess = 0
const allErrors: string[] = []
// 移除全局模型
for (const globalModelId of globalModelsToRemove.value) {
const existingModel = existingModels.value.find(m => m.global_model_id === globalModelId)
if (existingModel) {
hasAnyOperation = true
try {
await deleteModel(props.providerId, existingModel.id)
totalSuccess++
} catch (err: unknown) {
allErrors.push(parseApiError(err, '移除失败'))
}
}
}
// 添加全局模型
if (globalModelsToAdd.value.length > 0) {
hasAnyOperation = true
try {
const result = await batchAssignModelsToProvider(props.providerId, globalModelsToAdd.value)
totalSuccess += result.success.length
if (result.errors.length > 0) {
allErrors.push(...result.errors.map(e => e.error))
}
} catch (err: unknown) {
allErrors.push(parseApiError(err, '批量添加全局模型失败'))
}
}
if (totalSuccess > 0) {
success(`成功处理 ${totalSuccess} 个模型`)
}
if (allErrors.length > 0) {
showError(`部分操作失败: ${allErrors.slice(0, 3).join(', ')}${allErrors.length > 3 ? '...' : ''}`, '警告')
}
emit('changed')
emit('update:open', false)
} catch (err: unknown) {
showError(parseApiError(err, '保存失败'), '错误')
if (hasAnyOperation) {
emit('changed')
}
} finally {
saving.value = false
}
}
// 从已有数据同步选择状态
function syncGlobalModelSelection() {
const globalIds = [...existingGlobalModelIds.value].filter((id): id is string => id !== undefined)
selectedGlobalModelIds.value = new Set(globalIds)
initialGlobalModelIds.value = new Set(globalIds)
}
2025-12-10 20:52:44 +08:00
// 监听打开状态
watch(() => props.open, async (isOpen) => {
if (isOpen && props.providerId) {
await loadData()
} else {
searchQuery.value = ''
selectedGlobalModelIds.value = new Set()
initialGlobalModelIds.value = new Set()
2025-12-10 20:52:44 +08:00
}
})
// 加载数据
async function loadData() {
await Promise.all([loadGlobalModels(), loadExistingModels()])
syncGlobalModelSelection()
2025-12-10 20:52:44 +08:00
}
// 加载全局模型列表
async function loadGlobalModels() {
try {
loadingGlobalModels.value = true
const response = await getGlobalModels({ limit: 1000 })
allGlobalModels.value = response.models
} catch (err: unknown) {
2025-12-10 20:52:44 +08:00
showError(parseApiError(err, '加载全局模型失败'), '错误')
} finally {
loadingGlobalModels.value = false
}
}
// 加载已关联的模型
async function loadExistingModels() {
try {
existingModels.value = await getProviderModels(props.providerId)
} catch (err: unknown) {
2025-12-10 20:52:44 +08:00
showError(parseApiError(err, '加载已关联模型失败'), '错误')
}
}
</script>