mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-11 19:59:50 +08:00
813 lines
29 KiB
Vue
813 lines
29 KiB
Vue
<template>
|
||
<Dialog
|
||
:model-value="isOpen"
|
||
:title="props.apiKey?.name ? `模型权限 - ${props.apiKey.name}` : '模型权限'"
|
||
:description="isAutoFetchMode ? '自动获取模式:只允许已选择的模型,锁定的模型刷新时不会被删除' : '选中的模型将被允许访问,不选择则允许全部'"
|
||
:icon="Shield"
|
||
size="2xl"
|
||
@update:model-value="handleDialogUpdate"
|
||
>
|
||
<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"
|
||
/>
|
||
</div>
|
||
<!-- 已选数量徽章 -->
|
||
<span
|
||
v-if="selectedModels.length === 0 && !isAutoFetchMode"
|
||
class="h-7 px-2.5 text-xs rounded-md flex items-center bg-muted text-muted-foreground shrink-0"
|
||
>
|
||
全部模型
|
||
</span>
|
||
<span
|
||
v-else-if="selectedModels.length === 0 && isAutoFetchMode"
|
||
class="h-7 px-2.5 text-xs rounded-md flex items-center bg-amber-500/10 text-amber-600 dark:text-amber-400 shrink-0"
|
||
>
|
||
未选择模型
|
||
</span>
|
||
<span
|
||
v-else
|
||
class="h-7 px-2.5 text-xs rounded-md flex items-center bg-primary/10 text-primary shrink-0"
|
||
>
|
||
已选 {{ selectedModels.length }} 个
|
||
</span>
|
||
<!-- 刷新上游模型按钮 -->
|
||
<button
|
||
v-if="upstreamModelsLoaded"
|
||
type="button"
|
||
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
||
:disabled="fetchingUpstreamModels"
|
||
title="刷新上游模型"
|
||
@click="refreshUpstreamModels"
|
||
>
|
||
<RefreshCw
|
||
class="w-4 h-4"
|
||
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
||
/>
|
||
</button>
|
||
<button
|
||
v-else-if="!fetchingUpstreamModels"
|
||
type="button"
|
||
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
||
title="从提供商获取模型"
|
||
@click="refreshUpstreamModels"
|
||
>
|
||
<Zap class="w-4 h-4" />
|
||
</button>
|
||
<Loader2
|
||
v-else
|
||
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 模型列表 -->
|
||
<div class="border rounded-lg overflow-hidden">
|
||
<div class="max-h-96 overflow-y-auto">
|
||
<!-- 加载中 -->
|
||
<div
|
||
v-if="loadingProviderModels"
|
||
class="flex items-center justify-center py-12"
|
||
>
|
||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||
</div>
|
||
|
||
<template v-else>
|
||
<!-- 添加自定义模型(搜索内容不在列表中时显示) -->
|
||
<div
|
||
v-if="searchQuery && canAddAsCustom"
|
||
class="px-3 py-2 border-b bg-background sticky top-0 z-30"
|
||
>
|
||
<div
|
||
class="flex items-center justify-between px-3 py-2 rounded-lg border border-dashed hover:border-primary hover:bg-primary/5 cursor-pointer transition-colors"
|
||
@click="addCustomModel"
|
||
>
|
||
<div class="flex items-center gap-2">
|
||
<Plus class="w-4 h-4 text-muted-foreground" />
|
||
<span class="text-sm font-mono">{{ searchQuery }}</span>
|
||
</div>
|
||
<span class="text-xs text-muted-foreground">添加自定义模型</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 自定义模型 -->
|
||
<div v-if="customModels.length > 0">
|
||
<div
|
||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors"
|
||
@click="toggleGroupCollapse('custom')"
|
||
>
|
||
<div class="flex items-center gap-2">
|
||
<ChevronDown
|
||
class="w-4 h-4 transition-transform shrink-0"
|
||
:class="collapsedGroups.has('custom') ? '-rotate-90' : ''"
|
||
/>
|
||
<span class="text-xs font-medium">自定义模型</span>
|
||
<span class="text-xs text-muted-foreground">({{ customModels.length }})</span>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-show="!collapsedGroups.has('custom')"
|
||
class="space-y-1 p-2"
|
||
>
|
||
<div
|
||
v-for="model in sortedCustomModels"
|
||
:key="model"
|
||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
||
@click="toggleModel(model)"
|
||
>
|
||
<div
|
||
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
||
:class="selectedModels.includes(model) ? 'bg-primary border-primary' : ''"
|
||
>
|
||
<Check
|
||
v-if="selectedModels.includes(model)"
|
||
class="w-3 h-3 text-primary-foreground"
|
||
/>
|
||
</div>
|
||
<span class="text-sm font-mono truncate flex-1">{{ model }}</span>
|
||
<button
|
||
v-if="selectedModels.includes(model)"
|
||
type="button"
|
||
class="p-1 rounded hover:bg-muted-foreground/10 transition-colors shrink-0 text-muted-foreground"
|
||
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
||
@click="toggleLock(model, $event)"
|
||
>
|
||
<Lock
|
||
v-if="isLocked(model)"
|
||
class="w-3.5 h-3.5"
|
||
/>
|
||
<LockOpen
|
||
v-else
|
||
class="w-3.5 h-3.5"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 提供商模型 -->
|
||
<template v-if="filteredProviderModels.length > 0">
|
||
<!-- 标题 sticky top -->
|
||
<div
|
||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors"
|
||
@click="toggleGroupCollapse('global')"
|
||
>
|
||
<div class="flex items-center gap-2">
|
||
<ChevronDown
|
||
class="w-4 h-4 transition-transform shrink-0"
|
||
:class="collapsedGroups.has('global') ? '-rotate-90' : ''"
|
||
/>
|
||
<span class="text-xs font-medium">提供商模型</span>
|
||
<span class="text-xs text-muted-foreground">({{ filteredProviderModels.length }})</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="text-xs text-primary hover:underline"
|
||
@click.stop="toggleAllProviderModels"
|
||
>
|
||
{{ isAllProviderModelsSelected ? '取消全选' : '全选' }}
|
||
</button>
|
||
</div>
|
||
<!-- 内容 -->
|
||
<div
|
||
v-show="!collapsedGroups.has('global')"
|
||
class="space-y-1 p-2"
|
||
>
|
||
<div
|
||
v-for="model in filteredProviderModels"
|
||
:key="model.name"
|
||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
||
@click="toggleModel(model.name)"
|
||
>
|
||
<div
|
||
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
||
:class="selectedModels.includes(model.name) ? 'bg-primary border-primary' : ''"
|
||
>
|
||
<Check
|
||
v-if="selectedModels.includes(model.name)"
|
||
class="w-3 h-3 text-primary-foreground"
|
||
/>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<p class="text-sm font-medium truncate">
|
||
{{ model.name }}
|
||
</p>
|
||
<p
|
||
v-if="model.global_model_display_name || model.global_model_name"
|
||
class="text-xs text-muted-foreground truncate font-mono"
|
||
>
|
||
{{ model.global_model_display_name || model.global_model_name }}
|
||
</p>
|
||
</div>
|
||
<button
|
||
v-if="selectedModels.includes(model.name)"
|
||
type="button"
|
||
class="p-1 rounded hover:bg-muted-foreground/10 transition-colors shrink-0 text-muted-foreground"
|
||
:title="isLocked(model.name) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
||
@click="toggleLock(model.name, $event)"
|
||
>
|
||
<Lock
|
||
v-if="isLocked(model.name)"
|
||
class="w-3.5 h-3.5"
|
||
/>
|
||
<LockOpen
|
||
v-else
|
||
class="w-3.5 h-3.5"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 上游模型 -->
|
||
<template v-if="filteredUpstreamModels.length > 0">
|
||
<!-- 标题 sticky -->
|
||
<div
|
||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors"
|
||
@click="toggleGroupCollapse('upstream')"
|
||
>
|
||
<div class="flex items-center gap-2">
|
||
<ChevronDown
|
||
class="w-4 h-4 transition-transform shrink-0"
|
||
:class="collapsedGroups.has('upstream') ? '-rotate-90' : ''"
|
||
/>
|
||
<span class="text-xs font-medium">上游模型</span>
|
||
<span class="text-xs text-muted-foreground">({{ upstreamModelNames.length }})</span>
|
||
<span
|
||
v-if="isAutoFetchMode"
|
||
class="text-xs text-muted-foreground"
|
||
>
|
||
(自动同步)
|
||
</span>
|
||
</div>
|
||
<button
|
||
v-if="!isAutoFetchMode"
|
||
type="button"
|
||
class="text-xs text-primary hover:underline"
|
||
@click.stop="toggleAllUpstreamModels"
|
||
>
|
||
{{ isAllUpstreamModelsSelected ? '取消全选' : '全选' }}
|
||
</button>
|
||
</div>
|
||
<!-- 内容 -->
|
||
<div
|
||
v-show="!collapsedGroups.has('upstream')"
|
||
class="space-y-1 p-2"
|
||
>
|
||
<div
|
||
v-for="model in filteredUpstreamModels"
|
||
:key="model.id"
|
||
class="flex items-center gap-2 px-2 py-1.5 rounded"
|
||
:class="isAutoFetchMode ? 'opacity-80' : 'hover:bg-muted cursor-pointer'"
|
||
@click="handleUpstreamModelClick(model)"
|
||
>
|
||
<div
|
||
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
||
:class="[
|
||
selectedModels.includes(model.id) ? 'bg-primary border-primary' : '',
|
||
isAutoFetchMode ? 'opacity-50' : ''
|
||
]"
|
||
>
|
||
<Check
|
||
v-if="selectedModels.includes(model.id)"
|
||
class="w-3 h-3 text-primary-foreground"
|
||
/>
|
||
</div>
|
||
<span class="text-sm font-mono truncate flex-1">{{ model.id }}</span>
|
||
<!-- API 格式标签 -->
|
||
<div class="flex items-center gap-1 shrink-0">
|
||
<span
|
||
v-for="fmt in model.api_formats"
|
||
:key="fmt"
|
||
class="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground"
|
||
>
|
||
{{ formatApiFormatShort(fmt) }}
|
||
</span>
|
||
</div>
|
||
<button
|
||
v-if="selectedModels.includes(model.id)"
|
||
type="button"
|
||
class="p-1 rounded hover:bg-muted-foreground/10 transition-colors shrink-0 text-muted-foreground"
|
||
:title="isLocked(model.id) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
||
@click="toggleLock(model.id, $event)"
|
||
>
|
||
<Lock
|
||
v-if="isLocked(model.id)"
|
||
class="w-3.5 h-3.5"
|
||
/>
|
||
<LockOpen
|
||
v-else
|
||
class="w-3.5 h-3.5"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 空状态 -->
|
||
<div
|
||
v-if="showEmptyState"
|
||
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
||
>
|
||
<Shield class="w-10 h-10 mb-2 opacity-30" />
|
||
<p class="text-sm">
|
||
{{ searchQuery ? '无匹配结果' : '暂无可选模型' }}
|
||
</p>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template #footer>
|
||
<div class="flex items-center justify-between w-full">
|
||
<p class="text-xs text-muted-foreground">
|
||
{{ hasChanges ? '有未保存的更改' : '' }}
|
||
</p>
|
||
<div class="flex items-center gap-2">
|
||
<Button
|
||
:disabled="saving || !hasChanges"
|
||
@click="handleSave"
|
||
>
|
||
{{ saving ? '保存中...' : '保存' }}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
@click="handleCancel"
|
||
>
|
||
取消
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</Dialog>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||
import {
|
||
Shield,
|
||
Search,
|
||
Loader2,
|
||
Plus,
|
||
Check,
|
||
ChevronDown,
|
||
Lock,
|
||
LockOpen,
|
||
RefreshCw,
|
||
Zap
|
||
} from 'lucide-vue-next'
|
||
import { Dialog, Button, Input } from '@/components/ui'
|
||
import { useToast } from '@/composables/useToast'
|
||
import { useConfirm } from '@/composables/useConfirm'
|
||
import { parseApiError } from '@/utils/errorParser'
|
||
import {
|
||
getProviderModels,
|
||
updateProviderKey,
|
||
type EndpointAPIKey,
|
||
type AllowedModels,
|
||
} from '@/api/endpoints'
|
||
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
|
||
import { formatApiFormatShort, type UpstreamModel } from '@/api/endpoints/types'
|
||
|
||
interface AvailableModel {
|
||
name: string
|
||
display_name: string
|
||
global_model_name?: string
|
||
global_model_display_name?: string
|
||
}
|
||
|
||
const props = defineProps<{
|
||
open: boolean
|
||
apiKey: EndpointAPIKey | null
|
||
providerId: string
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
close: []
|
||
saved: []
|
||
}>()
|
||
|
||
const { success, error: showError, warning: showWarning } = useToast()
|
||
const { confirmWarning } = useConfirm()
|
||
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||
|
||
const isOpen = computed(() => props.open)
|
||
const saving = ref(false)
|
||
const loadingProviderModels = ref(false)
|
||
const fetchingUpstreamModels = ref(false)
|
||
const upstreamModelsLoaded = ref(false)
|
||
|
||
// 用于取消异步操作的标志
|
||
let loadingCancelled = false
|
||
|
||
// 搜索
|
||
const searchQuery = ref('')
|
||
|
||
// 可用模型列表(该 Provider 已关联的模型)
|
||
const allProviderModels = ref<AvailableModel[]>([])
|
||
// 上游模型列表(从 API 查询获取)
|
||
const upstreamModels = ref<UpstreamModel[]>([])
|
||
|
||
// 已选中的模型
|
||
const selectedModels = ref<string[]>([])
|
||
const initialSelectedModels = ref<string[]>([])
|
||
|
||
// 已锁定的模型
|
||
const lockedModels = ref<string[]>([])
|
||
const initialLockedModels = ref<string[]>([])
|
||
|
||
// 所有添加过的自定义模型(包括已取消勾选的,保存前不消失)
|
||
const allCustomModels = ref<string[]>([])
|
||
|
||
// 是否为自动获取模式
|
||
const isAutoFetchMode = computed(() => props.apiKey?.auto_fetch_models ?? false)
|
||
|
||
// 空状态判断
|
||
const showEmptyState = computed(() => {
|
||
return filteredProviderModels.value.length === 0 &&
|
||
filteredUpstreamModels.value.length === 0 &&
|
||
customModels.value.length === 0
|
||
})
|
||
|
||
// 折叠状态
|
||
const collapsedGroups = ref<Set<string>>(new Set())
|
||
|
||
// 是否有更改
|
||
const hasChanges = computed(() => {
|
||
// 检查选中模型是否有变化
|
||
if (selectedModels.value.length !== initialSelectedModels.value.length) return true
|
||
const sorted1 = [...selectedModels.value].sort()
|
||
const sorted2 = [...initialSelectedModels.value].sort()
|
||
if (sorted1.some((v, i) => v !== sorted2[i])) return true
|
||
|
||
// 检查锁定模型是否有变化
|
||
if (lockedModels.value.length !== initialLockedModels.value.length) return true
|
||
const sortedLocked1 = [...lockedModels.value].sort()
|
||
const sortedLocked2 = [...initialLockedModels.value].sort()
|
||
return sortedLocked1.some((v, i) => v !== sortedLocked2[i])
|
||
})
|
||
|
||
// 所有已知模型的集合(提供商模型 + 上游模型)
|
||
const allKnownModels = computed(() => {
|
||
const set = new Set<string>()
|
||
allProviderModels.value.forEach(m => set.add(m.name))
|
||
upstreamModels.value.forEach(m => set.add(m.id))
|
||
return set
|
||
})
|
||
|
||
// 提供商模型名称集合(用于判断模型是否为“提供商模型”分组项)
|
||
const providerModelNamesSet = computed(() => {
|
||
return new Set(allProviderModels.value.map(m => m.name))
|
||
})
|
||
|
||
// 判断模型是否为“提供商模型”分组项
|
||
function isProviderModel(modelId: string): boolean {
|
||
return providerModelNamesSet.value.has(modelId)
|
||
}
|
||
|
||
// 上游模型列表(后端已按 id 聚合,包含 api_formats 数组)
|
||
// 这里只做排序
|
||
const upstreamModelList = computed(() => {
|
||
return [...upstreamModels.value].sort((a, b) => a.id.localeCompare(b.id))
|
||
})
|
||
|
||
// 上游模型名称列表(用于计数和全选判断)
|
||
const upstreamModelNames = computed(() => upstreamModelList.value.map(m => m.id))
|
||
|
||
// 过滤后的上游模型
|
||
const filteredUpstreamModels = computed(() => {
|
||
if (!searchQuery.value.trim()) return upstreamModelList.value
|
||
const query = searchQuery.value.toLowerCase()
|
||
return upstreamModelList.value.filter(m => m.id.toLowerCase().includes(query))
|
||
})
|
||
|
||
// 上游模型是否全选
|
||
const isAllUpstreamModelsSelected = computed(() => {
|
||
if (filteredUpstreamModels.value.length === 0) return false
|
||
return filteredUpstreamModels.value.every(m => selectedModels.value.includes(m.id))
|
||
})
|
||
|
||
// 全选/取消全选上游模型
|
||
function toggleAllUpstreamModels() {
|
||
const allIds = filteredUpstreamModels.value.map(m => m.id)
|
||
if (isAllUpstreamModelsSelected.value) {
|
||
selectedModels.value = selectedModels.value.filter(id => !allIds.includes(id))
|
||
} else {
|
||
allIds.forEach(id => {
|
||
if (!selectedModels.value.includes(id)) {
|
||
selectedModels.value.push(id)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 处理上游模型点击(自动同步模式下禁用)
|
||
function handleUpstreamModelClick(model: UpstreamModel) {
|
||
if (!isAutoFetchMode.value) {
|
||
toggleModel(model.id)
|
||
}
|
||
}
|
||
|
||
// 自定义模型列表(显示所有添加过的,不因取消勾选而消失)
|
||
const customModels = computed(() => {
|
||
return allCustomModels.value
|
||
})
|
||
|
||
// 排序后的自定义模型(搜索命中的排前面)
|
||
const sortedCustomModels = computed(() => {
|
||
const search = searchQuery.value.toLowerCase().trim()
|
||
if (!search) return customModels.value
|
||
|
||
const matched: string[] = []
|
||
const unmatched: string[] = []
|
||
for (const m of customModels.value) {
|
||
if (m.toLowerCase().includes(search)) {
|
||
matched.push(m)
|
||
} else {
|
||
unmatched.push(m)
|
||
}
|
||
}
|
||
return [...matched, ...unmatched]
|
||
})
|
||
|
||
// 判断搜索内容是否可以作为自定义模型添加
|
||
const canAddAsCustom = computed(() => {
|
||
const search = searchQuery.value.trim()
|
||
if (!search) return false
|
||
// 已经选中了就不显示
|
||
if (selectedModels.value.includes(search)) return false
|
||
// 已经在自定义模型列表中就不显示
|
||
if (allCustomModels.value.includes(search)) return false
|
||
// 精确匹配提供商模型就不显示
|
||
if (allProviderModels.value.some(m => m.name === search)) return false
|
||
// 精确匹配上游模型就不显示
|
||
if (upstreamModelNames.value.includes(search)) return false
|
||
return true
|
||
})
|
||
|
||
// 搜索过滤后的提供商模型
|
||
const filteredProviderModels = computed(() => {
|
||
if (!searchQuery.value.trim()) return allProviderModels.value
|
||
const query = searchQuery.value.toLowerCase()
|
||
return allProviderModels.value.filter(m =>
|
||
m.name.toLowerCase().includes(query) ||
|
||
m.display_name.toLowerCase().includes(query) ||
|
||
(m.global_model_name || '').toLowerCase().includes(query) ||
|
||
(m.global_model_display_name || '').toLowerCase().includes(query)
|
||
)
|
||
})
|
||
|
||
// 提供商模型是否全选
|
||
const isAllProviderModelsSelected = computed(() => {
|
||
if (filteredProviderModels.value.length === 0) return false
|
||
return filteredProviderModels.value.every(m => selectedModels.value.includes(m.name))
|
||
})
|
||
|
||
// 切换模型选中状态
|
||
function toggleModel(modelId: string) {
|
||
const idx = selectedModels.value.indexOf(modelId)
|
||
if (idx === -1) {
|
||
selectedModels.value.push(modelId)
|
||
// 自动获取模式下,勾选提供商模型时自动锁定
|
||
// 防止下次刷新时被覆盖(即使全局模型与上游模型同名)
|
||
if (isAutoFetchMode.value && isProviderModel(modelId)) {
|
||
if (!lockedModels.value.includes(modelId)) {
|
||
lockedModels.value.push(modelId)
|
||
}
|
||
}
|
||
} else {
|
||
selectedModels.value.splice(idx, 1)
|
||
// 取消选中时也取消锁定
|
||
const lockIdx = lockedModels.value.indexOf(modelId)
|
||
if (lockIdx !== -1) {
|
||
lockedModels.value.splice(lockIdx, 1)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 切换模型锁定状态
|
||
function toggleLock(modelId: string, event: Event) {
|
||
event.stopPropagation()
|
||
// 只有已选中的模型才能锁定
|
||
if (!selectedModels.value.includes(modelId)) return
|
||
|
||
const idx = lockedModels.value.indexOf(modelId)
|
||
if (idx === -1) {
|
||
lockedModels.value.push(modelId)
|
||
} else {
|
||
lockedModels.value.splice(idx, 1)
|
||
}
|
||
}
|
||
|
||
// 检查模型是否被锁定
|
||
function isLocked(modelId: string): boolean {
|
||
return lockedModels.value.includes(modelId)
|
||
}
|
||
|
||
// 添加自定义模型
|
||
function addCustomModel() {
|
||
const model = searchQuery.value.trim()
|
||
if (model && !selectedModels.value.includes(model)) {
|
||
selectedModels.value.push(model)
|
||
// 同时添加到自定义模型列表(如果不在已知模型中)
|
||
if (!allKnownModels.value.has(model) && !allCustomModels.value.includes(model)) {
|
||
allCustomModels.value.push(model)
|
||
}
|
||
searchQuery.value = ''
|
||
}
|
||
}
|
||
|
||
// 全选/取消全选提供商模型
|
||
function toggleAllProviderModels() {
|
||
const allNames = filteredProviderModels.value.map(m => m.name)
|
||
if (isAllProviderModelsSelected.value) {
|
||
// 取消全选
|
||
selectedModels.value = selectedModels.value.filter(id => !allNames.includes(id))
|
||
// 同时取消锁定
|
||
lockedModels.value = lockedModels.value.filter(id => !allNames.includes(id))
|
||
} else {
|
||
// 全选
|
||
allNames.forEach(name => {
|
||
if (!selectedModels.value.includes(name)) {
|
||
selectedModels.value.push(name)
|
||
// 自动获取模式下,勾选提供商模型时自动锁定
|
||
if (isAutoFetchMode.value && !lockedModels.value.includes(name)) {
|
||
lockedModels.value.push(name)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 切换折叠状态
|
||
function toggleGroupCollapse(group: string) {
|
||
if (collapsedGroups.value.has(group)) {
|
||
collapsedGroups.value.delete(group)
|
||
} else {
|
||
collapsedGroups.value.add(group)
|
||
}
|
||
collapsedGroups.value = new Set(collapsedGroups.value)
|
||
}
|
||
|
||
// 加载该 Provider 已关联的模型
|
||
async function loadProviderModels() {
|
||
loadingProviderModels.value = true
|
||
try {
|
||
const response = await getProviderModels(props.providerId, { limit: 1000 })
|
||
if (loadingCancelled) return
|
||
allProviderModels.value = response.map(m => ({
|
||
name: m.provider_model_name,
|
||
display_name: m.global_model_display_name || m.global_model_name || m.provider_model_name,
|
||
global_model_name: m.global_model_name,
|
||
global_model_display_name: m.global_model_display_name
|
||
}))
|
||
} catch {
|
||
if (loadingCancelled) return
|
||
showError('加载提供商模型失败', '错误')
|
||
} finally {
|
||
loadingProviderModels.value = false
|
||
}
|
||
}
|
||
|
||
// 从提供商获取模型(使用缓存)
|
||
// 传 apiKeyId,获取当前 Key 自己能访问的上游模型
|
||
async function fetchUpstreamModels(forceRefresh = false) {
|
||
if (!props.providerId || !props.apiKey) return
|
||
try {
|
||
fetchingUpstreamModels.value = true
|
||
const result = await fetchCachedModels(props.providerId, props.apiKey.id, forceRefresh)
|
||
if (loadingCancelled) return
|
||
if (result.models.length > 0) {
|
||
upstreamModels.value = result.models
|
||
upstreamModelsLoaded.value = true
|
||
// 获取上游模型后,从自定义模型列表中移除已变成已知的模型
|
||
const upstreamIds = new Set(result.models.map((m: UpstreamModel) => m.id))
|
||
allCustomModels.value = allCustomModels.value.filter(m => !upstreamIds.has(m))
|
||
if (result.warning) {
|
||
showWarning(result.warning, '部分格式获取失败')
|
||
}
|
||
} else if (result.error) {
|
||
showError(result.error, '获取上游模型失败')
|
||
}
|
||
} finally {
|
||
fetchingUpstreamModels.value = false
|
||
}
|
||
}
|
||
|
||
// 手动刷新上游模型(强制跳过缓存)
|
||
async function refreshUpstreamModels() {
|
||
await fetchUpstreamModels(true)
|
||
if (upstreamModels.value.length > 0) {
|
||
success('上游模型已刷新')
|
||
// 获取成功后收缩所有分组
|
||
collapsedGroups.value = new Set(['global', 'upstream', 'custom'])
|
||
}
|
||
}
|
||
|
||
// 解析 allowed_models
|
||
function parseAllowedModels(allowed: AllowedModels): string[] {
|
||
if (allowed === null || allowed === undefined) {
|
||
return []
|
||
}
|
||
return [...allowed]
|
||
}
|
||
|
||
async function initializeDialogState(apiKey: EndpointAPIKey) {
|
||
loadingCancelled = false
|
||
|
||
const parsed = parseAllowedModels(apiKey.allowed_models ?? null)
|
||
selectedModels.value = [...parsed]
|
||
initialSelectedModels.value = [...parsed]
|
||
|
||
const locked = apiKey.locked_models ?? []
|
||
lockedModels.value = [...locked]
|
||
initialLockedModels.value = [...locked]
|
||
|
||
searchQuery.value = ''
|
||
upstreamModels.value = []
|
||
upstreamModelsLoaded.value = false
|
||
allCustomModels.value = []
|
||
|
||
if (apiKey.auto_fetch_models) {
|
||
collapsedGroups.value = new Set(['global', 'custom'])
|
||
} else {
|
||
collapsedGroups.value = new Set()
|
||
}
|
||
|
||
await loadProviderModels()
|
||
|
||
if (apiKey.auto_fetch_models) {
|
||
await fetchUpstreamModels()
|
||
}
|
||
|
||
const upstreamModelIdsSet = new Set(upstreamModels.value.map(m => m.id))
|
||
allCustomModels.value = selectedModels.value.filter(m =>
|
||
!providerModelNamesSet.value.has(m) && !upstreamModelIdsSet.has(m)
|
||
)
|
||
}
|
||
|
||
// 监听对话框打开 / 当前 key 变化
|
||
watch([() => props.open, () => props.apiKey], async ([open, apiKey]) => {
|
||
if (open && apiKey) {
|
||
await initializeDialogState(apiKey)
|
||
} else {
|
||
loadingCancelled = true
|
||
}
|
||
}, { immediate: true })
|
||
|
||
// 组件卸载时取消所有异步操作
|
||
onUnmounted(() => {
|
||
loadingCancelled = true
|
||
})
|
||
|
||
async function handleDialogUpdate(value: boolean) {
|
||
if (!value && hasChanges.value) {
|
||
const confirmed = await confirmWarning('有未保存的更改,确定要关闭吗?', '放弃更改')
|
||
if (!confirmed) return
|
||
}
|
||
if (!value) emit('close')
|
||
}
|
||
|
||
async function handleCancel() {
|
||
if (hasChanges.value) {
|
||
const confirmed = await confirmWarning('有未保存的更改,确定要关闭吗?', '放弃更改')
|
||
if (!confirmed) return
|
||
}
|
||
emit('close')
|
||
}
|
||
|
||
async function handleSave() {
|
||
if (!props.apiKey) return
|
||
|
||
saving.value = true
|
||
try {
|
||
const newAllowed: AllowedModels = selectedModels.value.length > 0
|
||
? [...selectedModels.value]
|
||
: null
|
||
|
||
// 只保存已选中且被锁定的模型
|
||
const newLocked = lockedModels.value.filter(m => selectedModels.value.includes(m))
|
||
|
||
await updateProviderKey(props.apiKey.id, {
|
||
allowed_models: newAllowed,
|
||
locked_models: newLocked
|
||
})
|
||
success('模型权限已更新', '成功')
|
||
emit('saved')
|
||
emit('close')
|
||
} catch (err: unknown) {
|
||
showError(parseApiError(err, '保存失败'), '错误')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
</script>
|