diff --git a/src/routes/admin.js b/src/routes/admin.js index 8c55fa87..1273370d 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -320,7 +320,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { restrictedModels, enableClientRestriction, allowedClients, - dailyCostLimit + dailyCostLimit, + tags } = req.body; // 输入验证 @@ -371,6 +372,15 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: 'Allowed clients must be an array' }); } + // 验证标签字段 + if (tags !== undefined && !Array.isArray(tags)) { + return res.status(400).json({ error: 'Tags must be an array' }); + } + + if (tags && tags.some(tag => typeof tag !== 'string' || tag.trim().length === 0)) { + return res.status(400).json({ error: 'All tags must be non-empty strings' }); + } + const newKey = await apiKeyService.generateApiKey({ name, description, @@ -386,7 +396,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { restrictedModels, enableClientRestriction, allowedClients, - dailyCostLimit + dailyCostLimit, + tags }); logger.success(`🔑 Admin created new API key: ${name}`); @@ -401,7 +412,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params; - const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt, dailyCostLimit } = req.body; + const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt, dailyCostLimit, tags } = req.body; // 只允许更新指定字段 const updates = {}; @@ -506,6 +517,17 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.dailyCostLimit = costLimit; } + // 处理标签 + if (tags !== undefined) { + if (!Array.isArray(tags)) { + return res.status(400).json({ error: 'Tags must be an array' }); + } + if (tags.some(tag => typeof tag !== 'string' || tag.trim().length === 0)) { + return res.status(400).json({ error: 'All tags must be non-empty strings' }); + } + updates.tags = tags; + } + await apiKeyService.updateApiKey(keyId, updates); logger.success(`📝 Admin updated API key: ${keyId}`); diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index f7a1797b..79dbf930 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -27,7 +27,8 @@ class ApiKeyService { restrictedModels = [], enableClientRestriction = false, allowedClients = [], - dailyCostLimit = 0 + dailyCostLimit = 0, + tags = [] } = options; // 生成简单的API Key (64字符十六进制) @@ -53,6 +54,7 @@ class ApiKeyService { enableClientRestriction: String(enableClientRestriction || false), allowedClients: JSON.stringify(allowedClients || []), dailyCostLimit: String(dailyCostLimit || 0), + tags: JSON.stringify(tags || []), createdAt: new Date().toISOString(), lastUsedAt: '', expiresAt: expiresAt || '', @@ -82,6 +84,7 @@ class ApiKeyService { enableClientRestriction: keyData.enableClientRestriction === 'true', allowedClients: JSON.parse(keyData.allowedClients || '[]'), dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + tags: JSON.parse(keyData.tags || '[]'), createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, createdBy: keyData.createdBy @@ -142,6 +145,14 @@ class ApiKeyService { allowedClients = []; } + // 解析标签 + let tags = []; + try { + tags = keyData.tags ? JSON.parse(keyData.tags) : []; + } catch (e) { + tags = []; + } + return { valid: true, keyData: { @@ -163,6 +174,7 @@ class ApiKeyService { allowedClients: allowedClients, dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), dailyCost: dailyCost || 0, + tags: tags, usage } }; @@ -201,6 +213,11 @@ class ApiKeyService { } catch (e) { key.allowedClients = []; } + try { + key.tags = key.tags ? JSON.parse(key.tags) : []; + } catch (e) { + key.tags = []; + } delete key.apiKey; // 不返回哈希后的key } @@ -220,12 +237,12 @@ class ApiKeyService { } // 允许更新的字段 - const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients', 'dailyCostLimit']; + const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients', 'dailyCostLimit', 'tags']; const updatedData = { ...keyData }; for (const [field, value] of Object.entries(updates)) { if (allowedUpdates.includes(field)) { - if (field === 'restrictedModels' || field === 'allowedClients') { + if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') { // 特殊处理数组字段 updatedData[field] = JSON.stringify(value || []); } else if (field === 'enableModelRestriction' || field === 'enableClientRestriction') { diff --git a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue index 502bfd34..c07e3a17 100644 --- a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue @@ -29,6 +29,40 @@ > + +
+ +
+ +
+ + {{ tag }} + + +
+ + +
+ + +
+

用于标记不同团队或用途,方便筛选管理

+
+
+
@@ -375,6 +409,9 @@ const authStore = useAuthStore() const clientsStore = useClientsStore() const loading = ref(false) +// 标签相关 +const newTag = ref('') + // 支持的客户端列表 const supportedClients = ref([]) @@ -397,7 +434,8 @@ const form = reactive({ restrictedModels: [], modelInput: '', enableClientRestriction: false, - allowedClients: [] + allowedClients: [], + tags: [] }) // 加载支持的客户端 @@ -482,6 +520,21 @@ const removeRestrictedModel = (index) => { form.restrictedModels.splice(index, 1) } +// 标签管理方法 +const addTag = () => { + if (newTag.value && newTag.value.trim()) { + const tag = newTag.value.trim() + if (!form.tags.includes(tag)) { + form.tags.push(tag) + } + newTag.value = '' + } +} + +const removeTag = (index) => { + form.tags.splice(index, 1) +} + // 创建 API Key const createApiKey = async () => { loading.value = true @@ -499,7 +552,8 @@ const createApiKey = async () => { expiresAt: form.expiresAt || undefined, permissions: form.permissions, claudeAccountId: form.claudeAccountId || undefined, - geminiAccountId: form.geminiAccountId || undefined + geminiAccountId: form.geminiAccountId || undefined, + tags: form.tags.length > 0 ? form.tags : undefined } // 模型限制 diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index 44144f81..8c84fc0a 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -29,6 +29,40 @@

名称不可修改

+ +
+ +
+ +
+ + {{ tag }} + + +
+ + +
+ + +
+

用于标记不同团队或用途,方便筛选管理

+
+
+
@@ -343,6 +377,9 @@ const loading = ref(false) // 支持的客户端列表 const supportedClients = ref([]) +// 标签相关 +const newTag = ref('') + // 表单数据 const form = reactive({ name: '', @@ -358,7 +395,8 @@ const form = reactive({ restrictedModels: [], modelInput: '', enableClientRestriction: false, - allowedClients: [] + allowedClients: [], + tags: [] }) @@ -375,6 +413,21 @@ const removeRestrictedModel = (index) => { form.restrictedModels.splice(index, 1) } +// 标签管理方法 +const addTag = () => { + if (newTag.value && newTag.value.trim()) { + const tag = newTag.value.trim() + if (!form.tags.includes(tag)) { + form.tags.push(tag) + } + newTag.value = '' + } +} + +const removeTag = (index) => { + form.tags.splice(index, 1) +} + // 更新 API Key const updateApiKey = async () => { loading.value = true @@ -389,7 +442,8 @@ const updateApiKey = async () => { dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0, permissions: form.permissions, claudeAccountId: form.claudeAccountId || null, - geminiAccountId: form.geminiAccountId || null + geminiAccountId: form.geminiAccountId || null, + tags: form.tags } // 模型限制 @@ -437,6 +491,7 @@ onMounted(async () => { form.geminiAccountId = props.apiKey.geminiAccountId || '' form.restrictedModels = props.apiKey.restrictedModels || [] form.allowedClients = props.apiKey.allowedClients || [] + form.tags = props.apiKey.tags || [] form.enableModelRestriction = form.restrictedModels.length > 0 form.enableClientRestriction = form.allowedClients.length > 0 }) diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index dbbd83e0..d688f30a 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -18,6 +18,14 @@ + +
+ +
+ + {{ tag }} + + 无标签 +
+
{{ (key.apiKey || '').substring(0, 20) }}... @@ -473,6 +492,10 @@ const apiKeyDateFilters = ref({}) const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)]) const accounts = ref({ claude: [], gemini: [] }) +// 标签相关 +const selectedTagFilter = ref('') +const availableTags = ref([]) + // 模态框状态 const showCreateApiKeyModal = ref(false) const showEditApiKeyModal = ref(false) @@ -484,9 +507,19 @@ const newApiKeyData = ref(null) // 计算排序后的API Keys const sortedApiKeys = computed(() => { - if (!apiKeysSortBy.value) return apiKeys.value + // 先进行标签筛选 + let filteredKeys = apiKeys.value + if (selectedTagFilter.value) { + filteredKeys = apiKeys.value.filter(key => + key.tags && key.tags.includes(selectedTagFilter.value) + ) + } - const sorted = [...apiKeys.value].sort((a, b) => { + // 如果没有排序字段,返回筛选后的结果 + if (!apiKeysSortBy.value) return filteredKeys + + // 排序 + const sorted = [...filteredKeys].sort((a, b) => { let aVal = a[apiKeysSortBy.value] let bVal = b[apiKeysSortBy.value] @@ -537,6 +570,15 @@ const loadApiKeys = async () => { const data = await apiClient.get(`/admin/api-keys?timeRange=${apiKeyStatsTimeRange.value}`) if (data.success) { apiKeys.value = data.data || [] + + // 更新可用标签列表 + const tagsSet = new Set() + apiKeys.value.forEach(key => { + if (key.tags && Array.isArray(key.tags)) { + key.tags.forEach(tag => tagsSet.add(tag)) + } + }) + availableTags.value = Array.from(tagsSet).sort() } } catch (error) { showToast('加载 API Keys 失败', 'error')