refactor(ui): 简化模型权限编辑对话框交互

- 将模型权限对话框从双栏穿梭框重构为单面板多选模式
- 简化对话框尺寸,优化布局为单列显示避免模型名称截断
- 模型列表移除能力列,新增快捷添加映射按钮
- 支持从模型列表直接跳转到模型映射对话框并预选模型
This commit is contained in:
fawney19
2026-01-11 02:06:48 +08:00
parent 8d8b20aa47
commit 76ed136228
5 changed files with 337 additions and 488 deletions

View File

@@ -2,9 +2,9 @@
<Dialog
:model-value="isOpen"
title="模型权限"
:description="`管理密钥 ${props.apiKey?.name || ''} 可访问的模型,清空右侧列表表示允许全部`"
:description="`管理密钥 ${props.apiKey?.name || ''} 可访问的模型`"
:icon="Shield"
size="4xl"
size="lg"
@update:model-value="handleDialogUpdate"
>
<template #default>
@@ -20,285 +20,176 @@
</p>
</div>
<!-- 密钥信息头部 -->
<div class="rounded-lg border bg-muted/30 p-4">
<div class="flex items-start justify-between">
<div>
<p class="font-semibold text-lg">{{ apiKey?.name }}</p>
<p class="text-sm text-muted-foreground font-mono">
{{ apiKey?.api_key_masked }}
</p>
</div>
<Badge
:variant="allowedModels.length === 0 ? 'default' : 'outline'"
class="text-xs"
>
{{ allowedModels.length === 0 ? '允许全部' : `限制 ${allowedModels.length} 个模型` }}
</Badge>
</div>
<!-- 已选数量提示 -->
<div class="text-sm text-muted-foreground">
<span v-if="selectedModels.length === 0">允许访问全部模型</span>
<span v-else>已选择 {{ selectedModels.length }} 个模型</span>
</div>
<!-- 左右对比布局 -->
<div class="flex gap-2 items-stretch">
<!-- 左侧可添加的模型 -->
<div class="flex-1 space-y-2">
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-medium shrink-0">可添加</p>
<div class="flex-1 relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
v-model="searchQuery"
placeholder="搜索模型..."
class="pl-7 h-7 text-xs"
/>
</div>
<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-3.5 h-3.5"
:class="{ 'animate-spin': fetchingUpstreamModels }"
/>
</button>
<button
v-else-if="!fetchingUpstreamModels"
type="button"
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
title="从提供商获取模型"
@click="fetchUpstreamModels()"
>
<Zap class="w-3.5 h-3.5" />
</button>
<Loader2
v-else
class="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0"
<!-- 常驻选择面板 -->
<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"
/>
</div>
<div class="border rounded-lg h-80 overflow-y-auto">
<div
v-if="loadingGlobalModels"
class="flex items-center justify-center h-full"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" />
</div>
<div
v-else-if="totalAvailableCount === 0 && !upstreamModelsLoaded"
class="flex flex-col items-center justify-center h-full text-muted-foreground"
>
<Shield class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">{{ searchQuery ? '无匹配结果' : '暂无可添加模型' }}</p>
</div>
<div v-else class="p-2 space-y-2">
<!-- 全局模型折叠组 -->
<div
v-if="availableGlobalModels.length > 0 || !upstreamModelsLoaded"
class="border rounded-lg overflow-hidden"
>
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
<button
type="button"
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
@click="toggleGroupCollapse('global')"
>
<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">
({{ availableGlobalModels.length }})
</span>
</button>
<button
v-if="availableGlobalModels.length > 0"
type="button"
class="text-xs text-primary hover:underline shrink-0"
@click.stop="selectAllGlobalModels"
>
{{ isAllGlobalModelsSelected ? '取消' : '全选' }}
</button>
</div>
<div
v-show="!collapsedGroups.has('global')"
class="p-2 space-y-1 border-t"
>
<div
v-if="availableGlobalModels.length === 0"
class="py-4 text-center text-xs text-muted-foreground"
>
所有全局模型均已添加
</div>
<div
v-for="model in availableGlobalModels"
v-else
:key="model.name"
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
:class="selectedLeftIds.includes(model.name)
? 'border-primary bg-primary/10'
: 'hover:bg-muted/50'"
@click="toggleLeftSelection(model.name)"
>
<Checkbox
:checked="selectedLeftIds.includes(model.name)"
@update:checked="toggleLeftSelection(model.name)"
@click.stop
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">{{ model.display_name }}</p>
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.name }}</p>
</div>
</div>
</div>
</div>
<!-- 从提供商获取的模型折叠组 -->
<div
v-for="group in upstreamModelGroups"
:key="group.api_format"
class="border rounded-lg overflow-hidden"
>
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
<button
type="button"
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
@click="toggleGroupCollapse(group.api_format)"
>
<ChevronDown
class="w-4 h-4 transition-transform shrink-0"
:class="collapsedGroups.has(group.api_format) ? '-rotate-90' : ''"
/>
<span class="text-xs font-medium">
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
</span>
<span class="text-xs text-muted-foreground">
({{ group.models.length }})
</span>
</button>
<button
type="button"
class="text-xs text-primary hover:underline shrink-0"
@click.stop="selectAllUpstreamModels(group.api_format)"
>
{{ isUpstreamGroupAllSelected(group.api_format) ? '取消' : '全选' }}
</button>
</div>
<div
v-show="!collapsedGroups.has(group.api_format)"
class="p-2 space-y-1 border-t"
>
<div
v-for="model in group.models"
:key="model.id"
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
:class="selectedLeftIds.includes(model.id)
? 'border-primary bg-primary/10'
: 'hover:bg-muted/50'"
@click="toggleLeftSelection(model.id)"
>
<Checkbox
:checked="selectedLeftIds.includes(model.id)"
@update:checked="toggleLeftSelection(model.id)"
@click.stop
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">{{ model.id }}</p>
<p class="text-xs text-muted-foreground truncate font-mono">
{{ model.owned_by || model.id }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 中间操作按钮 -->
<div class="flex flex-col items-center justify-center w-12 shrink-0 gap-2">
<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="w-9 h-8"
:class="selectedLeftIds.length > 0 ? 'border-primary' : ''"
:disabled="selectedLeftIds.length === 0"
title="添加选中"
@click="addSelected"
class="h-8"
title="从提供商获取模型"
@click="fetchUpstreamModels()"
>
<ChevronRight
class="w-6 h-6 stroke-[3]"
:class="selectedLeftIds.length > 0 ? 'text-primary' : ''"
/>
</Button>
<Button
variant="outline"
size="sm"
class="w-9 h-8"
:class="selectedRightIds.length > 0 ? 'border-primary' : ''"
:disabled="selectedRightIds.length === 0"
title="移除选中"
@click="removeSelected"
>
<ChevronLeft
class="w-6 h-6 stroke-[3]"
:class="selectedRightIds.length > 0 ? 'text-primary' : ''"
/>
<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="flex-1 space-y-2">
<div class="flex items-center justify-between">
<p class="text-sm font-medium">已添加</p>
<Button
v-if="allowedModels.length > 0"
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="toggleSelectAllRight"
>
{{ isAllRightSelected ? '取消' : '全选' }}
</Button>
<!-- 分组列表 -->
<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" />
</div>
<div class="border rounded-lg h-80 overflow-y-auto">
<template v-else>
<!-- 添加自定义模型搜索内容不在列表中时显示固定在顶部 -->
<div
v-if="allowedModels.length === 0"
class="flex flex-col items-center justify-center h-full text-muted-foreground"
v-if="searchQuery && canAddAsCustom"
class="px-3 py-2 border-b bg-background sticky top-0 z-10"
>
<Shield class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">允许访问全部模型</p>
<p class="text-xs mt-1">添加模型以限制访问范围</p>
</div>
<div v-else class="p-2 space-y-1">
<div
v-for="modelName in allowedModels"
:key="'allowed-' + modelName"
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
:class="selectedRightIds.includes(modelName)
? 'border-primary bg-primary/10'
: 'hover:bg-muted/50'"
@click="toggleRightSelection(modelName)"
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"
>
<Checkbox
:checked="selectedRightIds.includes(modelName)"
@update:checked="toggleRightSelection(modelName)"
@click.stop
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">
{{ getModelDisplayName(modelName) }}
</p>
<p class="text-xs text-muted-foreground truncate font-mono">
{{ modelName }}
</p>
<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-1.5 bg-muted/50">
<span class="text-xs font-medium text-muted-foreground">自定义模型</span>
</div>
<div 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-xs font-mono truncate">{{ model }}</span>
</div>
</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)"
>
<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>
<span class="text-xs font-mono truncate">{{ model.name }}</span>
</div>
</div>
</div>
<!-- 上游模型组 -->
<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">
<div
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)"
>
<div
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="selectedModels.includes(model.id) ? 'bg-primary border-primary' : ''"
>
<Check v-if="selectedModels.includes(model.id)" class="w-3 h-3 text-primary-foreground" />
</div>
<span class="text-xs font-mono truncate">{{ model.id }}</span>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-if="filteredGlobalModels.length === 0 && filteredUpstreamGroups.length === 0 && customModels.length === 0"
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>
<p v-if="!upstreamModelsLoaded" class="text-xs mt-1">点击闪电按钮从上游获取模型</p>
</div>
</template>
</div>
</div>
</div>
@@ -328,11 +219,10 @@ import {
RefreshCw,
Loader2,
Zap,
ChevronRight,
ChevronLeft,
ChevronDown
Plus,
Check
} from 'lucide-vue-next'
import { Dialog, Button, Input, Checkbox, Badge } from '@/components/ui'
import { Dialog, Button, Input } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseApiError, parseUpstreamModelError } from '@/utils/errorParser'
import {
@@ -375,73 +265,104 @@ let loadingCancelled = false
// 搜索
const searchQuery = ref('')
// 折叠状态
const collapsedGroups = ref<Set<string>>(new Set())
// 可用模型列表(全局模型)
const allGlobalModels = ref<AvailableModel[]>([])
// 上游模型列表
const upstreamModels = ref<UpstreamModel[]>([])
// 已添加的允许模型(右侧)
const allowedModels = ref<string[]>([])
const initialAllowedModels = ref<string[]>([])
// 已选中的模型
const selectedModels = ref<string[]>([])
const initialSelectedModels = ref<string[]>([])
// 选中状态
const selectedLeftIds = ref<string[]>([])
const selectedRightIds = ref<string[]>([])
// 所有添加过的自定义模型(包括已取消勾选的,保存前不消失)
const allCustomModels = ref<string[]>([])
// 是否为字典模式(按 API 格式区分)
const isDictMode = ref(false)
// 是否有更改
const hasChanges = computed(() => {
if (allowedModels.value.length !== initialAllowedModels.value.length) return true
const sorted1 = [...allowedModels.value].sort()
const sorted2 = [...initialAllowedModels.value].sort()
if (selectedModels.value.length !== initialSelectedModels.value.length) return true
const sorted1 = [...selectedModels.value].sort()
const sorted2 = [...initialSelectedModels.value].sort()
return sorted1.some((v, i) => v !== sorted2[i])
})
// 计算可添加的全局模型(排除已添加的
const availableGlobalModelsBase = computed(() => {
const allowedSet = new Set(allowedModels.value)
return allGlobalModels.value.filter(m => !allowedSet.has(m.name))
// 所有已知模型的集合(全局 + 上游
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
})
// 自定义模型列表(显示所有添加过的,不因取消勾选而消失)
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 (allGlobalModels.value.some(m => m.name === search)) return false
// 精确匹配上游模型就不显示
if (upstreamModels.value.some(m => m.id === search)) return false
return true
})
// 搜索过滤后的全局模型
const availableGlobalModels = computed(() => {
if (!searchQuery.value.trim()) return availableGlobalModelsBase.value
const filteredGlobalModels = computed(() => {
if (!searchQuery.value.trim()) return allGlobalModels.value
const query = searchQuery.value.toLowerCase()
return availableGlobalModelsBase.value.filter(m =>
return allGlobalModels.value.filter(m =>
m.name.toLowerCase().includes(query) ||
m.display_name.toLowerCase().includes(query)
)
})
// 计算可添加的上游模型(排除已添加的
const availableUpstreamModelsBase = computed(() => {
const allowedSet = new Set(allowedModels.value)
return upstreamModels.value.filter(m => !allowedSet.has(m.id))
})
// 按 API 格式分组的上游模型(过滤后
const filteredUpstreamGroups = computed(() => {
if (!upstreamModelsLoaded.value) return []
// 搜索过滤后的上游模型
const availableUpstreamModels = computed(() => {
if (!searchQuery.value.trim()) return availableUpstreamModelsBase.value
const query = searchQuery.value.toLowerCase()
return availableUpstreamModelsBase.value.filter(m =>
m.id.toLowerCase().includes(query) ||
(m.owned_by && m.owned_by.toLowerCase().includes(query))
)
})
// 按 API 格式分组的上游模型
const upstreamModelGroups = computed(() => {
const query = searchQuery.value.toLowerCase().trim()
const groups: Record<string, UpstreamModel[]> = {}
for (const model of availableUpstreamModels.value) {
for (const model of upstreamModels.value) {
// 搜索过滤
if (query && !model.id.toLowerCase().includes(query)) continue
const format = model.api_format || 'unknown'
if (!groups[format]) groups[format] = []
groups[format].push(model)
}
const order = Object.keys(API_FORMAT_LABELS)
return Object.entries(groups)
.map(([api_format, models]) => ({ api_format, models }))
.filter(g => g.models.length > 0)
.sort((a, b) => {
const aIndex = order.indexOf(a.api_format)
const bIndex = order.indexOf(b.api_format)
@@ -452,37 +373,75 @@ const upstreamModelGroups = computed(() => {
})
})
// 总可添加数量
const totalAvailableCount = computed(() => {
return availableGlobalModels.value.length + availableUpstreamModels.value.length
})
// 右侧全选状态
const isAllRightSelected = computed(() =>
allowedModels.value.length > 0 &&
selectedRightIds.value.length === allowedModels.value.length
)
// 全局模型是否全选
const isAllGlobalModelsSelected = computed(() => {
if (availableGlobalModels.value.length === 0) return false
return availableGlobalModels.value.every(m => selectedLeftIds.value.includes(m.name))
if (filteredGlobalModels.value.length === 0) return false
return filteredGlobalModels.value.every(m => selectedModels.value.includes(m.name))
})
// 检查某个上游组是否全选
function isUpstreamGroupAllSelected(apiFormat: string): boolean {
const group = upstreamModelGroups.value.find(g => g.api_format === apiFormat)
const group = filteredUpstreamGroups.value.find(g => g.api_format === apiFormat)
if (!group || group.models.length === 0) return false
return group.models.every(m => selectedLeftIds.value.includes(m.id))
return group.models.every(m => selectedModels.value.includes(m.id))
}
// 获取模型显示名称
function getModelDisplayName(name: string): string {
const globalModel = allGlobalModels.value.find(m => m.name === name)
if (globalModel) return globalModel.display_name
const upstreamModel = upstreamModels.value.find(m => m.id === name)
if (upstreamModel) return upstreamModel.id
return name
// 切换模型选中状态
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)
}
})
}
}
// 加载全局模型
@@ -490,7 +449,6 @@ async function loadGlobalModels() {
loadingGlobalModels.value = true
try {
const response = await getGlobalModels({ limit: 1000 })
// 检查是否已取消dialog 已关闭)
if (loadingCancelled) return
allGlobalModels.value = response.models.map((m: GlobalModelResponse) => ({
name: m.name,
@@ -504,25 +462,20 @@ async function loadGlobalModels() {
}
}
// 从提供商获取模型(使用当前 key
// 从提供商获取模型
async function fetchUpstreamModels() {
if (!props.providerId || !props.apiKey) return
try {
fetchingUpstreamModels.value = true
// 使用当前 key 的 ID 来查询上游模型
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
const allGroups = new Set(['global'])
for (const model of response.data.models) {
if (model.api_format) allGroups.add(model.api_format)
}
collapsedGroups.value = allGroups
// 获取上游模型后,从自定义模型列表中移除已变成已知的模型
const upstreamIds = new Set(response.data.models.map((m: UpstreamModel) => m.id))
allCustomModels.value = allCustomModels.value.filter(m => !upstreamIds.has(m))
} else {
// 使用友好的错误解析
const errorMsg = response.data?.error
? parseUpstreamModelError(response.data.error)
: '获取上游模型失败'
@@ -530,7 +483,6 @@ async function fetchUpstreamModels() {
}
} catch (err: any) {
if (loadingCancelled) return
// 使用友好的错误解析
const rawError = err.response?.data?.detail || err.message || '获取上游模型失败'
showError(parseUpstreamModelError(rawError), '获取上游模型失败')
} finally {
@@ -538,19 +490,6 @@ async function fetchUpstreamModels() {
}
}
// 切换折叠状态
function toggleGroupCollapse(group: string) {
if (collapsedGroups.value.has(group)) {
collapsedGroups.value.delete(group)
} else {
collapsedGroups.value.add(group)
}
collapsedGroups.value = new Set(collapsedGroups.value)
}
// 是否为字典模式(按 API 格式区分)
const isDictMode = ref(false)
// 解析 allowed_models
function parseAllowedModels(allowed: AllowedModels): string[] {
if (allowed === null || allowed === undefined) {
@@ -570,98 +509,25 @@ function parseAllowedModels(allowed: AllowedModels): string[] {
return Array.from(all)
}
// 左侧选择
function toggleLeftSelection(name: string) {
const idx = selectedLeftIds.value.indexOf(name)
if (idx === -1) {
selectedLeftIds.value.push(name)
} else {
selectedLeftIds.value.splice(idx, 1)
}
}
// 右侧选择
function toggleRightSelection(name: string) {
const idx = selectedRightIds.value.indexOf(name)
if (idx === -1) {
selectedRightIds.value.push(name)
} else {
selectedRightIds.value.splice(idx, 1)
}
}
// 右侧全选切换
function toggleSelectAllRight() {
if (isAllRightSelected.value) {
selectedRightIds.value = []
} else {
selectedRightIds.value = [...allowedModels.value]
}
}
// 全选全局模型
function selectAllGlobalModels() {
const allNames = availableGlobalModels.value.map(m => m.name)
const allSelected = allNames.every(name => selectedLeftIds.value.includes(name))
if (allSelected) {
selectedLeftIds.value = selectedLeftIds.value.filter(id => !allNames.includes(id))
} else {
const newNames = allNames.filter(name => !selectedLeftIds.value.includes(name))
selectedLeftIds.value.push(...newNames)
}
}
// 全选某个 API 格式的上游模型
function selectAllUpstreamModels(apiFormat: string) {
const group = upstreamModelGroups.value.find(g => g.api_format === apiFormat)
if (!group) return
const allIds = group.models.map(m => m.id)
const allSelected = allIds.every(id => selectedLeftIds.value.includes(id))
if (allSelected) {
selectedLeftIds.value = selectedLeftIds.value.filter(id => !allIds.includes(id))
} else {
const newIds = allIds.filter(id => !selectedLeftIds.value.includes(id))
selectedLeftIds.value.push(...newIds)
}
}
// 添加选中的模型到右侧
function addSelected() {
for (const name of selectedLeftIds.value) {
if (!allowedModels.value.includes(name)) {
allowedModels.value.push(name)
}
}
selectedLeftIds.value = []
}
// 从右侧移除选中的模型
function removeSelected() {
allowedModels.value = allowedModels.value.filter(
name => !selectedRightIds.value.includes(name)
)
selectedRightIds.value = []
}
// 监听对话框打开
watch(() => props.open, async (open) => {
if (open && props.apiKey) {
// 重置取消标志
loadingCancelled = false
const parsed = parseAllowedModels(props.apiKey.allowed_models ?? null)
allowedModels.value = [...parsed]
initialAllowedModels.value = [...parsed]
selectedLeftIds.value = []
selectedRightIds.value = []
selectedModels.value = [...parsed]
initialSelectedModels.value = [...parsed]
searchQuery.value = ''
upstreamModels.value = []
upstreamModelsLoaded.value = false
collapsedGroups.value = new Set()
allCustomModels.value = []
await loadGlobalModels()
// 加载全局模型后,从已选中的模型中提取自定义模型(不在全局模型中的)
const globalModelNames = new Set(allGlobalModels.value.map(m => m.name))
allCustomModels.value = selectedModels.value.filter(m => !globalModelNames.has(m))
} else {
// dialog 关闭时设置取消标志
loadingCancelled = true
}
})
@@ -684,9 +550,8 @@ async function handleSave() {
saving.value = true
try {
// 空列表 = null允许全部
const newAllowed: AllowedModels = allowedModels.value.length > 0
? [...allowedModels.value]
const newAllowed: AllowedModels = selectedModels.value.length > 0
? [...selectedModels.value]
: null
await updateProviderKey(props.apiKey.id, { allowed_models: newAllowed })

View File

@@ -377,6 +377,7 @@ const props = defineProps<{
providerApiFormats: string[]
models: Model[]
editingGroup?: AliasGroup | null
preselectedModelId?: string | null
}>()
const emit = defineEmits<{
@@ -500,7 +501,7 @@ function initForm() {
}
} else {
formData.value = {
modelId: '',
modelId: props.preselectedModelId || '',
apiFormats: [],
aliases: []
}

View File

@@ -357,6 +357,7 @@
@edit-model="handleEditModel"
@delete-model="handleDeleteModel"
@batch-assign="handleBatchAssign"
@add-mapping="handleAddMapping"
/>
<!-- 模型名称映射 -->
@@ -951,6 +952,11 @@ function handleBatchAssign() {
batchAssignDialogOpen.value = true
}
// 处理添加映射(从 ModelsTab 触发)
function handleAddMapping(model: Model) {
modelAliasesTabRef.value?.openAddDialogForModel(model.id)
}
// 处理批量关联完成
async function handleBatchAssignChanged() {
await loadProvider()

View File

@@ -168,6 +168,7 @@
:provider-api-formats="providerApiFormats"
:models="models"
:editing-group="editingGroup"
:preselected-model-id="preselectedModelId"
@saved="onDialogSaved"
/>
@@ -219,6 +220,7 @@ const deleteConfirmOpen = ref(false)
const editingGroup = ref<AliasGroup | null>(null)
const deletingGroup = ref<AliasGroup | null>(null)
const testingMapping = ref<string | null>(null)
const preselectedModelId = ref<string | null>(null)
// 列表展开状态
const expandedAliasGroups = ref<Set<string>>(new Set())
@@ -311,12 +313,21 @@ function toggleAliasGroupExpand(groupKey: string) {
// 打开添加对话框
function openAddDialog() {
editingGroup.value = null
preselectedModelId.value = null
dialogOpen.value = true
}
// 打开添加对话框并预选模型(供外部调用)
function openAddDialogForModel(modelId: string) {
editingGroup.value = null
preselectedModelId.value = modelId
dialogOpen.value = true
}
// 编辑分组
function editGroup(group: AliasGroup) {
editingGroup.value = group
preselectedModelId.value = null
dialogOpen.value = true
}
@@ -416,8 +427,9 @@ onMounted(() => {
}
})
// 暴露给父组件,用于检测是否有弹窗打开
// 暴露给父组件
defineExpose({
dialogOpen: computed(() => dialogOpen.value || deleteConfirmOpen.value)
dialogOpen: computed(() => dialogOpen.value || deleteConfirmOpen.value),
openAddDialogForModel
})
</script>

View File

@@ -34,13 +34,10 @@
<table class="w-full text-sm table-fixed">
<thead class="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th class="text-left px-4 py-3 font-semibold w-[40%]">
<th class="text-left px-4 py-3 font-semibold w-[50%]">
模型
</th>
<th class="text-left px-4 py-3 font-semibold w-[15%]">
能力
</th>
<th class="text-left px-4 py-3 font-semibold w-[25%]">
<th class="text-left px-4 py-3 font-semibold w-[30%]">
价格 ($/M)
</th>
<th class="text-center px-4 py-3 font-semibold w-[20%]">
@@ -80,42 +77,6 @@
</div>
</div>
</td>
<td class="align-top px-4 py-3">
<div
v-if="hasAnyCapability(model)"
class="grid grid-cols-3 gap-1 w-fit"
>
<Zap
v-if="model.effective_supports_streaming ?? model.supports_streaming"
class="w-4 h-4 text-muted-foreground"
title="流式输出"
/>
<Image
v-if="model.effective_supports_image_generation ?? model.supports_image_generation"
class="w-4 h-4 text-muted-foreground"
title="图像生成"
/>
<Eye
v-if="model.effective_supports_vision ?? model.supports_vision"
class="w-4 h-4 text-muted-foreground"
title="视觉理解"
/>
<Wrench
v-if="model.effective_supports_function_calling ?? model.supports_function_calling"
class="w-4 h-4 text-muted-foreground"
title="工具调用"
/>
<Brain
v-if="model.effective_supports_extended_thinking ?? model.supports_extended_thinking"
class="w-4 h-4 text-muted-foreground"
title="深度思考"
/>
</div>
<span
v-else
class="text-xs text-muted-foreground"
></span>
</td>
<td class="align-top px-4 py-3 text-xs whitespace-nowrap">
<div
class="grid gap-1"
@@ -156,6 +117,15 @@
</td>
<td class="align-top px-4 py-3">
<div class="flex justify-center gap-1.5">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="添加映射"
@click="addMapping(model)"
>
<Link class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -209,7 +179,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image } from 'lucide-vue-next'
import { Box, Edit, Trash2, Layers, Power, Copy, Link } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import { useToast } from '@/composables/useToast'
@@ -225,6 +195,7 @@ const emit = defineEmits<{
'editModel': [model: Model]
'deleteModel': [model: Model]
'batchAssign': []
'addMapping': [model: Model]
}>()
const { error: showError, success: showSuccess } = useToast()
@@ -276,17 +247,6 @@ function formatPrice(price: number | null | undefined): string {
return price.toFixed(4)
}
// 检查模型是否有任何能力
function hasAnyCapability(model: Model): boolean {
return !!(
(model.effective_supports_vision ?? model.supports_vision) ||
(model.effective_supports_function_calling ?? model.supports_function_calling) ||
(model.effective_supports_streaming ?? model.supports_streaming) ||
(model.effective_supports_extended_thinking ?? model.supports_extended_thinking) ||
(model.effective_supports_image_generation ?? model.supports_image_generation)
)
}
// 检查是否有按 Token 计费
function hasTokenPricing(model: Model): boolean {
const inputPrice = model.effective_input_price
@@ -355,6 +315,11 @@ function deleteModel(model: Model) {
emit('deleteModel', model)
}
// 添加映射
function addMapping(model: Model) {
emit('addMapping', model)
}
// 打开批量关联对话框
function openBatchAssignDialog() {
emit('batchAssign')