mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
fix: 修复apikeys页面的一些bug
This commit is contained in:
@@ -679,6 +679,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
if (updates.tokenLimit !== undefined) {
|
if (updates.tokenLimit !== undefined) {
|
||||||
finalUpdates.tokenLimit = updates.tokenLimit
|
finalUpdates.tokenLimit = updates.tokenLimit
|
||||||
}
|
}
|
||||||
|
if (updates.rateLimitCost !== undefined) {
|
||||||
|
finalUpdates.rateLimitCost = updates.rateLimitCost
|
||||||
|
}
|
||||||
if (updates.concurrencyLimit !== undefined) {
|
if (updates.concurrencyLimit !== undefined) {
|
||||||
finalUpdates.concurrencyLimit = updates.concurrencyLimit
|
finalUpdates.concurrencyLimit = updates.concurrencyLimit
|
||||||
}
|
}
|
||||||
@@ -1125,7 +1128,7 @@ router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => {
|
|||||||
deletedAt: key.deletedAt,
|
deletedAt: key.deletedAt,
|
||||||
deletedBy: key.deletedBy,
|
deletedBy: key.deletedBy,
|
||||||
deletedByType: key.deletedByType,
|
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`)
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 👥 账户分组管理
|
// 👥 账户分组管理
|
||||||
|
|
||||||
// 创建账户分组
|
// 创建账户分组
|
||||||
|
|||||||
@@ -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和账户级别统计)
|
// 📊 记录使用情况(支持缓存token和账户级别统计)
|
||||||
async recordUsage(
|
async recordUsage(
|
||||||
keyId,
|
keyId,
|
||||||
|
|||||||
@@ -188,12 +188,14 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||||
>Token 限制</label
|
>费用限制 (美元)</label
|
||||||
>
|
>
|
||||||
<input
|
<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"
|
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||||
|
min="0"
|
||||||
placeholder="不修改"
|
placeholder="不修改"
|
||||||
|
step="0.01"
|
||||||
type="number"
|
type="number"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,6 +218,24 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<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({
|
const form = reactive({
|
||||||
tokenLimit: '',
|
rateLimitCost: '', // 费用限制替代token限制
|
||||||
rateLimitWindow: '',
|
rateLimitWindow: '',
|
||||||
rateLimitRequests: '',
|
rateLimitRequests: '',
|
||||||
concurrencyLimit: '',
|
concurrencyLimit: '',
|
||||||
dailyCostLimit: '',
|
dailyCostLimit: '',
|
||||||
|
weeklyOpusCostLimit: '', // 新增Opus周费用限制
|
||||||
permissions: '', // 空字符串表示不修改
|
permissions: '', // 空字符串表示不修改
|
||||||
claudeAccountId: '',
|
claudeAccountId: '',
|
||||||
geminiAccountId: '',
|
geminiAccountId: '',
|
||||||
@@ -616,8 +637,8 @@ const batchUpdateApiKeys = async () => {
|
|||||||
const updates = {}
|
const updates = {}
|
||||||
|
|
||||||
// 只有非空值才添加到更新对象中
|
// 只有非空值才添加到更新对象中
|
||||||
if (form.tokenLimit !== '' && form.tokenLimit !== null) {
|
if (form.rateLimitCost !== '' && form.rateLimitCost !== null) {
|
||||||
updates.tokenLimit = parseInt(form.tokenLimit)
|
updates.rateLimitCost = parseFloat(form.rateLimitCost)
|
||||||
}
|
}
|
||||||
if (form.rateLimitWindow !== '' && form.rateLimitWindow !== null) {
|
if (form.rateLimitWindow !== '' && form.rateLimitWindow !== null) {
|
||||||
updates.rateLimitWindow = parseInt(form.rateLimitWindow)
|
updates.rateLimitWindow = parseInt(form.rateLimitWindow)
|
||||||
@@ -631,6 +652,9 @@ const batchUpdateApiKeys = async () => {
|
|||||||
if (form.dailyCostLimit !== '' && form.dailyCostLimit !== null) {
|
if (form.dailyCostLimit !== '' && form.dailyCostLimit !== null) {
|
||||||
updates.dailyCostLimit = parseFloat(form.dailyCostLimit)
|
updates.dailyCostLimit = parseFloat(form.dailyCostLimit)
|
||||||
}
|
}
|
||||||
|
if (form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null) {
|
||||||
|
updates.weeklyOpusCostLimit = parseFloat(form.weeklyOpusCostLimit)
|
||||||
|
}
|
||||||
|
|
||||||
// 权限设置
|
// 权限设置
|
||||||
if (form.permissions !== '') {
|
if (form.permissions !== '') {
|
||||||
|
|||||||
@@ -54,7 +54,8 @@
|
|||||||
<!-- Tab Content -->
|
<!-- Tab Content -->
|
||||||
<!-- 活跃 API Keys Tab Panel -->
|
<!-- 活跃 API Keys Tab Panel -->
|
||||||
<div v-if="activeTab === 'active'" class="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">
|
<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>
|
<span class="relative">刷新</span>
|
||||||
</button>
|
</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>
|
||||||
<!-- 创建按钮 -->
|
|
||||||
|
<!-- 创建按钮 - 独立在右侧 -->
|
||||||
<button
|
<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"
|
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"
|
@click.stop="openCreateApiKeyModal"
|
||||||
@@ -145,32 +173,6 @@
|
|||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
<span>创建新 Key</span>
|
<span>创建新 Key</span>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div v-if="apiKeysLoading" class="py-12 text-center">
|
<div v-if="apiKeysLoading" class="py-12 text-center">
|
||||||
@@ -1302,131 +1304,175 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 已删除的 API Keys 表格 -->
|
<!-- 已删除的 API Keys 表格 -->
|
||||||
<div v-else class="table-container">
|
<div v-else>
|
||||||
<table class="w-full table-fixed">
|
<!-- 工具栏 -->
|
||||||
<thead class="bg-gray-50/80 backdrop-blur-sm dark:bg-gray-700/80">
|
<div class="mb-4 flex justify-end">
|
||||||
<tr>
|
<button
|
||||||
<th
|
v-if="deletedApiKeys.length > 0"
|
||||||
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"
|
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"
|
||||||
名称
|
>
|
||||||
</th>
|
<i class="fas fa-trash-alt mr-2" />
|
||||||
<th
|
清空所有已删除 ({{ deletedApiKeys.length }})
|
||||||
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"
|
</button>
|
||||||
>
|
</div>
|
||||||
创建者
|
|
||||||
</th>
|
<div class="table-container">
|
||||||
<th
|
<table class="w-full table-fixed">
|
||||||
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"
|
<thead class="bg-gray-50/80 backdrop-blur-sm dark:bg-gray-700/80">
|
||||||
>
|
<tr>
|
||||||
创建时间
|
<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
|
>
|
||||||
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
|
||||||
</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
|
>
|
||||||
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
|
||||||
</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
|
>
|
||||||
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
|
||||||
</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"
|
||||||
</tr>
|
>
|
||||||
</thead>
|
删除者
|
||||||
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-600/50">
|
</th>
|
||||||
<tr v-for="key in deletedApiKeys" :key="key.id" class="table-row">
|
<th
|
||||||
<td class="px-3 py-4">
|
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"
|
||||||
<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"
|
</th>
|
||||||
>
|
<th
|
||||||
<i class="fas fa-trash text-xs text-white" />
|
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"
|
||||||
</div>
|
>
|
||||||
<div class="min-w-0">
|
使用统计
|
||||||
|
</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
|
<div
|
||||||
class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"
|
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"
|
||||||
:title="key.name"
|
|
||||||
>
|
>
|
||||||
{{ key.name }}
|
<i class="fas fa-trash text-xs text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="min-w-0">
|
||||||
class="truncate text-xs text-gray-500 dark:text-gray-400"
|
<div
|
||||||
:title="key.id"
|
class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"
|
||||||
>
|
:title="key.name"
|
||||||
{{ key.id }}
|
>
|
||||||
|
{{ key.name }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="truncate text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
:title="key.id"
|
||||||
|
>
|
||||||
|
{{ key.id }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td class="px-3 py-4">
|
||||||
<td class="px-3 py-4">
|
<div class="text-sm">
|
||||||
<div class="text-sm">
|
<span v-if="key.createdBy === 'admin'" class="text-blue-600">
|
||||||
<span v-if="key.createdBy === 'admin'" class="text-blue-600">
|
<i class="fas fa-user-shield mr-1" />
|
||||||
<i class="fas fa-user-shield mr-1" />
|
管理员
|
||||||
管理员
|
</span>
|
||||||
</span>
|
<span v-else-if="key.userUsername" class="text-green-600">
|
||||||
<span v-else-if="key.userUsername" class="text-green-600">
|
<i class="fas fa-user mr-1" />
|
||||||
<i class="fas fa-user mr-1" />
|
{{ key.userUsername }}
|
||||||
{{ key.userUsername }}
|
</span>
|
||||||
</span>
|
<span v-else class="text-gray-500 dark:text-gray-400">
|
||||||
<span v-else class="text-gray-500 dark:text-gray-400">
|
<i class="fas fa-question-circle mr-1" />
|
||||||
<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) }}次
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
</td>
|
||||||
<span class="text-gray-600 dark:text-gray-400">费用</span>
|
<td
|
||||||
<span class="font-semibold text-green-600">
|
class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400"
|
||||||
${{ (key.usage?.total?.cost || 0).toFixed(4) }}
|
>
|
||||||
|
{{ 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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="key.lastUsedAt" class="flex items-center justify-between">
|
</td>
|
||||||
<span class="text-gray-600 dark:text-gray-400">最后使用</span>
|
<td
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">
|
class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400"
|
||||||
{{ formatLastUsed(key.lastUsedAt) }}
|
>
|
||||||
</span>
|
{{ 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>
|
||||||
<div v-else class="text-xs text-gray-400">从未使用</div>
|
</td>
|
||||||
</div>
|
<td class="px-3 py-4">
|
||||||
</td>
|
<div class="flex items-center gap-2">
|
||||||
</tr>
|
<button
|
||||||
</tbody>
|
v-if="key.canRestore"
|
||||||
</table>
|
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>
|
</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
|
// 批量删除API Keys
|
||||||
const batchDeleteApiKeys = async () => {
|
const batchDeleteApiKeys = async () => {
|
||||||
const selectedCount = selectedApiKeys.value.length
|
const selectedCount = selectedApiKeys.value.length
|
||||||
|
|||||||
Reference in New Issue
Block a user