feat: management of deleted keys

This commit is contained in:
Feng Yue
2025-08-14 12:42:39 +08:00
parent 5d850a7c1c
commit aff9966ed1
3 changed files with 1313 additions and 1085 deletions

View File

@@ -5,12 +5,7 @@ const userService = require('../services/userService')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const config = require('../../config/config') const config = require('../../config/config')
const { const { authenticateUser, authenticateUserOrAdmin, requireAdmin } = require('../middleware/auth')
authenticateUser,
authenticateUserOrAdmin,
requireAdmin,
requireRole
} = require('../middleware/auth')
// 🔐 用户登录端点 // 🔐 用户登录端点
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
@@ -253,7 +248,6 @@ router.post('/api-keys', authenticateUser, async (req, res) => {
} }
}) })
// 🗑️ 删除API Key // 🗑️ 删除API Key
router.delete('/api-keys/:keyId', authenticateUser, async (req, res) => { router.delete('/api-keys/:keyId', authenticateUser, async (req, res) => {
try { try {
@@ -313,7 +307,7 @@ router.get('/usage-stats', authenticateUser, async (req, res) => {
} }
// 获取使用统计 // 获取使用统计
const stats = await apiKeyService.getUsageStats(apiKeyIds, { period, model }) const stats = await apiKeyService.getAggregatedUsageStats(apiKeyIds, { period, model })
res.json({ res.json({
success: true, success: true,
@@ -584,7 +578,7 @@ router.get('/:userId/usage-stats', authenticateUserOrAdmin, requireAdmin, async
} }
// 获取使用统计 // 获取使用统计
const stats = await apiKeyService.getUsageStats(apiKeyIds, { period, model }) const stats = await apiKeyService.getAggregatedUsageStats(apiKeyIds, { period, model })
res.json({ res.json({
success: true, success: true,

View File

@@ -536,7 +536,7 @@ class ApiKeyService {
createdAt: key.createdAt, createdAt: key.createdAt,
lastUsedAt: key.lastUsedAt, lastUsedAt: key.lastUsedAt,
expiresAt: key.expiresAt, expiresAt: key.expiresAt,
usage: usage, usage,
dailyCost, dailyCost,
totalCost: costStats.total, totalCost: costStats.total,
dailyCostLimit: parseFloat(key.dailyCostLimit || 0), dailyCostLimit: parseFloat(key.dailyCostLimit || 0),
@@ -628,8 +628,8 @@ class ApiKeyService {
} }
} }
// 🗑️ 删除API Key // 🗑️ 删除API Key (完全移除)
async deleteApiKey(keyId) { async hardDeleteApiKey(keyId) {
try { try {
const keyData = await redis.getApiKey(keyId) const keyData = await redis.getApiKey(keyId)
if (!keyData) { if (!keyData) {
@@ -669,14 +669,14 @@ class ApiKeyService {
} }
} }
// 📊 获取使用统计支持多个API Key // 📊 获取聚合使用统计支持多个API Key
async getUsageStats(keyIds, options = {}) { async getAggregatedUsageStats(keyIds, options = {}) {
try { try {
if (!Array.isArray(keyIds)) { if (!Array.isArray(keyIds)) {
keyIds = [keyIds] keyIds = [keyIds]
} }
const { period = 'week', model } = options const { period: _period = 'week', model: _model } = options
const stats = { const stats = {
totalRequests: 0, totalRequests: 0,
totalInputTokens: 0, totalInputTokens: 0,

View File

@@ -6,6 +6,50 @@
<h3 class="mb-1 text-lg font-bold text-gray-900 sm:mb-2 sm:text-xl">API Keys 管理</h3> <h3 class="mb-1 text-lg font-bold text-gray-900 sm:mb-2 sm:text-xl">API Keys 管理</h3>
<p class="text-sm text-gray-600 sm:text-base">管理和监控您的 API 密钥</p> <p class="text-sm text-gray-600 sm:text-base">管理和监控您的 API 密钥</p>
</div> </div>
<!-- Tab Navigation -->
<div class="border-b border-gray-200">
<nav aria-label="Tabs" class="-mb-px flex space-x-8">
<button
:class="[
'whitespace-nowrap border-b-2 px-1 py-2 text-sm font-medium',
activeTab === 'active'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
]"
@click="activeTab = 'active'"
>
活跃 API Keys
<span
v-if="apiKeys.length > 0"
class="ml-2 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-900"
>
{{ apiKeys.length }}
</span>
</button>
<button
:class="[
'whitespace-nowrap border-b-2 px-1 py-2 text-sm font-medium',
activeTab === 'deleted'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
]"
@click="loadDeletedApiKeys"
>
已删除 API Keys
<span
v-if="deletedApiKeys.length > 0"
class="ml-2 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-900"
>
{{ deletedApiKeys.length }}
</span>
</button>
</nav>
</div>
<!-- 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"> <div class="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">
@@ -98,7 +142,6 @@
<span>创建新 Key</span> <span>创建新 Key</span>
</button> </button>
</div> </div>
</div>
<div v-if="apiKeysLoading" class="py-12 text-center"> <div v-if="apiKeysLoading" class="py-12 text-center">
<div class="loading-spinner mx-auto mb-4" /> <div class="loading-spinner mx-auto mb-4" />
@@ -224,7 +267,10 @@
<i class="fas fa-key text-xs text-white" /> <i class="fas fa-key text-xs text-white" />
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<div class="truncate text-sm font-semibold text-gray-900" :title="key.name"> <div
class="truncate text-sm font-semibold text-gray-900"
:title="key.name"
>
{{ key.name }} {{ key.name }}
</div> </div>
<div class="truncate text-xs text-gray-500" :title="key.id"> <div class="truncate text-xs text-gray-500" :title="key.id">
@@ -297,7 +343,9 @@
> >
{{ tag }} {{ tag }}
</span> </span>
<span v-if="!key.tags || key.tags.length === 0" class="text-xs text-gray-400" <span
v-if="!key.tags || key.tags.length === 0"
class="text-xs text-gray-400"
>无标签</span >无标签</span
> >
</div> </div>
@@ -421,7 +469,12 @@
title="编辑过期时间" title="编辑过期时间"
@click.stop="startEditExpiry(key)" @click.stop="startEditExpiry(key)"
> >
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
class="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path <path
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
stroke-linecap="round" stroke-linecap="round"
@@ -586,10 +639,14 @@
<span class="text-xs">刷新</span> <span class="text-xs">刷新</span>
</button> </button>
</div> </div>
<p class="text-xs text-gray-400">尝试调整时间范围或点击刷新重新加载数据</p> <p class="text-xs text-gray-400">
尝试调整时间范围或点击刷新重新加载数据
</p>
</div> </div>
<div <div
v-else-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" v-else-if="
apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0
"
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3" class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
> >
<div <div
@@ -602,7 +659,8 @@
<span class="mb-1 block text-sm font-semibold text-gray-800">{{ <span class="mb-1 block text-sm font-semibold text-gray-800">{{
stat.model stat.model
}}</span> }}</span>
<span class="rounded-full bg-blue-50 px-2 py-1 text-xs text-gray-500" <span
class="rounded-full bg-blue-50 px-2 py-1 text-xs text-gray-500"
>{{ stat.requests }} 次请求</span >{{ stat.requests }} 次请求</span
> >
</div> </div>
@@ -628,7 +686,9 @@
}}</span> }}</span>
</div> </div>
<div class="mt-2 border-t border-gray-100 pt-2"> <div class="mt-2 border-t border-gray-100 pt-2">
<div class="flex items-center justify-between text-xs text-gray-500"> <div
class="flex items-center justify-between text-xs text-gray-500"
>
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-arrow-down mr-1 text-green-500" /> <i class="fas fa-arrow-down mr-1 text-green-500" />
输入: 输入:
@@ -637,7 +697,9 @@
formatTokenCount(stat.inputTokens) formatTokenCount(stat.inputTokens)
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between text-xs text-gray-500"> <div
class="flex items-center justify-between text-xs text-gray-500"
>
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-arrow-up mr-1 text-blue-500" /> <i class="fas fa-arrow-up mr-1 text-blue-500" />
输出: 输出:
@@ -713,7 +775,10 @@
<span class="text-gray-600"> <span class="text-gray-600">
总请求: 总请求:
<span class="font-semibold text-gray-800">{{ <span class="font-semibold text-gray-800">{{
apiKeyModelStats[key.id].reduce((sum, stat) => sum + stat.requests, 0) apiKeyModelStats[key.id].reduce(
(sum, stat) => sum + stat.requests,
0
)
}}</span> }}</span>
</span> </span>
<span class="text-gray-600"> <span class="text-gray-600">
@@ -809,7 +874,9 @@
</div> </div>
<!-- OpenAI 绑定 --> <!-- OpenAI 绑定 -->
<div v-if="key.openaiAccountId" class="flex flex-wrap items-center gap-1 text-xs"> <div v-if="key.openaiAccountId" class="flex flex-wrap items-center gap-1 text-xs">
<span class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-gray-700"> <span
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-gray-700"
>
<i class="fa-openai mr-1" /> <i class="fa-openai mr-1" />
OpenAI OpenAI
</span> </span>
@@ -886,7 +953,9 @@
<!-- 移动端时间窗口限制 --> <!-- 移动端时间窗口限制 -->
<WindowCountdown <WindowCountdown
v-if="key.rateLimitWindow > 0 && (key.rateLimitRequests > 0 || key.tokenLimit > 0)" v-if="
key.rateLimitWindow > 0 && (key.rateLimitRequests > 0 || key.tokenLimit > 0)
"
:current-requests="key.currentWindowRequests" :current-requests="key.currentWindowRequests"
:current-tokens="key.currentWindowTokens" :current-tokens="key.currentWindowTokens"
:rate-limit-window="key.rateLimitWindow" :rate-limit-window="key.rateLimitWindow"
@@ -1056,7 +1125,9 @@
</button> </button>
<!-- 最后一页 --> <!-- 最后一页 -->
<span v-if="currentPage < totalPages - 3" class="hidden px-2 text-gray-500 sm:inline" <span
v-if="currentPage < totalPages - 3"
class="hidden px-2 text-gray-500 sm:inline"
>...</span >...</span
> >
<button <button
@@ -1080,6 +1151,146 @@
</div> </div>
</div> </div>
<!-- 已删除 API Keys Tab Panel -->
<div v-else-if="activeTab === 'deleted'" class="tab-panel">
<div v-if="deletedApiKeysLoading" class="py-12 text-center">
<div class="loading-spinner mx-auto mb-4" />
<p class="text-gray-500">正在加载已删除的 API Keys...</p>
</div>
<div v-else-if="deletedApiKeys.length === 0" class="py-12 text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100"
>
<i class="fas fa-trash text-xl text-gray-400" />
</div>
<p class="text-lg text-gray-500">暂无已删除的 API Keys</p>
<p class="mt-2 text-sm text-gray-400">已删除的 API Keys 会出现在这里</p>
</div>
<!-- 已删除的 API Keys 表格 -->
<div v-else class="table-container">
<table class="w-full table-fixed">
<thead class="bg-gray-50/80 backdrop-blur-sm">
<tr>
<th
class="w-[20%] min-w-[150px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700"
>
名称
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700"
>
创建者
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700"
>
创建时间
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700"
>
删除者
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700"
>
删除时间
</th>
<th
class="w-[20%] min-w-[150px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700"
>
使用统计
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200/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 class="truncate text-sm font-semibold text-gray-900" :title="key.name">
{{ key.name }}
</div>
<div class="truncate text-xs text-gray-500" :title="key.id">
{{ key.id }}
</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">
<i class="fas fa-question-circle mr-1" />
未知
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{{ 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">
<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">
{{ 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">请求</span>
<span class="font-semibold text-gray-900">
{{ formatNumber(key.usage?.total?.requests || 0) }}次
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600">费用</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">最后使用</span>
<span class="font-medium text-gray-700">
{{ formatLastUsed(key.lastUsedAt) }}
</span>
</div>
<div v-else class="text-xs text-gray-400">从未使用</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 模态框组件 --> <!-- 模态框组件 -->
<CreateApiKeyModal <CreateApiKeyModal
v-if="showCreateApiKeyModal" v-if="showCreateApiKeyModal"
@@ -1132,6 +1343,8 @@
@close="showUsageDetailModal = false" @close="showUsageDetailModal = false"
/> />
</div> </div>
</div>
</div>
</template> </template>
<script setup> <script setup>
@@ -1154,6 +1367,11 @@ const clientsStore = useClientsStore()
const apiKeys = ref([]) const apiKeys = ref([])
const apiKeysLoading = ref(false) const apiKeysLoading = ref(false)
const apiKeyStatsTimeRange = ref('today') const apiKeyStatsTimeRange = ref('today')
// Tab management
const activeTab = ref('active')
const deletedApiKeys = ref([])
const deletedApiKeysLoading = ref(false)
const apiKeysSortBy = ref('') const apiKeysSortBy = ref('')
const apiKeysSortOrder = ref('asc') const apiKeysSortOrder = ref('asc')
const expandedApiKeys = ref({}) const expandedApiKeys = ref({})
@@ -1376,6 +1594,22 @@ const loadApiKeys = async () => {
} }
} }
// 加载已删除的API Keys
const loadDeletedApiKeys = async () => {
activeTab.value = 'deleted'
deletedApiKeysLoading.value = true
try {
const data = await apiClient.get('/admin/api-keys/deleted')
if (data.success) {
deletedApiKeys.value = data.apiKeys || []
}
} catch (error) {
showToast('加载已删除的 API Keys 失败', 'error')
} finally {
deletedApiKeysLoading.value = false
}
}
// 排序API Keys // 排序API Keys
const sortApiKeys = (field) => { const sortApiKeys = (field) => {
if (apiKeysSortBy.value === field) { if (apiKeysSortBy.value === field) {