2026-01-10 18:43:53 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
:model-value="isOpen"
|
|
|
|
|
|
title="模型权限"
|
2026-01-11 02:06:48 +08:00
|
|
|
|
:description="`管理密钥 ${props.apiKey?.name || ''} 可访问的模型`"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
:icon="Shield"
|
2026-01-11 02:06:48 +08:00
|
|
|
|
size="lg"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
@update:model-value="handleDialogUpdate"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #default>
|
|
|
|
|
|
<div class="space-y-4">
|
|
|
|
|
|
<!-- 字典模式警告 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="isDictMode"
|
|
|
|
|
|
class="rounded-lg border border-amber-500/50 bg-amber-50 dark:bg-amber-950/30 p-3"
|
|
|
|
|
|
>
|
|
|
|
|
|
<p class="text-sm text-amber-700 dark:text-amber-400">
|
|
|
|
|
|
<strong>注意:</strong>此密钥使用按 API 格式区分的模型权限配置。
|
|
|
|
|
|
编辑后将转换为统一列表模式,原有的格式区分信息将丢失。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<!-- 已选数量提示 -->
|
|
|
|
|
|
<div class="text-sm text-muted-foreground">
|
|
|
|
|
|
<span v-if="selectedModels.length === 0">允许访问全部模型</span>
|
|
|
|
|
|
<span v-else>已选择 {{ selectedModels.length }} 个模型</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 常驻选择面板 -->
|
|
|
|
|
|
<div class="border rounded-lg overflow-hidden">
|
|
|
|
|
|
<!-- 搜索 + 操作栏 -->
|
|
|
|
|
|
<div class="flex items-center gap-2 p-2 border-b bg-muted/30">
|
|
|
|
|
|
<div class="relative flex-1">
|
|
|
|
|
|
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="searchQuery"
|
|
|
|
|
|
placeholder="搜索模型或添加自定义模型..."
|
|
|
|
|
|
class="pl-8 h-8 text-sm"
|
|
|
|
|
|
/>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
</div>
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<button
|
|
|
|
|
|
v-if="upstreamModelsLoaded"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
|
|
|
|
|
title="刷新上游模型"
|
|
|
|
|
|
:disabled="fetchingUpstreamModels"
|
|
|
|
|
|
@click="fetchUpstreamModels()"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw
|
|
|
|
|
|
class="w-4 h-4"
|
|
|
|
|
|
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
v-else-if="!fetchingUpstreamModels"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-8"
|
|
|
|
|
|
title="从提供商获取模型"
|
|
|
|
|
|
@click="fetchUpstreamModels()"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
>
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<Zap class="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Loader2
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
|
|
|
|
|
|
/>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<!-- 分组列表 -->
|
|
|
|
|
|
<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" />
|
2026-01-10 18:43:53 +08:00
|
|
|
|
</div>
|
2026-01-11 02:06:48 +08:00
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<!-- 添加自定义模型(搜索内容不在列表中时显示,固定在顶部) -->
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<div
|
2026-01-11 02:06:48 +08:00
|
|
|
|
v-if="searchQuery && canAddAsCustom"
|
|
|
|
|
|
class="px-3 py-2 border-b bg-background sticky top-0 z-10"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
>
|
|
|
|
|
|
<div
|
2026-01-11 02:06:48 +08:00
|
|
|
|
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"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
>
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<Plus class="w-4 h-4 text-muted-foreground" />
|
|
|
|
|
|
<span class="text-sm font-mono">{{ searchQuery }}</span>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
</div>
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<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-1.5 bg-muted/50">
|
|
|
|
|
|
<span class="text-xs font-medium text-muted-foreground">自定义模型</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-1 p-2">
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<div
|
2026-01-11 02:06:48 +08:00
|
|
|
|
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)"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
>
|
|
|
|
|
|
<div
|
2026-01-11 02:06:48 +08:00
|
|
|
|
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
|
|
|
|
|
:class="selectedModels.includes(model) ? 'bg-primary border-primary' : ''"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
>
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<Check v-if="selectedModels.includes(model)" class="w-3 h-3 text-primary-foreground" />
|
2026-01-10 18:43:53 +08:00
|
|
|
|
</div>
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<span class="text-xs font-mono truncate">{{ model }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 全局模型 -->
|
|
|
|
|
|
<div v-if="filteredGlobalModels.length > 0">
|
|
|
|
|
|
<div class="flex items-center justify-between px-3 py-1.5 bg-muted/50">
|
|
|
|
|
|
<span class="text-xs font-medium text-muted-foreground">全局模型</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="text-xs text-primary hover:underline"
|
|
|
|
|
|
@click="toggleAllGlobalModels"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ isAllGlobalModelsSelected ? '取消全选' : '全选' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-1 p-2">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="model in filteredGlobalModels"
|
|
|
|
|
|
:key="model.name"
|
|
|
|
|
|
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
|
|
|
|
|
@click="toggleModel(model.name)"
|
|
|
|
|
|
>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<div
|
2026-01-11 02:06:48 +08:00
|
|
|
|
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
|
|
|
|
|
:class="selectedModels.includes(model.name) ? 'bg-primary border-primary' : ''"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
>
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<Check v-if="selectedModels.includes(model.name)" class="w-3 h-3 text-primary-foreground" />
|
2026-01-10 18:43:53 +08:00
|
|
|
|
</div>
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<span class="text-xs font-mono truncate">{{ model.name }}</span>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-11 02:06:48 +08:00
|
|
|
|
</div>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<!-- 上游模型组 -->
|
|
|
|
|
|
<div v-for="group in filteredUpstreamGroups" :key="group.api_format">
|
|
|
|
|
|
<div class="flex items-center justify-between px-3 py-1.5 bg-muted/50">
|
|
|
|
|
|
<span class="text-xs font-medium text-muted-foreground">
|
|
|
|
|
|
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="text-xs text-primary hover:underline"
|
|
|
|
|
|
@click="toggleAllUpstreamGroup(group.api_format)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ isUpstreamGroupAllSelected(group.api_format) ? '取消全选' : '全选' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-1 p-2">
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<div
|
2026-01-11 02:06:48 +08:00
|
|
|
|
v-for="model in group.models"
|
|
|
|
|
|
:key="model.id"
|
|
|
|
|
|
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
|
|
|
|
|
@click="toggleModel(model.id)"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
>
|
|
|
|
|
|
<div
|
2026-01-11 02:06:48 +08:00
|
|
|
|
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
|
|
|
|
|
:class="selectedModels.includes(model.id) ? 'bg-primary border-primary' : ''"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
>
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<Check v-if="selectedModels.includes(model.id)" class="w-3 h-3 text-primary-foreground" />
|
2026-01-10 18:43:53 +08:00
|
|
|
|
</div>
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<span class="text-xs font-mono truncate">{{ model.id }}</span>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<!-- 空状态 -->
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<div
|
2026-01-11 02:06:48 +08:00
|
|
|
|
v-if="filteredGlobalModels.length === 0 && filteredUpstreamGroups.length === 0 && customModels.length === 0"
|
|
|
|
|
|
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Shield class="w-10 h-10 mb-2 opacity-30" />
|
2026-01-11 02:06:48 +08:00
|
|
|
|
<p class="text-sm">{{ searchQuery ? '无匹配结果' : '暂无可选模型' }}</p>
|
|
|
|
|
|
<p v-if="!upstreamModelsLoaded" class="text-xs mt-1">点击闪电按钮从上游获取模型</p>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
</div>
|
2026-01-11 02:06:48 +08:00
|
|
|
|
</template>
|
2026-01-10 18:43:53 +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 ? '有未保存的更改' : '' }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<Button variant="outline" @click="handleCancel">取消</Button>
|
|
|
|
|
|
<Button :disabled="saving || !hasChanges" @click="handleSave">
|
|
|
|
|
|
{{ saving ? '保存中...' : '保存' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
Shield,
|
|
|
|
|
|
Search,
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
Loader2,
|
|
|
|
|
|
Zap,
|
2026-01-11 02:06:48 +08:00
|
|
|
|
Plus,
|
|
|
|
|
|
Check
|
2026-01-10 18:43:53 +08:00
|
|
|
|
} from 'lucide-vue-next'
|
2026-01-11 02:06:48 +08:00
|
|
|
|
import { Dialog, Button, Input } from '@/components/ui'
|
2026-01-10 18:43:53 +08:00
|
|
|
|
import { useToast } from '@/composables/useToast'
|
2026-01-11 00:41:41 +08:00
|
|
|
|
import { parseApiError, parseUpstreamModelError } from '@/utils/errorParser'
|
2026-01-10 18:43:53 +08:00
|
|
|
|
import {
|
|
|
|
|
|
updateProviderKey,
|
|
|
|
|
|
API_FORMAT_LABELS,
|
|
|
|
|
|
type EndpointAPIKey,
|
|
|
|
|
|
type AllowedModels,
|
|
|
|
|
|
} from '@/api/endpoints'
|
|
|
|
|
|
import { getGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
|
|
|
|
|
import { adminApi } from '@/api/admin'
|
|
|
|
|
|
import type { UpstreamModel } from '@/api/endpoints/types'
|
|
|
|
|
|
|
|
|
|
|
|
interface AvailableModel {
|
|
|
|
|
|
name: string
|
|
|
|
|
|
display_name: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
open: boolean
|
|
|
|
|
|
apiKey: EndpointAPIKey | null
|
|
|
|
|
|
providerId: string
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
close: []
|
|
|
|
|
|
saved: []
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const { success, error: showError } = useToast()
|
|
|
|
|
|
|
|
|
|
|
|
const isOpen = computed(() => props.open)
|
|
|
|
|
|
const saving = ref(false)
|
|
|
|
|
|
const loadingGlobalModels = ref(false)
|
|
|
|
|
|
const fetchingUpstreamModels = ref(false)
|
|
|
|
|
|
const upstreamModelsLoaded = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
// 用于取消异步操作的标志
|
|
|
|
|
|
let loadingCancelled = false
|
|
|
|
|
|
|
|
|
|
|
|
// 搜索
|
|
|
|
|
|
const searchQuery = ref('')
|
|
|
|
|
|
|
|
|
|
|
|
// 可用模型列表(全局模型)
|
|
|
|
|
|
const allGlobalModels = ref<AvailableModel[]>([])
|
|
|
|
|
|
// 上游模型列表
|
|
|
|
|
|
const upstreamModels = ref<UpstreamModel[]>([])
|
|
|
|
|
|
|
2026-01-11 02:06:48 +08:00
|
|
|
|
// 已选中的模型
|
|
|
|
|
|
const selectedModels = ref<string[]>([])
|
|
|
|
|
|
const initialSelectedModels = ref<string[]>([])
|
2026-01-10 18:43:53 +08:00
|
|
|
|
|
2026-01-11 02:06:48 +08:00
|
|
|
|
// 所有添加过的自定义模型(包括已取消勾选的,保存前不消失)
|
|
|
|
|
|
const allCustomModels = ref<string[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
// 是否为字典模式(按 API 格式区分)
|
|
|
|
|
|
const isDictMode = ref(false)
|
2026-01-10 18:43:53 +08:00
|
|
|
|
|
|
|
|
|
|
// 是否有更改
|
|
|
|
|
|
const hasChanges = computed(() => {
|
2026-01-11 02:06:48 +08:00
|
|
|
|
if (selectedModels.value.length !== initialSelectedModels.value.length) return true
|
|
|
|
|
|
const sorted1 = [...selectedModels.value].sort()
|
|
|
|
|
|
const sorted2 = [...initialSelectedModels.value].sort()
|
2026-01-10 18:43:53 +08:00
|
|
|
|
return sorted1.some((v, i) => v !== sorted2[i])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-11 02:06:48 +08:00
|
|
|
|
// 所有已知模型的集合(全局 + 上游)
|
|
|
|
|
|
const allKnownModels = computed(() => {
|
|
|
|
|
|
const set = new Set<string>()
|
|
|
|
|
|
allGlobalModels.value.forEach(m => set.add(m.name))
|
|
|
|
|
|
upstreamModels.value.forEach(m => set.add(m.id))
|
|
|
|
|
|
return set
|
2026-01-10 18:43:53 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-11 02:06:48 +08:00
|
|
|
|
// 自定义模型列表(显示所有添加过的,不因取消勾选而消失)
|
|
|
|
|
|
const customModels = computed(() => {
|
|
|
|
|
|
return allCustomModels.value
|
2026-01-10 18:43:53 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-11 02:06:48 +08:00
|
|
|
|
// 排序后的自定义模型(搜索命中的排前面)
|
|
|
|
|
|
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]
|
2026-01-10 18:43:53 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-11 02:06:48 +08:00
|
|
|
|
// 判断搜索内容是否可以作为自定义模型添加
|
|
|
|
|
|
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 (allGlobalModels.value.some(m => m.name === search)) return false
|
|
|
|
|
|
// 精确匹配上游模型就不显示
|
|
|
|
|
|
if (upstreamModels.value.some(m => m.id === search)) return false
|
|
|
|
|
|
return true
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 搜索过滤后的全局模型
|
|
|
|
|
|
const filteredGlobalModels = computed(() => {
|
|
|
|
|
|
if (!searchQuery.value.trim()) return allGlobalModels.value
|
2026-01-10 18:43:53 +08:00
|
|
|
|
const query = searchQuery.value.toLowerCase()
|
2026-01-11 02:06:48 +08:00
|
|
|
|
return allGlobalModels.value.filter(m =>
|
|
|
|
|
|
m.name.toLowerCase().includes(query) ||
|
|
|
|
|
|
m.display_name.toLowerCase().includes(query)
|
2026-01-10 18:43:53 +08:00
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-11 02:06:48 +08:00
|
|
|
|
// 按 API 格式分组的上游模型(过滤后)
|
|
|
|
|
|
const filteredUpstreamGroups = computed(() => {
|
|
|
|
|
|
if (!upstreamModelsLoaded.value) return []
|
|
|
|
|
|
|
|
|
|
|
|
const query = searchQuery.value.toLowerCase().trim()
|
2026-01-10 18:43:53 +08:00
|
|
|
|
const groups: Record<string, UpstreamModel[]> = {}
|
2026-01-11 02:06:48 +08:00
|
|
|
|
|
|
|
|
|
|
for (const model of upstreamModels.value) {
|
|
|
|
|
|
// 搜索过滤
|
|
|
|
|
|
if (query && !model.id.toLowerCase().includes(query)) continue
|
|
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
const format = model.api_format || 'unknown'
|
|
|
|
|
|
if (!groups[format]) groups[format] = []
|
|
|
|
|
|
groups[format].push(model)
|
|
|
|
|
|
}
|
2026-01-11 02:06:48 +08:00
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
const order = Object.keys(API_FORMAT_LABELS)
|
|
|
|
|
|
return Object.entries(groups)
|
|
|
|
|
|
.map(([api_format, models]) => ({ api_format, models }))
|
2026-01-11 02:06:48 +08:00
|
|
|
|
.filter(g => g.models.length > 0)
|
2026-01-10 18:43:53 +08:00
|
|
|
|
.sort((a, b) => {
|
|
|
|
|
|
const aIndex = order.indexOf(a.api_format)
|
|
|
|
|
|
const bIndex = order.indexOf(b.api_format)
|
|
|
|
|
|
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
|
|
|
|
|
|
if (aIndex === -1) return 1
|
|
|
|
|
|
if (bIndex === -1) return -1
|
|
|
|
|
|
return aIndex - bIndex
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 全局模型是否全选
|
|
|
|
|
|
const isAllGlobalModelsSelected = computed(() => {
|
2026-01-11 02:06:48 +08:00
|
|
|
|
if (filteredGlobalModels.value.length === 0) return false
|
|
|
|
|
|
return filteredGlobalModels.value.every(m => selectedModels.value.includes(m.name))
|
2026-01-10 18:43:53 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 检查某个上游组是否全选
|
|
|
|
|
|
function isUpstreamGroupAllSelected(apiFormat: string): boolean {
|
2026-01-11 02:06:48 +08:00
|
|
|
|
const group = filteredUpstreamGroups.value.find(g => g.api_format === apiFormat)
|
2026-01-10 18:43:53 +08:00
|
|
|
|
if (!group || group.models.length === 0) return false
|
2026-01-11 02:06:48 +08:00
|
|
|
|
return group.models.every(m => selectedModels.value.includes(m.id))
|
2026-01-10 18:43:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 02:06:48 +08:00
|
|
|
|
// 切换模型选中状态
|
|
|
|
|
|
function toggleModel(modelId: string) {
|
|
|
|
|
|
const idx = selectedModels.value.indexOf(modelId)
|
|
|
|
|
|
if (idx === -1) {
|
|
|
|
|
|
selectedModels.value.push(modelId)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
selectedModels.value.splice(idx, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 添加自定义模型
|
|
|
|
|
|
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 toggleAllGlobalModels() {
|
|
|
|
|
|
const allNames = filteredGlobalModels.value.map(m => m.name)
|
|
|
|
|
|
if (isAllGlobalModelsSelected.value) {
|
|
|
|
|
|
// 取消全选
|
|
|
|
|
|
selectedModels.value = selectedModels.value.filter(id => !allNames.includes(id))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 全选
|
|
|
|
|
|
allNames.forEach(name => {
|
|
|
|
|
|
if (!selectedModels.value.includes(name)) {
|
|
|
|
|
|
selectedModels.value.push(name)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 全选/取消全选某个上游组
|
|
|
|
|
|
function toggleAllUpstreamGroup(apiFormat: string) {
|
|
|
|
|
|
const group = filteredUpstreamGroups.value.find(g => g.api_format === apiFormat)
|
|
|
|
|
|
if (!group) return
|
|
|
|
|
|
|
|
|
|
|
|
const allIds = group.models.map(m => m.id)
|
|
|
|
|
|
if (isUpstreamGroupAllSelected(apiFormat)) {
|
|
|
|
|
|
// 取消全选
|
|
|
|
|
|
selectedModels.value = selectedModels.value.filter(id => !allIds.includes(id))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 全选
|
|
|
|
|
|
allIds.forEach(id => {
|
|
|
|
|
|
if (!selectedModels.value.includes(id)) {
|
|
|
|
|
|
selectedModels.value.push(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-01-10 18:43:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载全局模型
|
|
|
|
|
|
async function loadGlobalModels() {
|
|
|
|
|
|
loadingGlobalModels.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await getGlobalModels({ limit: 1000 })
|
|
|
|
|
|
if (loadingCancelled) return
|
|
|
|
|
|
allGlobalModels.value = response.models.map((m: GlobalModelResponse) => ({
|
|
|
|
|
|
name: m.name,
|
|
|
|
|
|
display_name: m.display_name
|
|
|
|
|
|
}))
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (loadingCancelled) return
|
|
|
|
|
|
showError('加载全局模型失败', '错误')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingGlobalModels.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 02:06:48 +08:00
|
|
|
|
// 从提供商获取模型
|
2026-01-10 18:43:53 +08:00
|
|
|
|
async function fetchUpstreamModels() {
|
|
|
|
|
|
if (!props.providerId || !props.apiKey) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
fetchingUpstreamModels.value = true
|
|
|
|
|
|
const response = await adminApi.queryProviderModels(props.providerId, props.apiKey.id)
|
|
|
|
|
|
if (loadingCancelled) return
|
|
|
|
|
|
if (response.success && response.data?.models) {
|
|
|
|
|
|
upstreamModels.value = response.data.models
|
|
|
|
|
|
upstreamModelsLoaded.value = true
|
2026-01-11 02:06:48 +08:00
|
|
|
|
// 获取上游模型后,从自定义模型列表中移除已变成已知的模型
|
|
|
|
|
|
const upstreamIds = new Set(response.data.models.map((m: UpstreamModel) => m.id))
|
|
|
|
|
|
allCustomModels.value = allCustomModels.value.filter(m => !upstreamIds.has(m))
|
2026-01-10 18:43:53 +08:00
|
|
|
|
} else {
|
2026-01-11 00:41:41 +08:00
|
|
|
|
const errorMsg = response.data?.error
|
|
|
|
|
|
? parseUpstreamModelError(response.data.error)
|
|
|
|
|
|
: '获取上游模型失败'
|
|
|
|
|
|
showError(errorMsg, '获取上游模型失败')
|
2026-01-10 18:43:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
if (loadingCancelled) return
|
2026-01-11 00:41:41 +08:00
|
|
|
|
const rawError = err.response?.data?.detail || err.message || '获取上游模型失败'
|
|
|
|
|
|
showError(parseUpstreamModelError(rawError), '获取上游模型失败')
|
2026-01-10 18:43:53 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
fetchingUpstreamModels.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析 allowed_models
|
|
|
|
|
|
function parseAllowedModels(allowed: AllowedModels): string[] {
|
|
|
|
|
|
if (allowed === null || allowed === undefined) {
|
|
|
|
|
|
isDictMode.value = false
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Array.isArray(allowed)) {
|
|
|
|
|
|
isDictMode.value = false
|
|
|
|
|
|
return [...allowed]
|
|
|
|
|
|
}
|
|
|
|
|
|
// 字典模式:合并所有格式的模型,并设置警告标志
|
|
|
|
|
|
isDictMode.value = true
|
|
|
|
|
|
const all = new Set<string>()
|
|
|
|
|
|
for (const models of Object.values(allowed)) {
|
|
|
|
|
|
models.forEach(m => all.add(m))
|
|
|
|
|
|
}
|
|
|
|
|
|
return Array.from(all)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 监听对话框打开
|
|
|
|
|
|
watch(() => props.open, async (open) => {
|
|
|
|
|
|
if (open && props.apiKey) {
|
|
|
|
|
|
loadingCancelled = false
|
|
|
|
|
|
|
|
|
|
|
|
const parsed = parseAllowedModels(props.apiKey.allowed_models ?? null)
|
2026-01-11 02:06:48 +08:00
|
|
|
|
selectedModels.value = [...parsed]
|
|
|
|
|
|
initialSelectedModels.value = [...parsed]
|
2026-01-10 18:43:53 +08:00
|
|
|
|
searchQuery.value = ''
|
|
|
|
|
|
upstreamModels.value = []
|
|
|
|
|
|
upstreamModelsLoaded.value = false
|
2026-01-11 02:06:48 +08:00
|
|
|
|
allCustomModels.value = []
|
2026-01-10 18:43:53 +08:00
|
|
|
|
|
|
|
|
|
|
await loadGlobalModels()
|
2026-01-11 02:06:48 +08:00
|
|
|
|
|
|
|
|
|
|
// 加载全局模型后,从已选中的模型中提取自定义模型(不在全局模型中的)
|
|
|
|
|
|
const globalModelNames = new Set(allGlobalModels.value.map(m => m.name))
|
|
|
|
|
|
allCustomModels.value = selectedModels.value.filter(m => !globalModelNames.has(m))
|
2026-01-10 18:43:53 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
loadingCancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 组件卸载时取消所有异步操作
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
loadingCancelled = true
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
function handleDialogUpdate(value: boolean) {
|
|
|
|
|
|
if (!value) emit('close')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleCancel() {
|
|
|
|
|
|
emit('close')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSave() {
|
|
|
|
|
|
if (!props.apiKey) return
|
|
|
|
|
|
|
|
|
|
|
|
saving.value = true
|
|
|
|
|
|
try {
|
2026-01-11 02:06:48 +08:00
|
|
|
|
const newAllowed: AllowedModels = selectedModels.value.length > 0
|
|
|
|
|
|
? [...selectedModels.value]
|
2026-01-10 18:43:53 +08:00
|
|
|
|
: null
|
|
|
|
|
|
|
|
|
|
|
|
await updateProviderKey(props.apiKey.id, { allowed_models: newAllowed })
|
|
|
|
|
|
success('模型权限已更新', '成功')
|
|
|
|
|
|
emit('saved')
|
|
|
|
|
|
emit('close')
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
showError(parseApiError(err, '保存失败'), '错误')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|