feat: 添加 API Key 锁定功能和请求头规则系统

1. API Key 锁定功能
- 管理员可锁定/解锁用户的 API Key
- 锁定后用户无法使用、修改或删除该 Key
- 前端显示锁定状态并禁用相关操作

2. 请求头规则系统
- 将 endpoint.headers 升级为 header_rules
- 支持 set(设置)、drop(删除)、rename(重命名)操作
- 前端提供可视化规则编辑界面
- 包含数据迁移脚本

Close #37 Close #86 Close #88
This commit is contained in:
fawney19
2026-01-16 01:18:54 +08:00
parent ea45918561
commit 8e11f3864a
22 changed files with 852 additions and 56 deletions

View File

@@ -252,12 +252,21 @@
</div>
</TableCell>
<TableCell class="py-4 text-center">
<Badge
:variant="apiKey.is_active ? 'success' : 'destructive'"
class="font-medium"
>
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
<div class="flex flex-col items-center gap-1">
<Badge
:variant="apiKey.is_active ? 'success' : 'destructive'"
class="font-medium"
>
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="apiKey.is_locked"
variant="secondary"
class="text-xs"
>
已锁定
</Badge>
</div>
</TableCell>
<TableCell class="py-4">
<div class="flex justify-center gap-1">
@@ -279,6 +288,22 @@
>
<DollarSign class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="apiKey.is_locked ? '解锁' : '锁定'"
@click="toggleLockApiKey(apiKey)"
>
<Lock
v-if="apiKey.is_locked"
class="h-4 w-4"
/>
<LockOpen
v-else
class="h-4 w-4"
/>
</Button>
<Button
variant="ghost"
size="icon"
@@ -343,12 +368,21 @@
{{ apiKey.name || '未命名 Key' }}
</div>
</div>
<Badge
:variant="apiKey.is_active ? 'success' : 'destructive'"
class="text-xs flex-shrink-0"
>
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
<div class="flex flex-col items-end gap-1">
<Badge
:variant="apiKey.is_active ? 'success' : 'destructive'"
class="text-xs flex-shrink-0"
>
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="apiKey.is_locked"
variant="secondary"
class="text-xs"
>
已锁定
</Badge>
</div>
</div>
<div class="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
@@ -455,6 +489,21 @@
<DollarSign class="h-3.5 w-3.5 mr-1.5" />
调整
</Button>
<Button
variant="outline"
size="sm"
@click="toggleLockApiKey(apiKey)"
>
<Lock
v-if="apiKey.is_locked"
class="h-3.5 w-3.5 mr-1.5"
/>
<LockOpen
v-else
class="h-3.5 w-3.5 mr-1.5"
/>
{{ apiKey.is_locked ? '解锁' : '锁定' }}
</Button>
<Button
variant="outline"
size="sm"
@@ -466,7 +515,7 @@
<Button
variant="outline"
size="sm"
class="text-rose-600"
class="text-rose-600 col-span-2"
@click="deleteApiKey(apiKey)"
>
<Trash2 class="h-3.5 w-3.5 mr-1.5" />
@@ -685,7 +734,9 @@ import {
Copy,
CheckCircle,
SquarePen,
Search
Search,
Lock,
LockOpen
} from 'lucide-vue-next'
import { StandaloneKeyFormDialog, type StandaloneKeyFormData } from '@/features/api-keys'
@@ -830,6 +881,20 @@ async function toggleApiKey(apiKey: AdminApiKey) {
}
}
async function toggleLockApiKey(apiKey: AdminApiKey) {
try {
const response = await adminApi.toggleLockApiKey(apiKey.id)
const index = apiKeys.value.findIndex(k => k.id === apiKey.id)
if (index !== -1) {
apiKeys.value[index].is_locked = response.is_locked
}
success(response.message)
} catch (err: any) {
log.error('切换密钥锁定状态失败:', err)
error(err.response?.data?.detail || '操作失败')
}
}
async function deleteApiKey(apiKey: AdminApiKey) {
const confirmed = await confirmDanger(
`确定要删除这个独立余额 Key 吗?\n\n${apiKey.name || apiKey.key_display || 'sk-****'}\n\n此操作无法撤销。`,

View File

@@ -556,6 +556,13 @@
>
{{ apiKey.is_active ? '活跃' : '已禁用' }}
</Badge>
<Badge
v-if="apiKey.is_locked"
variant="secondary"
class="text-xs"
>
已锁定
</Badge>
<Badge
v-if="apiKey.is_standalone"
variant="default"
@@ -588,6 +595,22 @@
${{ (apiKey.total_cost_usd || 0).toFixed(4) }}
</div>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="apiKey.is_locked ? '解锁' : '锁定'"
@click="toggleLockApiKey(apiKey)"
>
<Lock
v-if="apiKey.is_locked"
class="h-4 w-4"
/>
<LockOpen
v-else
class="h-4 w-4"
/>
</Button>
<Button
variant="ghost"
size="icon"
@@ -740,7 +763,9 @@ import {
Trash2,
Copy,
Search,
CheckCircle
CheckCircle,
Lock,
LockOpen
} from 'lucide-vue-next'
// 功能组件
@@ -1024,6 +1049,21 @@ async function deleteApiKey(apiKey: any) {
}
}
async function toggleLockApiKey(apiKey: any) {
try {
const response = await adminApi.toggleLockApiKey(apiKey.id)
// 更新本地状态
const index = userApiKeys.value.findIndex(k => k.id === apiKey.id)
if (index !== -1) {
userApiKeys.value[index].is_locked = response.is_locked
}
success(response.message)
} catch (err: any) {
log.error('切换密钥锁定状态失败:', err)
error(err.response?.data?.error?.message || err.response?.data?.detail || '操作失败', '锁定/解锁失败')
}
}
async function copyFullKey(apiKey: any) {
try {
// 调用后端 API 获取完整密钥

View File

@@ -129,12 +129,14 @@
:key="cap.name"
class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all"
:class="[
apiKey.is_locked ? 'opacity-50 cursor-not-allowed' : '',
isCapabilityEnabled(apiKey, cap.name)
? 'bg-primary text-primary-foreground'
: 'bg-transparent text-muted-foreground border border-dashed border-muted-foreground/50 hover:border-primary/50 hover:text-foreground'
]"
:title="getCapabilityTooltip(cap, isCapabilityEnabled(apiKey, cap.name))"
@click.stop="toggleCapability(apiKey, cap.name)"
:title="apiKey.is_locked ? '已锁定' : getCapabilityTooltip(cap, isCapabilityEnabled(apiKey, cap.name))"
:disabled="apiKey.is_locked"
@click.stop="!apiKey.is_locked && toggleCapability(apiKey, cap.name)"
>
<Check
v-if="isCapabilityEnabled(apiKey, cap.name)"
@@ -191,12 +193,21 @@
<!-- 状态 -->
<TableCell class="py-4 text-center">
<Badge
:variant="apiKey.is_active ? 'success' : 'secondary'"
class="font-medium px-3 py-1"
>
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
<div class="flex flex-col items-center gap-1">
<Badge
:variant="apiKey.is_active ? 'success' : 'secondary'"
class="font-medium px-3 py-1"
>
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="apiKey.is_locked"
variant="warning"
class="font-medium text-[10px]"
>
已锁定
</Badge>
</div>
</TableCell>
<!-- 最后使用时间 -->
@@ -211,7 +222,8 @@
variant="ghost"
size="icon"
class="h-8 w-8"
:title="apiKey.is_active ? '禁用' : '启用'"
:title="apiKey.is_locked ? '已锁定' : (apiKey.is_active ? '禁用' : '启用')"
:disabled="apiKey.is_locked"
@click="toggleApiKey(apiKey)"
>
<Power class="h-4 w-4" />
@@ -220,7 +232,8 @@
variant="ghost"
size="icon"
class="h-8 w-8"
title="删除"
:title="apiKey.is_locked ? '已锁定' : '删除'"
:disabled="apiKey.is_locked"
@click="confirmDelete(apiKey)"
>
<Trash2 class="h-4 w-4" />
@@ -256,6 +269,13 @@
>
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="apiKey.is_locked"
variant="warning"
class="text-[10px] px-1.5 py-0"
>
已锁定
</Badge>
</div>
<div class="flex items-center gap-0.5 flex-shrink-0">
<Button
@@ -271,7 +291,8 @@
variant="ghost"
size="icon"
class="h-7 w-7"
:title="apiKey.is_active ? '禁用' : '启用'"
:title="apiKey.is_locked ? '已锁定' : (apiKey.is_active ? '禁用' : '启用')"
:disabled="apiKey.is_locked"
@click="toggleApiKey(apiKey)"
>
<Power class="h-3.5 w-3.5" />
@@ -280,7 +301,8 @@
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除"
:title="apiKey.is_locked ? '已锁定' : '删除'"
:disabled="apiKey.is_locked"
@click="confirmDelete(apiKey)"
>
<Trash2 class="h-3.5 w-3.5" />