fix: 修复apikeys页面的一些bug

This commit is contained in:
shaw
2025-09-02 21:36:36 +08:00
parent 59d7705697
commit 81ad098678
4 changed files with 584 additions and 149 deletions

View File

@@ -679,6 +679,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
if (updates.tokenLimit !== undefined) {
finalUpdates.tokenLimit = updates.tokenLimit
}
if (updates.rateLimitCost !== undefined) {
finalUpdates.rateLimitCost = updates.rateLimitCost
}
if (updates.concurrencyLimit !== undefined) {
finalUpdates.concurrencyLimit = updates.concurrencyLimit
}
@@ -1125,7 +1128,7 @@ router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => {
deletedAt: key.deletedAt,
deletedBy: key.deletedBy,
deletedByType: key.deletedByType,
canRestore: false // Deleted keys cannot be restored per requirement
canRestore: true // 已删除的API Key可以恢复
}))
logger.success(`📋 Admin retrieved ${enrichedKeys.length} deleted API keys`)
@@ -1138,6 +1141,123 @@ router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => {
}
})
// 🔄 恢复已删除的API Key
router.post('/api-keys/:keyId/restore', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params
const adminUsername = req.session?.admin?.username || 'unknown'
// 调用服务层的恢复方法
const result = await apiKeyService.restoreApiKey(keyId, adminUsername, 'admin')
if (result.success) {
logger.success(`✅ Admin ${adminUsername} restored API key: ${keyId}`)
return res.json({
success: true,
message: 'API Key 已成功恢复',
apiKey: result.apiKey
})
} else {
return res.status(400).json({
success: false,
error: 'Failed to restore API key'
})
}
} catch (error) {
logger.error('❌ Failed to restore API key:', error)
// 根据错误类型返回适当的响应
if (error.message === 'API key not found') {
return res.status(404).json({
success: false,
error: 'API Key 不存在'
})
} else if (error.message === 'API key is not deleted') {
return res.status(400).json({
success: false,
error: '该 API Key 未被删除,无需恢复'
})
}
return res.status(500).json({
success: false,
error: '恢复 API Key 失败',
message: error.message
})
}
})
// 🗑️ 彻底删除API Key物理删除
router.delete('/api-keys/:keyId/permanent', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params
const adminUsername = req.session?.admin?.username || 'unknown'
// 调用服务层的彻底删除方法
const result = await apiKeyService.permanentDeleteApiKey(keyId)
if (result.success) {
logger.success(`🗑️ Admin ${adminUsername} permanently deleted API key: ${keyId}`)
return res.json({
success: true,
message: 'API Key 已彻底删除'
})
}
} catch (error) {
logger.error('❌ Failed to permanently delete API key:', error)
if (error.message === 'API key not found') {
return res.status(404).json({
success: false,
error: 'API Key 不存在'
})
} else if (error.message === '只能彻底删除已经删除的API Key') {
return res.status(400).json({
success: false,
error: '只能彻底删除已经删除的API Key'
})
}
return res.status(500).json({
success: false,
error: '彻底删除 API Key 失败',
message: error.message
})
}
})
// 🧹 清空所有已删除的API Keys
router.delete('/api-keys/deleted/clear-all', authenticateAdmin, async (req, res) => {
try {
const adminUsername = req.session?.admin?.username || 'unknown'
// 调用服务层的清空方法
const result = await apiKeyService.clearAllDeletedApiKeys()
logger.success(
`🧹 Admin ${adminUsername} cleared deleted API keys: ${result.successCount}/${result.total}`
)
return res.json({
success: true,
message: `成功清空 ${result.successCount} 个已删除的 API Keys`,
details: {
total: result.total,
successCount: result.successCount,
failedCount: result.failedCount,
errors: result.errors
}
})
} catch (error) {
logger.error('❌ Failed to clear all deleted API keys:', error)
return res.status(500).json({
success: false,
error: '清空已删除的 API Keys 失败',
message: error.message
})
}
})
// 👥 账户分组管理
// 创建账户分组

View File

@@ -434,6 +434,139 @@ class ApiKeyService {
}
}
// 🔄 恢复已删除的API Key
async restoreApiKey(keyId, restoredBy = 'system', restoredByType = 'system') {
try {
const keyData = await redis.getApiKey(keyId)
if (!keyData || Object.keys(keyData).length === 0) {
throw new Error('API key not found')
}
// 检查是否确实是已删除的key
if (keyData.isDeleted !== 'true') {
throw new Error('API key is not deleted')
}
// 准备更新的数据
const updatedData = { ...keyData }
updatedData.isActive = 'true'
updatedData.restoredAt = new Date().toISOString()
updatedData.restoredBy = restoredBy
updatedData.restoredByType = restoredByType
// 从更新的数据中移除删除相关的字段
delete updatedData.isDeleted
delete updatedData.deletedAt
delete updatedData.deletedBy
delete updatedData.deletedByType
// 保存更新后的数据
await redis.setApiKey(keyId, updatedData)
// 使用Redis的hdel命令删除不需要的字段
const keyName = `apikey:${keyId}`
await redis.client.hdel(keyName, 'isDeleted', 'deletedAt', 'deletedBy', 'deletedByType')
// 重新建立哈希映射恢复API Key的使用能力
if (keyData.apiKey) {
await redis.setApiKeyHash(keyData.apiKey, {
id: keyId,
name: keyData.name,
isActive: 'true'
})
}
logger.success(`✅ Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`)
return { success: true, apiKey: updatedData }
} catch (error) {
logger.error('❌ Failed to restore API key:', error)
throw error
}
}
// 🗑️ 彻底删除API Key物理删除
async permanentDeleteApiKey(keyId) {
try {
const keyData = await redis.getApiKey(keyId)
if (!keyData || Object.keys(keyData).length === 0) {
throw new Error('API key not found')
}
// 确保只能彻底删除已经软删除的key
if (keyData.isDeleted !== 'true') {
throw new Error('只能彻底删除已经删除的API Key')
}
// 删除所有相关的使用统计数据
const today = new Date().toISOString().split('T')[0]
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]
// 删除每日统计
await redis.client.del(`usage:daily:${today}:${keyId}`)
await redis.client.del(`usage:daily:${yesterday}:${keyId}`)
// 删除月度统计
const currentMonth = today.substring(0, 7)
await redis.client.del(`usage:monthly:${currentMonth}:${keyId}`)
// 删除所有相关的统计键(通过模式匹配)
const usageKeys = await redis.client.keys(`usage:*:${keyId}*`)
if (usageKeys.length > 0) {
await redis.client.del(...usageKeys)
}
// 删除API Key本身
await redis.deleteApiKey(keyId)
logger.success(`🗑️ Permanently deleted API key: ${keyId}`)
return { success: true }
} catch (error) {
logger.error('❌ Failed to permanently delete API key:', error)
throw error
}
}
// 🧹 清空所有已删除的API Keys
async clearAllDeletedApiKeys() {
try {
const allKeys = await this.getAllApiKeys(true)
const deletedKeys = allKeys.filter((key) => key.isDeleted === 'true')
let successCount = 0
let failedCount = 0
const errors = []
for (const key of deletedKeys) {
try {
await this.permanentDeleteApiKey(key.id)
successCount++
} catch (error) {
failedCount++
errors.push({
keyId: key.id,
keyName: key.name,
error: error.message
})
}
}
logger.success(`🧹 Cleared deleted API keys: ${successCount} success, ${failedCount} failed`)
return {
success: true,
total: deletedKeys.length,
successCount,
failedCount,
errors
}
} catch (error) {
logger.error('❌ Failed to clear all deleted API keys:', error)
throw error
}
}
// 📊 记录使用情况支持缓存token和账户级别统计
async recordUsage(
keyId,

View File

@@ -188,12 +188,14 @@
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>费用限制 (美元)</label
>
<input
v-model="form.tokenLimit"
v-model="form.rateLimitCost"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0"
placeholder="不修改"
step="0.01"
type="number"
/>
</div>
@@ -216,6 +218,24 @@
/>
</div>
<!-- Opus 模型周费用限制 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
Opus 模型周费用限制 (美元)
</label>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0"
placeholder="不修改 (0 表示无限制)"
step="0.01"
type="number"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户
</p>
</div>
<!-- 并发限制 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -496,11 +516,12 @@ const unselectedTags = computed(() => {
// 表单数据
const form = reactive({
tokenLimit: '',
rateLimitCost: '', // 费用限制替代token限制
rateLimitWindow: '',
rateLimitRequests: '',
concurrencyLimit: '',
dailyCostLimit: '',
weeklyOpusCostLimit: '', // 新增Opus周费用限制
permissions: '', // 空字符串表示不修改
claudeAccountId: '',
geminiAccountId: '',
@@ -616,8 +637,8 @@ const batchUpdateApiKeys = async () => {
const updates = {}
// 只有非空值才添加到更新对象中
if (form.tokenLimit !== '' && form.tokenLimit !== null) {
updates.tokenLimit = parseInt(form.tokenLimit)
if (form.rateLimitCost !== '' && form.rateLimitCost !== null) {
updates.rateLimitCost = parseFloat(form.rateLimitCost)
}
if (form.rateLimitWindow !== '' && form.rateLimitWindow !== null) {
updates.rateLimitWindow = parseInt(form.rateLimitWindow)
@@ -631,6 +652,9 @@ const batchUpdateApiKeys = async () => {
if (form.dailyCostLimit !== '' && form.dailyCostLimit !== null) {
updates.dailyCostLimit = parseFloat(form.dailyCostLimit)
}
if (form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null) {
updates.weeklyOpusCostLimit = parseFloat(form.weeklyOpusCostLimit)
}
// 权限设置
if (form.permissions !== '') {

View File

@@ -54,7 +54,8 @@
<!-- Tab Content -->
<!-- 活跃 API Keys Tab Panel -->
<div v-if="activeTab === 'active'" class="tab-panel">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<!-- 工具栏区域 - 添加 mb-4 增加与表格的间距 -->
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<!-- 筛选器组 -->
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<!-- 时间范围筛选 -->
@@ -136,8 +137,35 @@
/>
<span class="relative">刷新</span>
</button>
<!-- 批量编辑按钮 - 移到刷新按钮旁边 -->
<button
v-if="selectedApiKeys.length > 0"
class="group relative flex items-center justify-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-medium text-blue-700 shadow-sm transition-all duration-200 hover:border-blue-300 hover:bg-blue-100 hover:shadow-md dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50 sm:w-auto"
@click="openBatchEditModal()"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i class="fas fa-edit relative text-blue-600 dark:text-blue-400" />
<span class="relative">编辑选中 ({{ selectedApiKeys.length }})</span>
</button>
<!-- 批量删除按钮 - 移到刷新按钮旁边 -->
<button
v-if="selectedApiKeys.length > 0"
class="group relative flex items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition-all duration-200 hover:border-red-300 hover:bg-red-100 hover:shadow-md dark:border-red-700 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50 sm:w-auto"
@click="batchDeleteApiKeys()"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-red-500 to-pink-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i class="fas fa-trash relative text-red-600 dark:text-red-400" />
<span class="relative">删除选中 ({{ selectedApiKeys.length }})</span>
</button>
</div>
<!-- 创建按钮 -->
<!-- 创建按钮 - 独立在右侧 -->
<button
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-blue-500 to-blue-600 px-5 py-2.5 text-sm font-medium text-white shadow-md transition-all duration-200 hover:from-blue-600 hover:to-blue-700 hover:shadow-lg sm:w-auto"
@click.stop="openCreateApiKeyModal"
@@ -145,32 +173,6 @@
<i class="fas fa-plus"></i>
<span>创建新 Key</span>
</button>
<!-- 批量编辑按钮 -->
<button
v-if="selectedApiKeys.length > 0"
class="group relative flex items-center justify-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-medium text-blue-700 shadow-sm transition-all duration-200 hover:border-blue-300 hover:bg-blue-100 hover:shadow-md dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50 sm:w-auto"
@click="openBatchEditModal()"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i class="fas fa-edit relative text-blue-600 dark:text-blue-400" />
<span class="relative">编辑选中 ({{ selectedApiKeys.length }})</span>
</button>
<!-- 批量删除按钮 -->
<button
v-if="selectedApiKeys.length > 0"
class="group relative flex items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition-all duration-200 hover:border-red-300 hover:bg-red-100 hover:shadow-md dark:border-red-700 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50 sm:w-auto"
@click="batchDeleteApiKeys()"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-red-500 to-pink-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i class="fas fa-trash relative text-red-600 dark:text-red-400" />
<span class="relative">删除选中 ({{ selectedApiKeys.length }})</span>
</button>
</div>
<div v-if="apiKeysLoading" class="py-12 text-center">
@@ -1302,131 +1304,175 @@
</div>
<!-- 已删除的 API Keys 表格 -->
<div v-else class="table-container">
<table class="w-full table-fixed">
<thead class="bg-gray-50/80 backdrop-blur-sm dark:bg-gray-700/80">
<tr>
<th
class="w-[20%] min-w-[150px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
名称
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
创建者
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
创建时间
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
删除者
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
删除时间
</th>
<th
class="w-[20%] min-w-[150px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
使用统计
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-600/50">
<tr v-for="key in deletedApiKeys" :key="key.id" class="table-row">
<td class="px-3 py-4">
<div class="flex items-center">
<div
class="mr-2 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-red-500 to-red-600"
>
<i class="fas fa-trash text-xs text-white" />
</div>
<div class="min-w-0">
<div v-else>
<!-- 工具栏 -->
<div class="mb-4 flex justify-end">
<button
v-if="deletedApiKeys.length > 0"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700"
@click="clearAllDeletedApiKeys"
>
<i class="fas fa-trash-alt mr-2" />
清空所有已删除 ({{ deletedApiKeys.length }})
</button>
</div>
<div class="table-container">
<table class="w-full table-fixed">
<thead class="bg-gray-50/80 backdrop-blur-sm dark:bg-gray-700/80">
<tr>
<th
class="w-[20%] min-w-[150px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
名称
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
创建者
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
创建时间
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
删除者
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
删除时间
</th>
<th
class="w-[20%] min-w-[150px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
使用统计
</th>
<th
class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
操作
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-600/50">
<tr v-for="key in deletedApiKeys" :key="key.id" class="table-row">
<td class="px-3 py-4">
<div class="flex items-center">
<div
class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"
:title="key.name"
class="mr-2 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-red-500 to-red-600"
>
{{ key.name }}
<i class="fas fa-trash text-xs text-white" />
</div>
<div
class="truncate text-xs text-gray-500 dark:text-gray-400"
:title="key.id"
>
{{ key.id }}
<div class="min-w-0">
<div
class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"
:title="key.name"
>
{{ key.name }}
</div>
<div
class="truncate text-xs text-gray-500 dark:text-gray-400"
:title="key.id"
>
{{ key.id }}
</div>
</div>
</div>
</div>
</td>
<td class="px-3 py-4">
<div class="text-sm">
<span v-if="key.createdBy === 'admin'" class="text-blue-600">
<i class="fas fa-user-shield mr-1" />
管理员
</span>
<span v-else-if="key.userUsername" class="text-green-600">
<i class="fas fa-user mr-1" />
{{ key.userUsername }}
</span>
<span v-else class="text-gray-500 dark:text-gray-400">
<i class="fas fa-question-circle mr-1" />
未知
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
{{ formatDate(key.createdAt) }}
</td>
<td class="px-3 py-4">
<div class="text-sm">
<span v-if="key.deletedByType === 'admin'" class="text-blue-600">
<i class="fas fa-user-shield mr-1" />
{{ key.deletedBy }}
</span>
<span v-else-if="key.deletedByType === 'user'" class="text-green-600">
<i class="fas fa-user mr-1" />
{{ key.deletedBy }}
</span>
<span v-else class="text-gray-500 dark:text-gray-400">
<i class="fas fa-cog mr-1" />
{{ key.deletedBy }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
{{ formatDate(key.deletedAt) }}
</td>
<td class="px-3 py-4">
<div class="text-sm">
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">请求</span>
<span class="font-semibold text-gray-900 dark:text-gray-100">
{{ formatNumber(key.usage?.total?.requests || 0) }}次
</td>
<td class="px-3 py-4">
<div class="text-sm">
<span v-if="key.createdBy === 'admin'" class="text-blue-600">
<i class="fas fa-user-shield mr-1" />
管理员
</span>
<span v-else-if="key.userUsername" class="text-green-600">
<i class="fas fa-user mr-1" />
{{ key.userUsername }}
</span>
<span v-else class="text-gray-500 dark:text-gray-400">
<i class="fas fa-question-circle mr-1" />
未知
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">费用</span>
<span class="font-semibold text-green-600">
${{ (key.usage?.total?.cost || 0).toFixed(4) }}
</td>
<td
class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400"
>
{{ formatDate(key.createdAt) }}
</td>
<td class="px-3 py-4">
<div class="text-sm">
<span v-if="key.deletedByType === 'admin'" class="text-blue-600">
<i class="fas fa-user-shield mr-1" />
{{ key.deletedBy }}
</span>
<span v-else-if="key.deletedByType === 'user'" class="text-green-600">
<i class="fas fa-user mr-1" />
{{ key.deletedBy }}
</span>
<span v-else class="text-gray-500 dark:text-gray-400">
<i class="fas fa-cog mr-1" />
{{ key.deletedBy }}
</span>
</div>
<div v-if="key.lastUsedAt" class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">最后使用</span>
<span class="font-medium text-gray-700 dark:text-gray-300">
{{ formatLastUsed(key.lastUsedAt) }}
</span>
</td>
<td
class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400"
>
{{ formatDate(key.deletedAt) }}
</td>
<td class="px-3 py-4">
<div class="text-sm">
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">请求</span>
<span class="font-semibold text-gray-900 dark:text-gray-100">
{{ formatNumber(key.usage?.total?.requests || 0) }}次
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">费用</span>
<span class="font-semibold text-green-600">
${{ (key.usage?.total?.cost || 0).toFixed(4) }}
</span>
</div>
<div v-if="key.lastUsedAt" class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">最后使用</span>
<span class="font-medium text-gray-700 dark:text-gray-300">
{{ formatLastUsed(key.lastUsedAt) }}
</span>
</div>
<div v-else class="text-xs text-gray-400">从未使用</div>
</div>
<div v-else class="text-xs text-gray-400">从未使用</div>
</div>
</td>
</tr>
</tbody>
</table>
</td>
<td class="px-3 py-4">
<div class="flex items-center gap-2">
<button
v-if="key.canRestore"
class="rounded-lg bg-green-50 px-3 py-1.5 text-xs font-medium text-green-600 transition-colors hover:bg-green-100 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50"
title="恢复 API Key"
@click="restoreApiKey(key.id)"
>
<i class="fas fa-undo mr-1" />
恢复
</button>
<button
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-600 transition-colors hover:bg-red-100 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
title="彻底删除 API Key"
@click="permanentDeleteApiKey(key.id)"
>
<i class="fas fa-times mr-1" />
彻底删除
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
@@ -2309,6 +2355,118 @@ const deleteApiKey = async (keyId) => {
}
}
// 恢复API Key
const restoreApiKey = async (keyId) => {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'恢复 API Key',
'确定要恢复这个 API Key 吗?恢复后可以重新使用。',
'确定恢复',
'取消'
)
} else {
// 降级方案
confirmed = confirm('确定要恢复这个 API Key 吗?恢复后可以重新使用。')
}
if (!confirmed) return
try {
const data = await apiClient.post(`/admin/api-keys/${keyId}/restore`)
if (data.success) {
showToast('API Key 已成功恢复', 'success')
// 刷新已删除列表
await loadDeletedApiKeys()
// 同时刷新活跃列表
await loadApiKeys()
} else {
showToast(data.error || '恢复失败', 'error')
}
} catch (error) {
showToast(error.response?.data?.error || '恢复失败', 'error')
}
}
// 彻底删除API Key
const permanentDeleteApiKey = async (keyId) => {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'彻底删除 API Key',
'确定要彻底删除这个 API Key 吗?此操作不可恢复,所有相关数据将被永久删除。',
'确定彻底删除',
'取消'
)
} else {
// 降级方案
confirmed = confirm('确定要彻底删除这个 API Key 吗?此操作不可恢复,所有相关数据将被永久删除。')
}
if (!confirmed) return
try {
const data = await apiClient.delete(`/admin/api-keys/${keyId}/permanent`)
if (data.success) {
showToast('API Key 已彻底删除', 'success')
// 刷新已删除列表
loadDeletedApiKeys()
} else {
showToast(data.error || '彻底删除失败', 'error')
}
} catch (error) {
showToast(error.response?.data?.error || '彻底删除失败', 'error')
}
}
// 清空所有已删除的API Keys
const clearAllDeletedApiKeys = async () => {
const count = deletedApiKeys.value.length
if (count === 0) {
showToast('没有需要清空的 API Keys', 'info')
return
}
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'清空所有已删除的 API Keys',
`确定要彻底删除全部 ${count} 个已删除的 API Keys 吗?此操作不可恢复,所有相关数据将被永久删除。`,
'确定清空全部',
'取消'
)
} else {
// 降级方案
confirmed = confirm(`确定要彻底删除全部 ${count} 个已删除的 API Keys 吗?此操作不可恢复。`)
}
if (!confirmed) return
try {
const data = await apiClient.delete('/admin/api-keys/deleted/clear-all')
if (data.success) {
showToast(data.message || '已清空所有已删除的 API Keys', 'success')
// 如果有失败的,显示详细信息
if (data.details && data.details.failedCount > 0) {
const errors = data.details.errors
console.error('部分API Keys清空失败:', errors)
showToast(`${data.details.failedCount} 个清空失败,请查看控制台`, 'warning')
}
// 刷新已删除列表
loadDeletedApiKeys()
} else {
showToast(data.error || '清空失败', 'error')
}
} catch (error) {
showToast(error.response?.data?.error || '清空失败', 'error')
}
}
// 批量删除API Keys
const batchDeleteApiKeys = async () => {
const selectedCount = selectedApiKeys.value.length