refactor: 简化优先级拖拽交换逻辑并优化用户配额处理

- 优先级拖拽改为直接交换优先级值,而非重新编号
- 添加 Provider 优先级的点击编辑功能
- 创建用户时由后端统一处理默认配额逻辑
- 移除前端 getDefaultQuota API 调用和后端对应端点

Close #97
This commit is contained in:
fawney19
2026-01-15 18:59:01 +08:00
parent dd387735d4
commit 81b7b74fd4
7 changed files with 124 additions and 119 deletions

View File

@@ -22,6 +22,7 @@ export interface CreateUserRequest {
email: string
role?: 'admin' | 'user'
quota_usd?: number | null
unlimited?: boolean
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
@@ -98,11 +99,6 @@ export const usersApi = {
await apiClient.patch(`/api/admin/users/${userId}/quota`)
},
async getDefaultQuota(): Promise<{ default_quota_usd: number }> {
const response = await apiClient.get<{ default_quota_usd: number }>('/api/admin/users/defaults/quota')
return response.data
},
// 管理员统计
async getUsageStats(): Promise<any> {
const response = await apiClient.get('/api/admin/usage/stats')

View File

@@ -48,7 +48,7 @@
<!-- 提示信息 -->
<div 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>
<span>拖拽调整顺序点击序号可编辑相同数字为同级负载均衡</span>
</div>
<!-- 空状态 -->
@@ -88,9 +88,27 @@
<GripVertical class="w-4 h-4" />
</div>
<!-- 序号 -->
<div class="w-6 h-6 rounded-md bg-muted/50 flex items-center justify-center text-xs font-medium text-muted-foreground shrink-0">
{{ index + 1 }}
<!-- 可编辑序号 -->
<div class="shrink-0">
<input
v-if="editingProviderPriority === provider.id"
type="number"
min="1"
:value="provider.provider_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="finishEditProviderPriority(provider, $event)"
@keydown.enter="($event.target as HTMLInputElement).blur()"
@keydown.escape="cancelEditProviderPriority()"
>
<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="startEditProviderPriority(provider)"
>
{{ provider.provider_priority }}
</div>
</div>
<!-- 提供商信息 -->
@@ -457,6 +475,9 @@ const saving = ref(false)
// Key 优先级编辑状态
const editingKeyPriority = ref<Record<string, string | null>>({}) // format -> keyId
// Provider 优先级编辑状态
const editingProviderPriority = ref<string | null>(null) // providerId
// 调度模式状态
const schedulingMode = ref<'fixed_order' | 'load_balance' | 'cache_affinity'>('cache_affinity')
@@ -552,6 +573,35 @@ function finishEditKeyPriority(format: string, key: KeyWithMeta, event: FocusEve
editingKeyPriority.value[format] = null
}
// Provider 优先级编辑
function startEditProviderPriority(provider: ProviderWithEndpointsSummary) {
editingProviderPriority.value = provider.id
}
function cancelEditProviderPriority() {
editingProviderPriority.value = null
}
function finishEditProviderPriority(provider: ProviderWithEndpointsSummary, event: FocusEvent) {
const input = event.target as HTMLInputElement
const newPriority = parseInt(input.value, 10)
if (!isNaN(newPriority) && newPriority >= 1) {
// 更新该 provider 的优先级
const idx = sortedProviders.value.findIndex(p => p.id === provider.id)
if (idx !== -1) {
sortedProviders.value[idx] = {
...sortedProviders.value[idx],
provider_priority: newPriority
}
}
// 按 provider_priority 重新排序
sortedProviders.value = [...sortedProviders.value].sort((a, b) => a.provider_priority - b.provider_priority)
}
editingProviderPriority.value = null
}
// Provider 拖拽处理
function handleProviderDragStart(index: number, event: DragEvent) {
draggedProvider.value = index
@@ -576,21 +626,32 @@ function handleProviderDragLeave() {
function handleProviderDrop(dropIndex: number) {
if (draggedProvider.value === null || draggedProvider.value === dropIndex) {
draggedProvider.value = null
dragOverProvider.value = null
return
}
const providers = [...sortedProviders.value]
const providers = sortedProviders.value
const draggedItem = providers[draggedProvider.value]
const targetItem = providers[dropIndex]
const draggedPriority = draggedItem.provider_priority
const targetPriority = targetItem.provider_priority
providers.splice(draggedProvider.value, 1)
providers.splice(dropIndex, 0, draggedItem)
// 如果是同组内拖拽(同优先级),忽略操作
if (draggedPriority === targetPriority) {
draggedProvider.value = null
dragOverProvider.value = null
return
}
sortedProviders.value = providers.map((provider, index) => ({
...provider,
provider_priority: index + 1
}))
// 直接交换优先级
draggedItem.provider_priority = targetPriority
targetItem.provider_priority = draggedPriority
// 重新排序
sortedProviders.value = [...providers].sort((a, b) => a.provider_priority - b.provider_priority)
draggedProvider.value = null
dragOverProvider.value = null
}
// Key 拖拽处理
@@ -619,50 +680,31 @@ function handleKeyDrop(format: string, dropIndex: number) {
const dragIndex = draggedKey.value[format]
if (dragIndex === null || dragIndex === dropIndex) {
draggedKey.value[format] = null
dragOverKey.value[format] = null
return
}
const keys = [...keysByFormat.value[format]]
const keys = keysByFormat.value[format]
const draggedItem = keys[dragIndex]
const targetItem = keys[dropIndex]
const draggedPriority = draggedItem.priority
const targetPriority = targetItem.priority
// 记录每个 key 的原始优先级(在修改前)
const originalPriorityMap = new Map<string, number>()
for (const key of keys) {
originalPriorityMap.set(key.id, key.priority)
// 如果是同组内拖拽(同优先级),忽略操作
if (draggedPriority === targetPriority) {
draggedKey.value[format] = null
dragOverKey.value[format] = null
return
}
// 重排数组
keys.splice(dragIndex, 1)
keys.splice(dropIndex, 0, draggedItem)
// 直接交换优先级
draggedItem.priority = targetPriority
targetItem.priority = draggedPriority
// 按新顺序为每个组分配新的优先级
// 同组的 Key 保持相同的优先级
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
let currentPriority = 1
for (const key of keys) {
if (key.id === draggedItem.id) {
// 被拖动的 Key 是独立的新组,获得当前优先级
key.priority = currentPriority
currentPriority++
} else {
// 使用记录的原始优先级,而不是可能已被修改的值
const originalPriority = originalPriorityMap.get(key.id)!
if (groupNewPriority.has(originalPriority)) {
// 这个组已经分配过优先级,使用相同的值
key.priority = groupNewPriority.get(originalPriority)!
} else {
// 这个组第一次出现,分配新优先级
groupNewPriority.set(originalPriority, currentPriority)
key.priority = currentPriority
currentPriority++
}
}
}
keysByFormat.value[format] = keys
// 重新排序
keysByFormat.value[format] = [...keys].sort((a, b) => a.priority - b.priority)
draggedKey.value[format] = null
dragOverKey.value[format] = null
}
// 保存
@@ -686,8 +728,8 @@ async function save() {
)
])
const providerUpdates = sortedProviders.value.map((provider, index) =>
updateProvider(provider.id, { provider_priority: index + 1 })
const providerUpdates = sortedProviders.value.map((provider) =>
updateProvider(provider.id, { provider_priority: provider.provider_priority })
)
const keyUpdates: Promise<any>[] = []

View File

@@ -1095,27 +1095,14 @@ async function handleKeyDrop(event: DragEvent, targetIndex: number) {
return
}
// 检查目标 key 是否属于一个"组"(除了被拖拽的 key还有其他 key 与目标同优先级)
// 组的定义2 个及以上同优先级的 key
const keysAtTargetPriority = keys.filter(k =>
k.id !== draggedKey.id && (k.internal_priority ?? 0) === targetPriority
)
// 如果有 2 个及以上 key 在目标优先级(不含被拖拽的),说明目标在组内
const targetIsInGroup = keysAtTargetPriority.length >= 2
handleKeyDragEnd()
try {
if (targetIsInGroup) {
// 目标在组内,被拖拽的 key 加入该组
await updateProviderKey(draggedKey.id, { internal_priority: targetPriority })
} else {
// 目标是单独的(或只有目标自己),交换优先级
await Promise.all([
updateProviderKey(draggedKey.id, { internal_priority: targetPriority }),
updateProviderKey(targetKey.id, { internal_priority: draggedPriority })
])
}
// 直接交换优先级
await Promise.all([
updateProviderKey(draggedKey.id, { internal_priority: targetPriority }),
updateProviderKey(targetKey.id, { internal_priority: draggedPriority })
])
showSuccess('优先级已更新')
await loadEndpoints()
emit('refresh')

View File

@@ -143,7 +143,8 @@
step="0.01"
min="0"
max="10000"
placeholder="10"
:placeholder="isEditMode ? '10' : '使用系统默认'"
:disabled="form.unlimited"
:class="form.unlimited ? 'flex-1 h-10 opacity-50' : 'flex-1 h-10'"
/>
<div class="flex items-center justify-center gap-2 border rounded-lg px-3 py-2 bg-muted/50 w-24">
@@ -363,7 +364,6 @@ import { ModelMultiSelect } from '@/components/common'
import { getProvidersSummary } from '@/api/endpoints/providers'
import { getGlobalModels } from '@/api/global-models'
import { adminApi } from '@/api/admin'
import { usersApi } from '@/api/users'
import { log } from '@/utils/logger'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
@@ -403,7 +403,6 @@ const endpointDropdownOpen = ref(false)
const providers = ref<ProviderWithEndpointsSummary[]>([])
const globalModels = ref<GlobalModelResponse[]>([])
const apiFormats = ref<Array<{ value: string; label: string }>>([])
const defaultQuota = ref<number>(10)
// 表单数据
const form = ref({
@@ -411,7 +410,7 @@ const form = ref({
password: '',
confirmPassword: '',
email: '',
quota: 10,
quota: null as number | null,
role: 'user' as 'admin' | 'user',
unlimited: false,
is_active: true,
@@ -432,7 +431,7 @@ function resetForm() {
password: '',
confirmPassword: '',
email: '',
quota: defaultQuota.value,
quota: null,
role: 'user',
unlimited: false,
is_active: true,
@@ -481,22 +480,18 @@ const isFormValid = computed(() => {
})
// 加载访问控制选项
async function loadAccessControlOptions(): Promise<boolean> {
async function loadAccessControlOptions(): Promise<void> {
try {
const [providersData, modelsData, formatsData, quotaData] = await Promise.all([
const [providersData, modelsData, formatsData] = await Promise.all([
getProvidersSummary(),
getGlobalModels({ limit: 1000, is_active: true }),
adminApi.getApiFormats(),
usersApi.getDefaultQuota()
adminApi.getApiFormats()
])
providers.value = providersData
globalModels.value = modelsData.models || []
apiFormats.value = formatsData.formats || []
defaultQuota.value = quotaData.default_quota_usd
return true
} catch (err) {
log.error('加载访问限制选项失败:', err)
return false
}
}
@@ -520,7 +515,7 @@ async function handleSubmit() {
saving.value = true
try {
const data: UserFormData & { password?: string } = {
const data: UserFormData & { password?: string; unlimited?: boolean } = {
username: form.value.username,
email: form.value.email.trim(),
quota_usd: form.value.unlimited ? null : form.value.quota,
@@ -530,6 +525,11 @@ async function handleSubmit() {
allowed_models: form.value.allowed_models.length > 0 ? form.value.allowed_models : null
}
// 创建模式下传递 unlimited 字段
if (!isEditMode.value) {
data.unlimited = form.value.unlimited
}
if (isEditMode.value && props.user?.id) {
data.id = props.user.id
}
@@ -557,13 +557,9 @@ function setSaving(value: boolean) {
}
// 监听打开状态,加载选项数据
watch(isOpen, async (val) => {
watch(isOpen, (val) => {
if (val) {
const success = await loadAccessControlOptions()
// 创建模式下,仅在加载成功时更新表单配额
if (!isEditMode.value && success) {
form.value.quota = defaultQuota.value
}
loadAccessControlOptions()
}
})

View File

@@ -939,6 +939,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
password: data.password!,
email: data.email || undefined,
quota_usd: data.quota_usd,
unlimited: (data as any).unlimited,
role: data.role,
allowed_providers: data.allowed_providers,
allowed_api_formats: data.allowed_api_formats,