mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 00:02:28 +08:00
338 lines
10 KiB
Vue
338 lines
10 KiB
Vue
<template>
|
||
<Dialog
|
||
:model-value="open"
|
||
title="管理模型名称映射"
|
||
description="配置 Provider 对此模型使用的名称变体,系统会按优先级顺序选择"
|
||
:icon="Tag"
|
||
size="lg"
|
||
@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_mappings && Array.isArray(props.model.provider_model_mappings)) {
|
||
aliases.value = JSON.parse(JSON.stringify(props.model.provider_model_mappings))
|
||
} 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_mappings: 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>
|