refactor(ui): 重构批量模型管理对话框为单列勾选模式

- BatchAssignModelsDialog: 从左右双栏改为单列勾选,支持统一保存
- KeyAllowedModelsEditDialog: 添加分组折叠功能,优化布局
- Dialog: 调整移动端内边距适配
- ProviderDetailDrawer: 密钥列表改为两行显示
- KeyFormDialog: 缩小对话框尺寸
- ModelsTab: 调整表格列宽
This commit is contained in:
fawney19
2026-01-11 19:33:16 +08:00
parent 76ed136228
commit 94abab3260
6 changed files with 607 additions and 595 deletions

View File

@@ -22,7 +22,7 @@
/>
</Transition>
<div class="relative flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0 pointer-events-none">
<div class="relative flex min-h-full items-end justify-center px-3 py-4 text-center sm:items-center sm:p-0 pointer-events-none">
<!-- 对话框内容 -->
<Transition
enter-active-class="duration-300 ease-out"
@@ -34,7 +34,7 @@
>
<div
v-if="isOpen"
class="relative transform rounded-lg bg-background text-left shadow-2xl transition-all sm:my-8 sm:w-full border border-border pointer-events-auto"
class="relative transform rounded-lg bg-background text-left shadow-2xl transition-all w-full sm:my-8 sm:w-full border border-border pointer-events-auto"
:style="{ zIndex: contentZIndex }"
:class="maxWidthClass"
@click.stop

View File

@@ -1,10 +1,10 @@
<template>
<Dialog
:model-value="isOpen"
title="模型权限"
:description="`管理密钥 ${props.apiKey?.name || ''} 可访问的模型`"
:title="props.apiKey?.name ? `模型权限 - ${props.apiKey.name}` : '模型权限'"
description="选中的模型将被允许访问,不选择则允许全部"
:icon="Shield"
size="lg"
size="2xl"
@update:model-value="handleDialogUpdate"
>
<template #default>
@@ -20,12 +20,6 @@
</p>
</div>
<!-- 已选数量提示 -->
<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">
<!-- 搜索 + 操作栏 -->
@@ -38,6 +32,19 @@
class="pl-8 h-8 text-sm"
/>
</div>
<!-- 已选数量徽章 -->
<span
v-if="selectedModels.length === 0"
class="h-6 px-2 text-xs rounded flex items-center bg-muted text-muted-foreground shrink-0"
>
全部模型
</span>
<span
v-else
class="h-6 px-2 text-xs rounded flex items-center bg-primary/10 text-primary shrink-0"
>
已选 {{ selectedModels.length }}
</span>
<button
v-if="upstreamModelsLoaded"
type="button"
@@ -56,7 +63,7 @@
variant="outline"
size="sm"
class="h-8"
title="从提供获取模型"
title="从提供<EFBFBD><EFBFBD><EFBFBD>获取模型"
@click="fetchUpstreamModels()"
>
<Zap class="w-4 h-4" />
@@ -97,10 +104,23 @@
<!-- 自定义模型手动添加的始终显示全部搜索命中的排前面 -->
<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
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 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 class="space-y-1 p-2">
<div
v-show="!collapsedGroups.has('custom')"
class="space-y-1 p-2"
>
<div
v-for="model in sortedCustomModels"
:key="model"
@@ -113,24 +133,37 @@
>
<Check v-if="selectedModels.includes(model)" class="w-3 h-3 text-primary-foreground" />
</div>
<span class="text-xs font-mono truncate">{{ model }}</span>
<span class="text-sm 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>
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 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">({{ filteredGlobalModels.length }})</span>
</div>
<button
type="button"
class="text-xs text-primary hover:underline"
@click="toggleAllGlobalModels"
@click.stop="toggleAllGlobalModels"
>
{{ isAllGlobalModelsSelected ? '取消全选' : '全选' }}
</button>
</div>
<div class="space-y-1 p-2">
<div
v-show="!collapsedGroups.has('global')"
class="space-y-1 p-2"
>
<div
v-for="model in filteredGlobalModels"
:key="model.name"
@@ -143,26 +176,42 @@
>
<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 class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ model.display_name }}</p>
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.name }}</p>
</div>
</div>
</div>
</div>
<!-- 上游模型组 -->
<div v-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>
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
@click="toggleGroupCollapse(group.api_format)"
>
<div class="flex items-center gap-2">
<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>
</div>
<button
type="button"
class="text-xs text-primary hover:underline"
@click="toggleAllUpstreamGroup(group.api_format)"
@click.stop="toggleAllUpstreamGroup(group.api_format)"
>
{{ isUpstreamGroupAllSelected(group.api_format) ? '取消全选' : '全选' }}
</button>
</div>
<div class="space-y-1 p-2">
<div
v-show="!collapsedGroups.has(group.api_format)"
class="space-y-1 p-2"
>
<div
v-for="model in group.models"
:key="model.id"
@@ -175,7 +224,7 @@
>
<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>
<span class="text-sm font-mono truncate">{{ model.id }}</span>
</div>
</div>
</div>
@@ -201,10 +250,10 @@
{{ hasChanges ? '有未保存的更改' : '' }}
</p>
<div class="flex items-center gap-2">
<Button variant="outline" @click="handleCancel">取消</Button>
<Button :disabled="saving || !hasChanges" @click="handleSave">
{{ saving ? '保存中...' : '保存' }}
</Button>
<Button variant="outline" @click="handleCancel">取消</Button>
</div>
</div>
</template>
@@ -220,10 +269,12 @@ import {
Loader2,
Zap,
Plus,
Check
Check,
ChevronDown
} from 'lucide-vue-next'
import { Dialog, Button, Input } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { parseApiError, parseUpstreamModelError } from '@/utils/errorParser'
import {
updateProviderKey,
@@ -252,6 +303,7 @@ const emit = defineEmits<{
}>()
const { success, error: showError } = useToast()
const { confirmWarning } = useConfirm()
const isOpen = computed(() => props.open)
const saving = ref(false)
@@ -280,6 +332,9 @@ const allCustomModels = ref<string[]>([])
// 是否为字典模式(按 API 格式区分)
const isDictMode = ref(false)
// 折叠状态
const collapsedGroups = ref<Set<string>>(new Set())
// 是否有更改
const hasChanges = computed(() => {
if (selectedModels.value.length !== initialSelectedModels.value.length) return true
@@ -444,6 +499,16 @@ function toggleAllUpstreamGroup(apiFormat: string) {
}
}
// 切换折叠状态
function toggleGroupCollapse(group: string) {
if (collapsedGroups.value.has(group)) {
collapsedGroups.value.delete(group)
} else {
collapsedGroups.value.add(group)
}
collapsedGroups.value = new Set(collapsedGroups.value)
}
// 加载全局模型
async function loadGlobalModels() {
loadingGlobalModels.value = true
@@ -537,11 +602,19 @@ onUnmounted(() => {
loadingCancelled = true
})
function handleDialogUpdate(value: boolean) {
async function handleDialogUpdate(value: boolean) {
if (!value && hasChanges.value) {
const confirmed = await confirmWarning('有未保存的更改,确定要关闭吗?', '放弃更改')
if (!confirmed) return
}
if (!value) emit('close')
}
function handleCancel() {
async function handleCancel() {
if (hasChanges.value) {
const confirmed = await confirmWarning('有未保存的更改,确定要关闭吗?', '放弃更改')
if (!confirmed) return
}
emit('close')
}

View File

@@ -4,7 +4,7 @@
:title="isEditMode ? '编辑密钥' : '添加密钥'"
:description="isEditMode ? '修改 API 密钥配置' : '为提供商添加新的 API 密钥'"
:icon="isEditMode ? SquarePen : Key"
size="2xl"
size="xl"
@update:model-value="handleDialogUpdate"
>
<form
@@ -80,7 +80,7 @@
<!-- API 格式选择 -->
<div v-if="sortedApiFormats.length > 0">
<Label class="mb-1.5 block">支持的 API 格式 *</Label>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<div class="grid grid-cols-2 gap-2">
<div
v-for="format in sortedApiFormats"
:key="format"

View File

@@ -189,19 +189,23 @@
<div class="cursor-grab active:cursor-grabbing text-muted-foreground/30 group-hover/item:text-muted-foreground transition-colors shrink-0">
<GripVertical class="w-4 h-4" />
</div>
<span class="text-sm font-medium truncate">{{ key.name || '未命名密钥' }}</span>
<span class="text-xs font-mono text-muted-foreground">
{{ key.api_key_masked }}
</span>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 shrink-0"
title="复制密钥"
@click.stop="copyFullKey(key)"
>
<Copy class="w-3 h-3" />
</Button>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium truncate">{{ key.name || '未命名密钥' }}</span>
<div class="flex items-center gap-1">
<span class="text-[11px] font-mono text-muted-foreground">
{{ key.api_key_masked }}
</span>
<Button
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
title="复制密钥"
@click.stop="copyFullKey(key)"
>
<Copy class="w-2.5 h-2.5" />
</Button>
</div>
</div>
<Badge
v-if="!key.is_active"
variant="secondary"

View File

@@ -34,13 +34,13 @@
<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-[50%]">
<th class="text-left px-4 py-3 font-semibold w-[45%]">
模型
</th>
<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%]">
<th class="text-center px-4 py-3 font-semibold w-[25%]">
操作
</th>
</tr>
@@ -109,7 +109,7 @@
${{ formatPrice(model.effective_price_per_request ?? model.price_per_request) }}/
</span>
</template>
<!-- 无计配置 -->
<!-- 无计<EFBFBD><EFBFBD>配置 -->
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model)">
<span class="text-muted-foreground"></span>
</template>
@@ -305,7 +305,7 @@ function getStatusTitle(model: Model): string {
return '活跃但不可用'
}
// 辑模型
// <EFBFBD><EFBFBD>辑模型
function editModel(model: Model) {
emit('editModel', model)
}