feat(frontend): add ModelAliasDialog component for alias management

This commit is contained in:
fawney19
2025-12-15 14:30:31 +08:00
parent 7068aa9130
commit 743f23e640

View File

@@ -0,0 +1,333 @@
<template>
<Dialog
:model-value="open"
title="管理模型名称别名"
description="配置 Provider 对此模型使用的名称变体,系统会按优先级顺序选择"
:icon="Tag"
size="md"
@update:model-value="handleClose"
>
<div class="space-y-4">
<!-- 模型信息 -->
<div class="rounded-lg border bg-muted/30 p-3">
<p class="font-medium">
{{ model?.global_model_display_name || model?.provider_model_name }}
</p>
<p class="text-sm text-muted-foreground font-mono">
主名称: {{ model?.provider_model_name }}
</p>
</div>
<!-- 别名列表 -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label class="text-sm font-medium">名称别名</Label>
<Button
type="button"
variant="outline"
size="sm"
@click="addAlias"
>
<Plus class="w-4 h-4 mr-1" />
添加
</Button>
</div>
<!-- 提示信息 -->
<div
v-if="aliases.length > 0"
class="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground bg-muted/30 rounded-md"
>
<Info class="w-3.5 h-3.5 shrink-0" />
<span>拖拽调整顺序点击序号可编辑相同数字为同级负载均衡</span>
</div>
<div
v-if="aliases.length > 0"
class="space-y-2"
>
<div
v-for="(alias, index) in aliases"
:key="index"
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200"
:class="[
draggedIndex === index
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
: dragOverIndex === index
? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
]"
draggable="true"
@dragstart="handleDragStart(index, $event)"
@dragend="handleDragEnd"
@dragover.prevent="handleDragOver(index)"
@dragleave="handleDragLeave"
@drop="handleDrop(index)"
>
<!-- 拖拽手柄 -->
<div class="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted text-muted-foreground/40 group-hover:text-muted-foreground transition-colors shrink-0">
<GripVertical class="w-4 h-4" />
</div>
<!-- 可编辑优先级 -->
<div class="shrink-0">
<input
v-if="editingPriorityIndex === index"
type="number"
min="1"
:value="alias.priority"
class="w-8 h-6 rounded-md bg-background border border-primary text-xs font-medium text-center focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
autofocus
@blur="finishEditPriority(index, $event)"
@keydown.enter="($event.target as HTMLInputElement).blur()"
@keydown.escape="cancelEditPriority"
>
<div
v-else
class="w-6 h-6 rounded-md bg-muted/50 flex items-center justify-center text-xs font-medium text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary transition-colors"
title="点击编辑优先级,相同数字为同级(负载均衡)"
@click.stop="startEditPriority(index)"
>
{{ alias.priority }}
</div>
</div>
<!-- 别名输入框 -->
<Input
v-model="alias.name"
placeholder="别名,如 Claude-Sonnet-4.5"
class="flex-1"
/>
<!-- 删除按钮 -->
<Button
type="button"
variant="ghost"
size="icon"
class="shrink-0 text-destructive hover:text-destructive h-8 w-8"
@click="removeAlias(index)"
>
<X class="w-4 h-4" />
</Button>
</div>
</div>
<div
v-else
class="text-center py-6 text-muted-foreground border rounded-lg border-dashed"
>
<Tag class="w-8 h-8 mx-auto mb-2 opacity-50" />
<p class="text-sm">未配置别名</p>
<p class="text-xs mt-1">将只使用主模型名称</p>
</div>
</div>
</div>
<template #footer>
<Button
variant="outline"
@click="handleClose(false)"
>
取消
</Button>
<Button
:disabled="submitting"
@click="handleSubmit"
>
<Loader2
v-if="submitting"
class="w-4 h-4 mr-2 animate-spin"
/>
保存
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Tag, Plus, X, Loader2, GripVertical, Info } from 'lucide-vue-next'
import { Dialog, Button, Input, Label } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { updateModel } from '@/api/endpoints/models'
import type { Model, ProviderModelAlias } from '@/api/endpoints'
interface Props {
open: boolean
providerId: string
model: Model | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
'saved': []
}>()
const { error: showError, success: showSuccess } = useToast()
const submitting = ref(false)
const aliases = ref<ProviderModelAlias[]>([])
// 拖拽状态
const draggedIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
// 优先级编辑状态
const editingPriorityIndex = ref<number | null>(null)
// 监听 open 变化
watch(() => props.open, (newOpen) => {
if (newOpen && props.model) {
// 加载现有别名配置
if (props.model.provider_model_aliases && Array.isArray(props.model.provider_model_aliases)) {
aliases.value = JSON.parse(JSON.stringify(props.model.provider_model_aliases))
} else {
aliases.value = []
}
// 重置状态
editingPriorityIndex.value = null
draggedIndex.value = null
dragOverIndex.value = null
}
})
// 添加别名
function addAlias() {
// 新别名优先级为当前最大优先级 + 1或者默认为 1
const maxPriority = aliases.value.length > 0
? Math.max(...aliases.value.map(a => a.priority))
: 0
aliases.value.push({ name: '', priority: maxPriority + 1 })
}
// 移除别名
function removeAlias(index: number) {
aliases.value.splice(index, 1)
}
// ===== 拖拽排序 =====
function handleDragStart(index: number, event: DragEvent) {
draggedIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
function handleDragEnd() {
draggedIndex.value = null
dragOverIndex.value = null
}
function handleDragOver(index: number) {
if (draggedIndex.value !== null && draggedIndex.value !== index) {
dragOverIndex.value = index
}
}
function handleDragLeave() {
dragOverIndex.value = null
}
function handleDrop(targetIndex: number) {
const dragIndex = draggedIndex.value
if (dragIndex === null || dragIndex === targetIndex) {
dragOverIndex.value = null
return
}
const items = [...aliases.value]
const draggedItem = items[dragIndex]
// 记录每个别名的原始优先级(在修改前)
const originalPriorityMap = new Map<number, number>()
items.forEach((alias, idx) => {
originalPriorityMap.set(idx, alias.priority)
})
// 重排数组
items.splice(dragIndex, 1)
items.splice(targetIndex, 0, draggedItem)
// 按新顺序为每个组分配新的优先级
// 同组的别名保持相同的优先级(被拖动的别名单独成组)
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
let currentPriority = 1
// 找到被拖动项在原数组中的索引对应的原始优先级
const draggedOriginalPriority = originalPriorityMap.get(dragIndex)!
items.forEach((alias, newIdx) => {
// 找到这个别名在原数组中的索引
const originalIdx = aliases.value.findIndex(a => a === alias)
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
if (alias === draggedItem) {
// 被拖动的别名是独立的新组,获得当前优先级
alias.priority = currentPriority
currentPriority++
} else {
if (groupNewPriority.has(originalPriority)) {
// 这个组已经分配过优先级,使用相同的值
alias.priority = groupNewPriority.get(originalPriority)!
} else {
// 这个组第一次出现,分配新优先级
groupNewPriority.set(originalPriority, currentPriority)
alias.priority = currentPriority
currentPriority++
}
}
})
aliases.value = items
draggedIndex.value = null
dragOverIndex.value = null
}
// ===== 优先级编辑 =====
function startEditPriority(index: number) {
editingPriorityIndex.value = index
}
function finishEditPriority(index: number, event: FocusEvent) {
const input = event.target as HTMLInputElement
const newPriority = parseInt(input.value) || 1
aliases.value[index].priority = Math.max(1, newPriority)
editingPriorityIndex.value = null
}
function cancelEditPriority() {
editingPriorityIndex.value = null
}
// 关闭对话框
function handleClose(value: boolean) {
if (!submitting.value) {
emit('update:open', value)
}
}
// 提交保存
async function handleSubmit() {
if (submitting.value || !props.model) return
submitting.value = true
try {
// 过滤掉空的别名
const validAliases = aliases.value.filter(a => a.name.trim())
await updateModel(props.providerId, props.model.id, {
provider_model_aliases: validAliases.length > 0 ? validAliases : null
})
showSuccess('别名配置已保存')
emit('update:open', false)
emit('saved')
} catch (err: any) {
showError(err.response?.data?.detail || '保存失败', '错误')
} finally {
submitting.value = false
}
}
</script>