mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
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:
@@ -269,6 +269,7 @@ export interface AdminApiKey {
|
||||
name?: string
|
||||
key_display?: string // 脱敏后的密钥显示
|
||||
is_active: boolean
|
||||
is_locked: boolean // 管理员锁定标志
|
||||
is_standalone: boolean // 是否为独立余额Key
|
||||
balance_used_usd?: number // 已使用余额(仅独立Key)
|
||||
current_balance_usd?: number | null // 当前余额(独立Key预付费模式,null表示无限制)
|
||||
@@ -310,6 +311,12 @@ export interface ApiKeyToggleResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ApiKeyLockResponse {
|
||||
id: string // UUID
|
||||
is_locked: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
// 管理员API密钥管理相关API
|
||||
export const adminApi = {
|
||||
// 获取所有独立余额Keys列表
|
||||
@@ -358,6 +365,14 @@ export const adminApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 切换API密钥锁定状态(锁定/解锁)
|
||||
async toggleLockApiKey(keyId: string): Promise<ApiKeyLockResponse> {
|
||||
const response = await apiClient.patch<ApiKeyLockResponse>(
|
||||
`/api/admin/api-keys/${keyId}/lock`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 为独立余额Key调整余额
|
||||
async addApiKeyBalance(keyId: string, amountUsd: number): Promise<AdminApiKey & { message: string }> {
|
||||
const response = await apiClient.patch<AdminApiKey & { message: string }>(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import client from '../client'
|
||||
import type { ProviderEndpoint, ProxyConfig } from './types'
|
||||
import type { ProviderEndpoint, ProxyConfig, HeaderRule } from './types'
|
||||
|
||||
/**
|
||||
* 获取指定 Provider 的所有 Endpoints
|
||||
@@ -27,7 +27,7 @@ export async function createEndpoint(
|
||||
api_format: string
|
||||
base_url: string
|
||||
custom_path?: string
|
||||
headers?: Record<string, string>
|
||||
header_rules?: HeaderRule[]
|
||||
timeout?: number
|
||||
max_retries?: number
|
||||
is_active?: boolean
|
||||
@@ -47,7 +47,7 @@ export async function updateEndpoint(
|
||||
data: Partial<{
|
||||
base_url: string
|
||||
custom_path: string | null
|
||||
headers: Record<string, string>
|
||||
header_rules: HeaderRule[]
|
||||
timeout: number
|
||||
max_retries: number
|
||||
is_active: boolean
|
||||
|
||||
@@ -62,6 +62,31 @@ export interface ProxyConfig {
|
||||
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求头规则类型
|
||||
* - set: 设置/覆盖请求头
|
||||
* - drop: 删除请求头
|
||||
* - rename: 重命名请求头(保留原值)
|
||||
*/
|
||||
export interface HeaderRuleSet {
|
||||
action: 'set'
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface HeaderRuleDrop {
|
||||
action: 'drop'
|
||||
key: string
|
||||
}
|
||||
|
||||
export interface HeaderRuleRename {
|
||||
action: 'rename'
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export type HeaderRule = HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename
|
||||
|
||||
export interface ProviderEndpoint {
|
||||
id: string
|
||||
provider_id: string
|
||||
@@ -69,7 +94,8 @@ export interface ProviderEndpoint {
|
||||
api_format: string
|
||||
base_url: string
|
||||
custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径)
|
||||
headers?: Record<string, string>
|
||||
// 请求头配置
|
||||
header_rules?: HeaderRule[] // 请求头规则列表,支持 set/drop/rename 操作
|
||||
timeout: number
|
||||
max_retries: number
|
||||
is_active: boolean
|
||||
|
||||
@@ -120,6 +120,7 @@ export interface ApiKey {
|
||||
key?: string
|
||||
key_display: string
|
||||
is_active: boolean
|
||||
is_locked: boolean // 管理员锁定标志
|
||||
last_used_at?: string
|
||||
created_at: string
|
||||
total_requests?: number
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface ApiKey {
|
||||
last_used_at?: string
|
||||
expires_at?: string // 过期时间
|
||||
is_active: boolean
|
||||
is_locked: boolean // 管理员锁定标志
|
||||
is_standalone: boolean // 是否为独立余额Key
|
||||
balance_used_usd?: number // 已使用余额(仅独立Key)
|
||||
current_balance_usd?: number | null // 当前余额(独立Key预付费模式,null表示无限制)
|
||||
|
||||
@@ -68,9 +68,139 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 请求头规则配置 -->
|
||||
<Collapsible v-model:open="rulesExpanded" class="mt-2">
|
||||
<CollapsibleTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronRight
|
||||
class="w-3 h-3 transition-transform"
|
||||
:class="{ 'rotate-90': rulesExpanded }"
|
||||
/>
|
||||
<span>请求头规则</span>
|
||||
<span v-if="editingRules.length > 0" class="text-primary">
|
||||
({{ editingRules.length }})
|
||||
</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="pt-2">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(rule, index) in editingRules"
|
||||
:key="index"
|
||||
class="flex items-start gap-2 p-2 rounded bg-muted/50"
|
||||
>
|
||||
<!-- 操作类型选择 -->
|
||||
<Select
|
||||
:model-value="rule.action"
|
||||
v-model:open="ruleSelectOpen[index]"
|
||||
@update:model-value="(v) => updateRuleAction(index, v as 'set' | 'drop' | 'rename')"
|
||||
>
|
||||
<SelectTrigger class="w-24 h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent :side-offset="4">
|
||||
<SelectItem value="set">设置</SelectItem>
|
||||
<SelectItem value="drop">删除</SelectItem>
|
||||
<SelectItem value="rename">重命名</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- set: key + value -->
|
||||
<template v-if="rule.action === 'set'">
|
||||
<div class="flex-1 space-y-1">
|
||||
<Input
|
||||
v-model="rule.key"
|
||||
placeholder="Header 名称"
|
||||
class="h-7 text-xs"
|
||||
/>
|
||||
<p
|
||||
v-if="validateRuleKey(rule.key, index)"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
{{ validateRuleKey(rule.key, index) }}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
v-model="rule.value"
|
||||
placeholder="Header 值"
|
||||
class="flex-1 h-7 text-xs"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- drop: key only -->
|
||||
<template v-else-if="rule.action === 'drop'">
|
||||
<div class="flex-1 space-y-1">
|
||||
<Input
|
||||
v-model="rule.key"
|
||||
placeholder="要删除的 Header 名称"
|
||||
class="h-7 text-xs"
|
||||
/>
|
||||
<p
|
||||
v-if="validateRuleKey(rule.key, index)"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
{{ validateRuleKey(rule.key, index) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- rename: from + to -->
|
||||
<template v-else-if="rule.action === 'rename'">
|
||||
<div class="flex-1 space-y-1">
|
||||
<Input
|
||||
v-model="rule.from"
|
||||
placeholder="原 Header 名称"
|
||||
class="h-7 text-xs"
|
||||
/>
|
||||
<p
|
||||
v-if="validateRenameFrom(rule.from, index)"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
{{ validateRenameFrom(rule.from, index) }}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight class="w-4 h-4 shrink-0 text-muted-foreground mt-1.5" />
|
||||
<div class="flex-1 space-y-1">
|
||||
<Input
|
||||
v-model="rule.to"
|
||||
placeholder="新 Header 名称"
|
||||
class="h-7 text-xs"
|
||||
/>
|
||||
<p
|
||||
v-if="validateRenameTo(rule.to, index)"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
{{ validateRenameTo(rule.to, index) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
@click="removeRule(index)"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full h-7 text-xs"
|
||||
@click="addRule"
|
||||
>
|
||||
<Plus class="w-3 h-3 mr-1" />
|
||||
添加规则
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 查看模式 -->
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-24 shrink-0">
|
||||
@@ -80,6 +210,12 @@
|
||||
<span class="text-sm text-muted-foreground truncate block">
|
||||
{{ endpoint.base_url }}{{ endpoint.custom_path ? endpoint.custom_path : '' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getEndpointRulesCount(endpoint) > 0"
|
||||
class="text-xs text-muted-foreground/70"
|
||||
>
|
||||
{{ getEndpointRulesCount(endpoint) }} 条请求头规则
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
@@ -205,8 +341,11 @@ import {
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from '@/components/ui'
|
||||
import { Settings, Edit, Trash2, Check, X, Power } from 'lucide-vue-next'
|
||||
import { Settings, Edit, Trash2, Check, X, Power, ChevronRight, Plus, ArrowRight } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { log } from '@/utils/logger'
|
||||
import {
|
||||
@@ -215,10 +354,20 @@ import {
|
||||
deleteEndpoint,
|
||||
API_FORMAT_LABELS,
|
||||
type ProviderEndpoint,
|
||||
type ProviderWithEndpointsSummary
|
||||
type ProviderWithEndpointsSummary,
|
||||
type HeaderRule,
|
||||
} from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
// 编辑用的规则类型(统一的可编辑结构)
|
||||
interface EditableRule {
|
||||
action: 'set' | 'drop' | 'rename'
|
||||
key: string // set/drop 用
|
||||
value: string // set 用
|
||||
from: string // rename 用
|
||||
to: string // rename 用
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
provider: ProviderWithEndpointsSummary | null
|
||||
@@ -243,6 +392,21 @@ const deletingEndpointId = ref<string | null>(null)
|
||||
const togglingEndpointId = ref<string | null>(null)
|
||||
const formatSelectOpen = ref(false)
|
||||
|
||||
// 请求头规则编辑状态
|
||||
const editingRules = ref<EditableRule[]>([])
|
||||
const rulesExpanded = ref(false)
|
||||
const ruleSelectOpen = ref<Record<number, boolean>>({}) // 每个规则 Select 的打开状态
|
||||
|
||||
// 系统保留的 header 名称(不允许用户设置)
|
||||
const RESERVED_HEADERS = new Set([
|
||||
'authorization',
|
||||
'x-api-key',
|
||||
'x-goog-api-key',
|
||||
'content-type',
|
||||
'content-length',
|
||||
'host',
|
||||
])
|
||||
|
||||
// 内部状态
|
||||
const internalOpen = computed(() => props.modelValue)
|
||||
|
||||
@@ -271,6 +435,147 @@ function getDefaultPath(apiFormat: string): string {
|
||||
return format?.default_path || ''
|
||||
}
|
||||
|
||||
// 将 API 返回的 header_rules 转换为可编辑的规则数组
|
||||
function loadRulesFromEndpoint(endpoint: ProviderEndpoint): EditableRule[] {
|
||||
const rules: EditableRule[] = []
|
||||
|
||||
if (endpoint.header_rules && endpoint.header_rules.length > 0) {
|
||||
for (const rule of endpoint.header_rules) {
|
||||
if (rule.action === 'set') {
|
||||
rules.push({ action: 'set', key: rule.key, value: rule.value || '', from: '', to: '' })
|
||||
} else if (rule.action === 'drop') {
|
||||
rules.push({ action: 'drop', key: rule.key, value: '', from: '', to: '' })
|
||||
} else if (rule.action === 'rename') {
|
||||
rules.push({ action: 'rename', key: '', value: '', from: rule.from, to: rule.to })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
// 将可编辑规则数组转换为 API 需要的 HeaderRule[]
|
||||
function rulesToHeaderRules(rules: EditableRule[]): HeaderRule[] | undefined {
|
||||
const result: HeaderRule[] = []
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.action === 'set' && rule.key.trim()) {
|
||||
result.push({ action: 'set', key: rule.key.trim(), value: rule.value })
|
||||
} else if (rule.action === 'drop' && rule.key.trim()) {
|
||||
result.push({ action: 'drop', key: rule.key.trim() })
|
||||
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
|
||||
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim() })
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : undefined
|
||||
}
|
||||
|
||||
// 添加新规则
|
||||
function addRule() {
|
||||
editingRules.value.push({ action: 'set', key: '', value: '', from: '', to: '' })
|
||||
}
|
||||
|
||||
// 删除规则
|
||||
function removeRule(index: number) {
|
||||
editingRules.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 更新规则类型时重置字段
|
||||
function updateRuleAction(index: number, action: 'set' | 'drop' | 'rename') {
|
||||
const rule = editingRules.value[index]
|
||||
rule.action = action
|
||||
// 重置字段
|
||||
rule.key = ''
|
||||
rule.value = ''
|
||||
rule.from = ''
|
||||
rule.to = ''
|
||||
}
|
||||
|
||||
// 验证 set/drop 的 key
|
||||
function validateRuleKey(key: string, index: number): string | null {
|
||||
const trimmedKey = key.trim().toLowerCase()
|
||||
if (!trimmedKey) return null
|
||||
|
||||
// set/drop 操作都不允许操作保留头
|
||||
if (RESERVED_HEADERS.has(trimmedKey)) {
|
||||
return `"${key}" 是系统保留的请求头`
|
||||
}
|
||||
|
||||
// 检查重复(在所有规则中检查同类型的 key)
|
||||
const duplicate = editingRules.value.findIndex(
|
||||
(r, i) => i !== index && (
|
||||
((r.action === 'set' || r.action === 'drop') && r.key.trim().toLowerCase() === trimmedKey) ||
|
||||
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedKey)
|
||||
)
|
||||
)
|
||||
if (duplicate >= 0) {
|
||||
return '请求头名称重复'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 验证 rename 的 from
|
||||
function validateRenameFrom(from: string, index: number): string | null {
|
||||
const trimmedFrom = from.trim().toLowerCase()
|
||||
if (!trimmedFrom) return null
|
||||
|
||||
// 检查是否有其他规则已经修改了这个头
|
||||
const duplicate = editingRules.value.findIndex(
|
||||
(r, i) => i !== index &&
|
||||
((r.action === 'set' && r.key.trim().toLowerCase() === trimmedFrom) ||
|
||||
(r.action === 'drop' && r.key.trim().toLowerCase() === trimmedFrom) ||
|
||||
(r.action === 'rename' && r.from.trim().toLowerCase() === trimmedFrom))
|
||||
)
|
||||
if (duplicate >= 0) {
|
||||
return '该请求头已被其他规则处理'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 验证 rename 的 to
|
||||
function validateRenameTo(to: string, index: number): string | null {
|
||||
const trimmedTo = to.trim().toLowerCase()
|
||||
if (!trimmedTo) return null
|
||||
|
||||
if (RESERVED_HEADERS.has(trimmedTo)) {
|
||||
return `"${to}" 是系统保留的请求头`
|
||||
}
|
||||
|
||||
// 检查重复
|
||||
const duplicate = editingRules.value.findIndex(
|
||||
(r, i) => i !== index &&
|
||||
((r.action === 'set' && r.key.trim().toLowerCase() === trimmedTo) ||
|
||||
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedTo))
|
||||
)
|
||||
if (duplicate >= 0) {
|
||||
return '请求头名称重复'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 检查所有规则是否有效(用于保存前验证)
|
||||
function hasValidationErrors(): boolean {
|
||||
for (let i = 0; i < editingRules.value.length; i++) {
|
||||
const rule = editingRules.value[i]
|
||||
if (rule.action === 'set' || rule.action === 'drop') {
|
||||
if (validateRuleKey(rule.key, i)) return true
|
||||
} else if (rule.action === 'rename') {
|
||||
if (validateRenameFrom(rule.from, i)) return true
|
||||
if (validateRenameTo(rule.to, i)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取端点的请求头规则数量(用于查看模式显示)
|
||||
function getEndpointRulesCount(endpoint: ProviderEndpoint): number {
|
||||
return endpoint.header_rules?.length || 0
|
||||
}
|
||||
|
||||
// 当前编辑端点的默认路径
|
||||
const editingDefaultPath = computed(() => {
|
||||
const endpoint = localEndpoints.value.find(e => e.id === editingEndpointId.value)
|
||||
@@ -304,6 +609,8 @@ watch(() => props.modelValue, (open) => {
|
||||
editingEndpointId.value = null
|
||||
editingUrl.value = ''
|
||||
editingPath.value = ''
|
||||
editingRules.value = []
|
||||
rulesExpanded.value = false
|
||||
} else {
|
||||
// 关闭对话框时完全清空新端点表单
|
||||
newEndpoint.value = { api_format: '', base_url: '', custom_path: '' }
|
||||
@@ -321,6 +628,9 @@ function startEdit(endpoint: ProviderEndpoint) {
|
||||
editingEndpointId.value = endpoint.id
|
||||
editingUrl.value = endpoint.base_url
|
||||
editingPath.value = endpoint.custom_path || ''
|
||||
// 加载规则数据
|
||||
editingRules.value = loadRulesFromEndpoint(endpoint)
|
||||
rulesExpanded.value = editingRules.value.length > 0
|
||||
}
|
||||
|
||||
// 取消编辑
|
||||
@@ -328,17 +638,26 @@ function cancelEdit() {
|
||||
editingEndpointId.value = null
|
||||
editingUrl.value = ''
|
||||
editingPath.value = ''
|
||||
editingRules.value = []
|
||||
rulesExpanded.value = false
|
||||
}
|
||||
|
||||
// 保存端点
|
||||
async function saveEndpointUrl(endpoint: ProviderEndpoint) {
|
||||
if (!editingUrl.value) return
|
||||
|
||||
// 检查规则是否有验证错误
|
||||
if (hasValidationErrors()) {
|
||||
showError('请修正请求头规则中的错误')
|
||||
return
|
||||
}
|
||||
|
||||
savingEndpointId.value = endpoint.id
|
||||
try {
|
||||
await updateEndpoint(endpoint.id, {
|
||||
base_url: editingUrl.value,
|
||||
custom_path: editingPath.value || null, // 空字符串时传 null 清空
|
||||
custom_path: editingPath.value || null,
|
||||
header_rules: rulesToHeaderRules(editingRules.value),
|
||||
})
|
||||
success('端点已更新')
|
||||
emit('endpointUpdated')
|
||||
|
||||
@@ -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此操作无法撤销。`,
|
||||
|
||||
@@ -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 获取完整密钥
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user