mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-07 18:22:28 +08:00
refactor: 提取 ModelMultiSelect 组件并支持失效模型检测
- 新增 ModelMultiSelect 组件,支持显示和移除已失效的模型 - 新增 useInvalidModels composable 检测 allowed_models 中的无效引用 - 重构 StandaloneKeyFormDialog 和 UserFormDialog 使用新组件 - 补充 GlobalModel 删除逻辑的设计说明注释
This commit is contained in:
117
frontend/src/components/common/ModelMultiSelect.vue
Normal file
117
frontend/src/components/common/ModelMultiSelect.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-medium">允许的模型</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
||||||
|
@click="isOpen = !isOpen"
|
||||||
|
>
|
||||||
|
<span :class="modelValue.length ? 'text-foreground' : 'text-muted-foreground'">
|
||||||
|
{{ modelValue.length ? `已选择 ${modelValue.length} 个` : '全部可用' }}
|
||||||
|
<span
|
||||||
|
v-if="invalidModels.length"
|
||||||
|
class="text-destructive"
|
||||||
|
>({{ invalidModels.length }} 个已失效)</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
class="h-4 w-4 text-muted-foreground transition-transform"
|
||||||
|
:class="isOpen ? 'rotate-180' : ''"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="fixed inset-0 z-[80]"
|
||||||
|
@click.stop="isOpen = false"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<!-- 失效模型(置顶显示,只能取消选择) -->
|
||||||
|
<div
|
||||||
|
v-for="modelName in invalidModels"
|
||||||
|
:key="modelName"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer bg-destructive/5"
|
||||||
|
@click="removeModel(modelName)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="true"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||||
|
@click.stop
|
||||||
|
@change="removeModel(modelName)"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-destructive">{{ modelName }}</span>
|
||||||
|
<span class="text-xs text-destructive/70">(已失效)</span>
|
||||||
|
</div>
|
||||||
|
<!-- 有效模型 -->
|
||||||
|
<div
|
||||||
|
v-for="model in models"
|
||||||
|
:key="model.name"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
||||||
|
@click="toggleModel(model.name)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="modelValue.includes(model.name)"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||||
|
@click.stop
|
||||||
|
@change="toggleModel(model.name)"
|
||||||
|
>
|
||||||
|
<span class="text-sm">{{ model.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="models.length === 0 && invalidModels.length === 0"
|
||||||
|
class="px-3 py-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无可用模型
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { Label } from '@/components/ui'
|
||||||
|
import { ChevronDown } from 'lucide-vue-next'
|
||||||
|
import { useInvalidModels } from '@/composables/useInvalidModels'
|
||||||
|
|
||||||
|
export interface ModelWithName {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
models: ModelWithName[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
// 检测失效模型
|
||||||
|
const { invalidModels } = useInvalidModels(
|
||||||
|
computed(() => props.modelValue),
|
||||||
|
computed(() => props.models)
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleModel(name: string) {
|
||||||
|
const newValue = [...props.modelValue]
|
||||||
|
const index = newValue.indexOf(name)
|
||||||
|
if (index === -1) {
|
||||||
|
newValue.push(name)
|
||||||
|
} else {
|
||||||
|
newValue.splice(index, 1)
|
||||||
|
}
|
||||||
|
emit('update:modelValue', newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeModel(name: string) {
|
||||||
|
const newValue = props.modelValue.filter(m => m !== name)
|
||||||
|
emit('update:modelValue', newValue)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -7,3 +7,6 @@
|
|||||||
export { default as EmptyState } from './EmptyState.vue'
|
export { default as EmptyState } from './EmptyState.vue'
|
||||||
export { default as AlertDialog } from './AlertDialog.vue'
|
export { default as AlertDialog } from './AlertDialog.vue'
|
||||||
export { default as LoadingState } from './LoadingState.vue'
|
export { default as LoadingState } from './LoadingState.vue'
|
||||||
|
|
||||||
|
// 表单组件
|
||||||
|
export { default as ModelMultiSelect } from './ModelMultiSelect.vue'
|
||||||
|
|||||||
34
frontend/src/composables/useInvalidModels.ts
Normal file
34
frontend/src/composables/useInvalidModels.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { computed, type Ref, type ComputedRef } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测失效模型的 composable
|
||||||
|
*
|
||||||
|
* 用于检测 allowed_models 中已不存在于 globalModels 的模型名称,
|
||||||
|
* 这些模型可能已被删除但引用未清理。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const { invalidModels } = useInvalidModels(
|
||||||
|
* computed(() => form.value.allowed_models),
|
||||||
|
* globalModels
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface ModelWithName {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInvalidModels<T extends ModelWithName>(
|
||||||
|
allowedModels: Ref<string[]> | ComputedRef<string[]>,
|
||||||
|
globalModels: Ref<T[]>
|
||||||
|
): { invalidModels: ComputedRef<string[]> } {
|
||||||
|
const validModelNames = computed(() =>
|
||||||
|
new Set(globalModels.value.map(m => m.name))
|
||||||
|
)
|
||||||
|
|
||||||
|
const invalidModels = computed(() =>
|
||||||
|
allowedModels.value.filter(name => !validModelNames.value.has(name))
|
||||||
|
)
|
||||||
|
|
||||||
|
return { invalidModels }
|
||||||
|
}
|
||||||
@@ -244,55 +244,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模型多选下拉框 -->
|
<!-- 模型多选下拉框 -->
|
||||||
<div class="space-y-2">
|
<ModelMultiSelect
|
||||||
<Label class="text-sm font-medium">允许的模型</Label>
|
v-model="form.allowed_models"
|
||||||
<div class="relative">
|
:models="globalModels"
|
||||||
<button
|
/>
|
||||||
type="button"
|
|
||||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
|
||||||
@click="modelDropdownOpen = !modelDropdownOpen"
|
|
||||||
>
|
|
||||||
<span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'">
|
|
||||||
{{ form.allowed_models.length ? `已选择 ${form.allowed_models.length} 个` : '全部可用' }}
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
|
||||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
|
||||||
:class="modelDropdownOpen ? 'rotate-180' : ''"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-if="modelDropdownOpen"
|
|
||||||
class="fixed inset-0 z-[80]"
|
|
||||||
@click.stop="modelDropdownOpen = false"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="modelDropdownOpen"
|
|
||||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="model in globalModels"
|
|
||||||
:key="model.name"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
|
||||||
@click="toggleSelection('allowed_models', model.name)"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="form.allowed_models.includes(model.name)"
|
|
||||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
|
||||||
@click.stop
|
|
||||||
@change="toggleSelection('allowed_models', model.name)"
|
|
||||||
>
|
|
||||||
<span class="text-sm">{{ model.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="globalModels.length === 0"
|
|
||||||
class="px-3 py-2 text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
暂无可用模型
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -327,6 +282,7 @@ import {
|
|||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { Plus, SquarePen, Key, Shield, ChevronDown } from 'lucide-vue-next'
|
import { Plus, SquarePen, Key, Shield, ChevronDown } from 'lucide-vue-next'
|
||||||
import { useFormDialog } from '@/composables/useFormDialog'
|
import { useFormDialog } from '@/composables/useFormDialog'
|
||||||
|
import { ModelMultiSelect } from '@/components/common'
|
||||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||||
import { getGlobalModels } from '@/api/global-models'
|
import { getGlobalModels } from '@/api/global-models'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
@@ -363,7 +319,6 @@ const saving = ref(false)
|
|||||||
// 下拉框状态
|
// 下拉框状态
|
||||||
const providerDropdownOpen = ref(false)
|
const providerDropdownOpen = ref(false)
|
||||||
const apiFormatDropdownOpen = ref(false)
|
const apiFormatDropdownOpen = ref(false)
|
||||||
const modelDropdownOpen = ref(false)
|
|
||||||
|
|
||||||
// 选项数据
|
// 选项数据
|
||||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||||
@@ -397,7 +352,6 @@ function resetForm() {
|
|||||||
}
|
}
|
||||||
providerDropdownOpen.value = false
|
providerDropdownOpen.value = false
|
||||||
apiFormatDropdownOpen.value = false
|
apiFormatDropdownOpen.value = false
|
||||||
modelDropdownOpen.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadKeyData() {
|
function loadKeyData() {
|
||||||
|
|||||||
@@ -316,55 +316,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模型多选下拉框 -->
|
<!-- 模型多选下拉框 -->
|
||||||
<div class="space-y-2">
|
<ModelMultiSelect
|
||||||
<Label class="text-sm font-medium">允许的模型</Label>
|
v-model="form.allowed_models"
|
||||||
<div class="relative">
|
:models="globalModels"
|
||||||
<button
|
/>
|
||||||
type="button"
|
|
||||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
|
||||||
@click="modelDropdownOpen = !modelDropdownOpen"
|
|
||||||
>
|
|
||||||
<span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'">
|
|
||||||
{{ form.allowed_models.length ? `已选择 ${form.allowed_models.length} 个` : '全部可用' }}
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
|
||||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
|
||||||
:class="modelDropdownOpen ? 'rotate-180' : ''"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-if="modelDropdownOpen"
|
|
||||||
class="fixed inset-0 z-[80]"
|
|
||||||
@click.stop="modelDropdownOpen = false"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="modelDropdownOpen"
|
|
||||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="model in globalModels"
|
|
||||||
:key="model.name"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
|
||||||
@click="toggleSelection('allowed_models', model.name)"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="form.allowed_models.includes(model.name)"
|
|
||||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
|
||||||
@click.stop
|
|
||||||
@change="toggleSelection('allowed_models', model.name)"
|
|
||||||
>
|
|
||||||
<span class="text-sm">{{ model.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="globalModels.length === 0"
|
|
||||||
class="px-3 py-2 text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
暂无可用模型
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -404,10 +359,12 @@ import {
|
|||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { UserPlus, SquarePen, ChevronDown } from 'lucide-vue-next'
|
import { UserPlus, SquarePen, ChevronDown } from 'lucide-vue-next'
|
||||||
import { useFormDialog } from '@/composables/useFormDialog'
|
import { useFormDialog } from '@/composables/useFormDialog'
|
||||||
|
import { ModelMultiSelect } from '@/components/common'
|
||||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||||
import { getGlobalModels } from '@/api/global-models'
|
import { getGlobalModels } from '@/api/global-models'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
|
||||||
|
|
||||||
export interface UserFormData {
|
export interface UserFormData {
|
||||||
id?: string
|
id?: string
|
||||||
@@ -440,11 +397,10 @@ const roleSelectOpen = ref(false)
|
|||||||
// 下拉框状态
|
// 下拉框状态
|
||||||
const providerDropdownOpen = ref(false)
|
const providerDropdownOpen = ref(false)
|
||||||
const endpointDropdownOpen = ref(false)
|
const endpointDropdownOpen = ref(false)
|
||||||
const modelDropdownOpen = ref(false)
|
|
||||||
|
|
||||||
// 选项数据
|
// 选项数据
|
||||||
const providers = ref<any[]>([])
|
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||||
const globalModels = ref<any[]>([])
|
const globalModels = ref<GlobalModelResponse[]>([])
|
||||||
const apiFormats = ref<Array<{ value: string; label: string }>>([])
|
const apiFormats = ref<Array<{ value: string; label: string }>>([])
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ class GlobalModelService:
|
|||||||
删除 GlobalModel
|
删除 GlobalModel
|
||||||
|
|
||||||
默认行为: 级联删除所有关联的 Provider 模型实现
|
默认行为: 级联删除所有关联的 Provider 模型实现
|
||||||
|
注意: 不清理 API Key 和 User 的 allowed_models 引用,
|
||||||
|
保留无效引用可让用户在前端看到"已失效"的模型,便于手动清理或等待重建同名模型
|
||||||
"""
|
"""
|
||||||
global_model = GlobalModelService.get_global_model(db, global_model_id)
|
global_model = GlobalModelService.get_global_model(db, global_model_id)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user