feat(admin-spa): 添加 API Key 标签管理功能

基于 PR #114 的功能需求,为新版 admin-spa 实现完整的标签系统:

后端改进:
- apiKeyService 支持标签的创建、查询和更新
- admin 路由添加标签验证和处理逻辑
- 标签以 JSON 数组形式存储在 Redis 中

前端功能:
- API Key 列表增加标签列,显示彩色标签徽章
- 添加标签筛选器,支持按标签过滤 API Keys
- 创建和编辑 API Key 时可添加/删除标签
- 标签输入支持 Enter 键快速添加
- 自动收集并排序所有可用标签

界面优化:
- 使用蓝色圆角标签样式,视觉清晰
- 无标签时显示"无标签"提示
- 标签管理操作流畅,支持即时添加删除

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-07-29 16:13:46 +08:00
parent f23b9bd222
commit 03a5300b78
5 changed files with 202 additions and 12 deletions

View File

@@ -29,6 +29,40 @@
>
</div>
<!-- 标签 -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">标签</label>
<div class="space-y-3">
<!-- 已添加的标签 -->
<div v-if="form.tags.length > 0" class="flex flex-wrap gap-2">
<span v-for="(tag, index) in form.tags" :key="index"
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
{{ tag }}
<button type="button" @click="removeTag(index)"
class="ml-1 hover:text-blue-900">
<i class="fas fa-times text-xs"></i>
</button>
</span>
</div>
<!-- 标签输入 -->
<div class="flex gap-2">
<input
v-model="newTag"
type="text"
class="form-input flex-1"
placeholder="输入新标签名称"
@keypress.enter.prevent="addTag"
>
<button type="button" @click="addTag"
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-plus"></i>
</button>
</div>
<p class="text-xs text-gray-500">用于标记不同团队或用途方便筛选管理</p>
</div>
</div>
<!-- 速率限制设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
<div class="flex items-start gap-3 mb-3">
@@ -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
}
// 模型限制

View File

@@ -29,6 +29,40 @@
<p class="text-xs text-gray-500 mt-2">名称不可修改</p>
</div>
<!-- 标签 -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">标签</label>
<div class="space-y-3">
<!-- 已添加的标签 -->
<div v-if="form.tags.length > 0" class="flex flex-wrap gap-2">
<span v-for="(tag, index) in form.tags" :key="index"
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
{{ tag }}
<button type="button" @click="removeTag(index)"
class="ml-1 hover:text-blue-900">
<i class="fas fa-times text-xs"></i>
</button>
</span>
</div>
<!-- 标签输入 -->
<div class="flex gap-2">
<input
v-model="newTag"
type="text"
class="form-input flex-1"
placeholder="输入新标签名称"
@keypress.enter.prevent="addTag"
>
<button type="button" @click="addTag"
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-plus"></i>
</button>
</div>
<p class="text-xs text-gray-500">用于标记不同团队或用途方便筛选管理</p>
</div>
</div>
<!-- 速率限制设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
<div class="flex items-start gap-3 mb-3">
@@ -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
})

View File

@@ -18,6 +18,14 @@
<option value="monthly">本月</option>
<option value="all">全部时间</option>
</select>
<!-- 标签筛选器 -->
<select
v-model="selectedTagFilter"
class="form-input px-3 py-2 text-sm"
>
<option value="">所有标签</option>
<option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option>
</select>
<button
@click.stop="openCreateApiKeyModal"
class="btn btn-primary px-6 py-3 flex items-center gap-2"
@@ -49,6 +57,7 @@
<i v-if="apiKeysSortBy === 'name'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">标签</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">API Key</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('status')">
状态
@@ -101,6 +110,16 @@
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
<span v-for="tag in (key.tags || [])" :key="tag"
class="inline-flex items-center px-2 py-0.5 bg-blue-100 text-blue-800 text-xs rounded-full">
{{ tag }}
</span>
<span v-if="!key.tags || key.tags.length === 0"
class="text-xs text-gray-400">无标签</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-mono text-gray-600 bg-gray-50 px-3 py-1 rounded-lg">
{{ (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')