mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 00:02:28 +08:00
feat(frontend): add ModelAliasDialog component for alias management
This commit is contained in:
333
frontend/src/features/providers/components/ModelAliasDialog.vue
Normal file
333
frontend/src/features/providers/components/ModelAliasDialog.vue
Normal 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>
|
||||
Reference in New Issue
Block a user