mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
refactor: 简化优先级拖拽交换逻辑并优化用户配额处理
- 优先级拖拽改为直接交换优先级值,而非重新编号 - 添加 Provider 优先级的点击编辑功能 - 创建用户时由后端统一处理默认配额逻辑 - 移除前端 getDefaultQuota API 调用和后端对应端点 Close #97
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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>[] = []
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -198,20 +198,6 @@ async def delete_user_api_key(
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/defaults/quota")
|
||||
async def get_default_quota(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取默认用户配额
|
||||
|
||||
获取系统配置的默认用户配额值,用于创建用户时的默认值。
|
||||
|
||||
**返回字段**:
|
||||
- `default_quota_usd`: 默认配额(USD)
|
||||
"""
|
||||
adapter = AdminGetDefaultQuotaAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ============== 管理员适配器实现 ==============
|
||||
|
||||
|
||||
@@ -233,6 +219,14 @@ class AdminCreateUserAdapter(AdminApiAdapter):
|
||||
except (KeyError, AttributeError):
|
||||
raise InvalidRequestException("角色参数不合法")
|
||||
|
||||
# 确定配额:unlimited 优先,其次是指定值,最后是系统默认
|
||||
if request.unlimited:
|
||||
quota_usd = None # None 表示无限制
|
||||
elif request.quota_usd is not None:
|
||||
quota_usd = request.quota_usd
|
||||
else:
|
||||
quota_usd = SystemConfigService.get_config(db, "default_user_quota_usd", default=10.0)
|
||||
|
||||
try:
|
||||
user = UserService.create_user(
|
||||
db=db,
|
||||
@@ -240,7 +234,7 @@ class AdminCreateUserAdapter(AdminApiAdapter):
|
||||
username=request.username,
|
||||
password=request.password,
|
||||
role=role,
|
||||
quota_usd=request.quota_usd,
|
||||
quota_usd=quota_usd,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise InvalidRequestException(str(exc))
|
||||
@@ -601,15 +595,3 @@ class AdminDeleteUserKeyAdapter(AdminApiAdapter):
|
||||
)
|
||||
|
||||
return {"message": "API Key已删除"}
|
||||
|
||||
|
||||
class AdminGetDefaultQuotaAdapter(AdminApiAdapter):
|
||||
"""获取系统默认用户配额"""
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
db = context.db
|
||||
default_quota = SystemConfigService.get_config(db, "default_user_quota_usd", default=10.0)
|
||||
|
||||
return {
|
||||
"default_quota_usd": float(default_quota),
|
||||
}
|
||||
|
||||
@@ -233,12 +233,13 @@ class CreateUserRequest(BaseModel):
|
||||
password: str = Field(..., min_length=6, max_length=128, description="密码")
|
||||
email: str = Field(..., min_length=3, max_length=255, description="邮箱地址")
|
||||
role: Optional[UserRole] = Field(UserRole.USER, description="用户角色")
|
||||
quota_usd: Optional[float] = Field(default=10.0, description="USD配额,null表示无限制")
|
||||
quota_usd: Optional[float] = Field(default=None, description="USD配额,null表示使用系统默认配额")
|
||||
unlimited: bool = Field(default=False, description="是否无限配额")
|
||||
|
||||
@field_validator("quota_usd", mode="before")
|
||||
@classmethod
|
||||
def validate_quota_usd(cls, v):
|
||||
"""验证配额值,允许null表示无限制"""
|
||||
"""验证配额值,null表示使用系统默认配额"""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, (int, float)) and v >= 0 and v <= 10000:
|
||||
|
||||
Reference in New Issue
Block a user