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> </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 <Transition
enter-active-class="duration-300 ease-out" enter-active-class="duration-300 ease-out"
@@ -34,7 +34,7 @@
> >
<div <div
v-if="isOpen" 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 }" :style="{ zIndex: contentZIndex }"
:class="maxWidthClass" :class="maxWidthClass"
@click.stop @click.stop

View File

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

View File

@@ -4,7 +4,7 @@
:title="isEditMode ? '编辑密钥' : '添加密钥'" :title="isEditMode ? '编辑密钥' : '添加密钥'"
:description="isEditMode ? '修改 API 密钥配置' : '为提供商添加新的 API 密钥'" :description="isEditMode ? '修改 API 密钥配置' : '为提供商添加新的 API 密钥'"
:icon="isEditMode ? SquarePen : Key" :icon="isEditMode ? SquarePen : Key"
size="2xl" size="xl"
@update:model-value="handleDialogUpdate" @update:model-value="handleDialogUpdate"
> >
<form <form
@@ -80,7 +80,7 @@
<!-- API 格式选择 --> <!-- API 格式选择 -->
<div v-if="sortedApiFormats.length > 0"> <div v-if="sortedApiFormats.length > 0">
<Label class="mb-1.5 block">支持的 API 格式 *</Label> <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 <div
v-for="format in sortedApiFormats" v-for="format in sortedApiFormats"
:key="format" :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"> <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" /> <GripVertical class="w-4 h-4" />
</div> </div>
<span class="text-sm font-medium truncate">{{ key.name || '未命名密钥' }}</span> <div class="flex flex-col min-w-0">
<span class="text-xs font-mono text-muted-foreground"> <span class="text-sm font-medium truncate">{{ key.name || '未命名密钥' }}</span>
{{ key.api_key_masked }} <div class="flex items-center gap-1">
</span> <span class="text-[11px] font-mono text-muted-foreground">
<Button {{ key.api_key_masked }}
variant="ghost" </span>
size="icon" <Button
class="h-5 w-5 shrink-0" variant="ghost"
title="复制密钥" size="icon"
@click.stop="copyFullKey(key)" class="h-4 w-4 shrink-0"
> title="复制密钥"
<Copy class="w-3 h-3" /> @click.stop="copyFullKey(key)"
</Button> >
<Copy class="w-2.5 h-2.5" />
</Button>
</div>
</div>
<Badge <Badge
v-if="!key.is_active" v-if="!key.is_active"
variant="secondary" variant="secondary"

View File

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