feat: 增强 API Key 标签选择功能

- 添加获取已存在标签的 API 端点 /admin/api-keys/tags
- 重构标签输入 UI,从下拉菜单改为平铺展示
- 支持点击选择已有标签,避免重复创建
- 增加弹框宽度 (max-w-4xl),改善布局和用户体验
- 统一创建和编辑 API Key 的标签管理体验

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Alfonsxh
2025-07-30 11:24:27 +08:00
parent dae8e8e914
commit fc5c24e1ca
4 changed files with 230 additions and 111 deletions

View File

@@ -303,6 +303,34 @@ router.get('/supported-clients', authenticateAdmin, async (req, res) => {
} }
}); });
// 获取已存在的标签列表
router.get('/api-keys/tags', authenticateAdmin, async (req, res) => {
try {
const apiKeys = await apiKeyService.getAllApiKeys();
const tagSet = new Set();
// 收集所有API Keys的标签
for (const apiKey of apiKeys) {
if (apiKey.tags && Array.isArray(apiKey.tags)) {
apiKey.tags.forEach(tag => {
if (tag && tag.trim()) {
tagSet.add(tag.trim());
}
});
}
}
// 转换为数组并排序
const tags = Array.from(tagSet).sort();
logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`);
res.json({ success: true, data: tags });
} catch (error) {
logger.error('❌ Failed to get API key tags:', error);
res.status(500).json({ error: 'Failed to get API key tags', message: error.message });
}
});
// 创建新的API Key // 创建新的API Key
router.post('/api-keys', authenticateAdmin, async (req, res) => { router.post('/api-keys', authenticateAdmin, async (req, res) => {
try { try {

View File

@@ -1,7 +1,7 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-lg p-6 mx-auto max-h-[90vh] flex flex-col"> <div class="modal-content w-full max-w-4xl p-6 mx-auto max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center"> <div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
@@ -35,10 +35,12 @@
<!-- 标签 --> <!-- 标签 -->
<div> <div>
<label class="block text-sm font-semibold text-gray-700 mb-2">标签</label> <label class="block text-sm font-semibold text-gray-700 mb-2">标签</label>
<div class="space-y-2"> <div class="space-y-4">
<!-- 添加的标签 --> <!-- 选择的标签 -->
<div v-if="form.tags.length > 0" class="flex flex-wrap gap-2"> <div v-if="form.tags.length > 0">
<span v-for="(tag, index) in form.tags" :key="index" <div class="text-xs font-medium text-gray-600 mb-2">已选择的标签:</div>
<div class="flex flex-wrap gap-2">
<span v-for="(tag, index) in form.tags" :key="'selected-' + index"
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full"> class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
{{ tag }} {{ tag }}
<button type="button" @click="removeTag(index)" <button type="button" @click="removeTag(index)"
@@ -47,8 +49,28 @@
</button> </button>
</span> </span>
</div> </div>
</div>
<!-- 标签输入 --> <!-- 可选择的已有标签 -->
<div v-if="unselectedTags.length > 0">
<div class="text-xs font-medium text-gray-600 mb-2">点击选择已有标签:</div>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in unselectedTags"
:key="'available-' + tag"
type="button"
@click="selectTag(tag)"
class="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full hover:bg-blue-100 hover:text-blue-700 transition-colors"
>
<i class="fas fa-tag text-gray-500 text-xs"></i>
{{ tag }}
</button>
</div>
</div>
<!-- 创建新标签 -->
<div>
<div class="text-xs font-medium text-gray-600 mb-2">创建新标签:</div>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="newTag" v-model="newTag"
@@ -58,10 +80,12 @@
@keypress.enter.prevent="addTag" @keypress.enter.prevent="addTag"
> >
<button type="button" @click="addTag" <button type="button" @click="addTag"
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"> class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
</button> </button>
</div> </div>
</div>
<p class="text-xs text-gray-500">用于标记不同团队或用途方便筛选管理</p> <p class="text-xs text-gray-500">用于标记不同团队或用途方便筛选管理</p>
</div> </div>
</div> </div>
@@ -86,7 +110,7 @@
placeholder="无限制" placeholder="无限制"
class="form-input w-full text-sm" class="form-input w-full text-sm"
> >
<p class="text-xs text-gray-500 mt-0.5">时间段单位</p> <p class="text-xs text-gray-500 mt-0.5 ml-2">时间段单位</p>
</div> </div>
<div> <div>
@@ -98,7 +122,7 @@
placeholder="无限制" placeholder="无限制"
class="form-input w-full text-sm" class="form-input w-full text-sm"
> >
<p class="text-xs text-gray-500 mt-0.5">窗口内最大请求</p> <p class="text-xs text-gray-500 mt-0.5 ml-2">窗口内最大请求</p>
</div> </div>
<div> <div>
@@ -109,7 +133,7 @@
placeholder="无限制" placeholder="无限制"
class="form-input w-full text-sm" class="form-input w-full text-sm"
> >
<p class="text-xs text-gray-500 mt-0.5">窗口内最大Token</p> <p class="text-xs text-gray-500 mt-0.5 ml-2">窗口内最大Token</p>
</div> </div>
</div> </div>
@@ -394,6 +418,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useClientsStore } from '@/stores/clients' import { useClientsStore } from '@/stores/clients'
import { useApiKeysStore } from '@/stores/apiKeys'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
const props = defineProps({ const props = defineProps({
@@ -407,6 +432,7 @@ const emit = defineEmits(['close', 'success'])
const authStore = useAuthStore() const authStore = useAuthStore()
const clientsStore = useClientsStore() const clientsStore = useClientsStore()
const apiKeysStore = useApiKeysStore()
const loading = ref(false) const loading = ref(false)
// 表单验证状态 // 表单验证状态
@@ -416,6 +442,12 @@ const errors = ref({
// 标签相关 // 标签相关
const newTag = ref('') const newTag = ref('')
const availableTags = ref([])
// 计算未选择的标签
const unselectedTags = computed(() => {
return availableTags.value.filter(tag => !form.tags.includes(tag))
})
// 支持的客户端列表 // 支持的客户端列表
const supportedClients = ref([]) const supportedClients = ref([])
@@ -443,9 +475,10 @@ const form = reactive({
tags: [] tags: []
}) })
// 加载支持的客户端 // 加载支持的客户端和已存在的标签
onMounted(async () => { onMounted(async () => {
supportedClients.value = await clientsStore.loadSupportedClients() supportedClients.value = await clientsStore.loadSupportedClients()
availableTags.value = await apiKeysStore.fetchTags()
}) })
// 计算最小日期时间 // 计算最小日期时间
@@ -536,6 +569,12 @@ const addTag = () => {
} }
} }
const selectTag = (tag) => {
if (!form.tags.includes(tag)) {
form.tags.push(tag)
}
}
const removeTag = (index) => { const removeTag = (index) => {
form.tags.splice(index, 1) form.tags.splice(index, 1)
} }

View File

@@ -1,7 +1,7 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col"> <div class="modal-content w-full max-w-4xl p-8 mx-auto max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center"> <div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
@@ -32,10 +32,12 @@
<!-- 标签 --> <!-- 标签 -->
<div> <div>
<label class="block text-sm font-semibold text-gray-700 mb-3">标签</label> <label class="block text-sm font-semibold text-gray-700 mb-3">标签</label>
<div class="space-y-3"> <div class="space-y-4">
<!-- 添加的标签 --> <!-- 选择的标签 -->
<div v-if="form.tags.length > 0" class="flex flex-wrap gap-2"> <div v-if="form.tags.length > 0">
<span v-for="(tag, index) in form.tags" :key="index" <div class="text-xs font-medium text-gray-600 mb-2">已选择的标签:</div>
<div class="flex flex-wrap gap-2">
<span v-for="(tag, index) in form.tags" :key="'selected-' + index"
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full"> class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
{{ tag }} {{ tag }}
<button type="button" @click="removeTag(index)" <button type="button" @click="removeTag(index)"
@@ -44,8 +46,28 @@
</button> </button>
</span> </span>
</div> </div>
</div>
<!-- 标签输入 --> <!-- 可选择的已有标签 -->
<div v-if="unselectedTags.length > 0">
<div class="text-xs font-medium text-gray-600 mb-2">点击选择已有标签:</div>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in unselectedTags"
:key="'available-' + tag"
type="button"
@click="selectTag(tag)"
class="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full hover:bg-blue-100 hover:text-blue-700 transition-colors"
>
<i class="fas fa-tag text-gray-500 text-xs"></i>
{{ tag }}
</button>
</div>
</div>
<!-- 创建新标签 -->
<div>
<div class="text-xs font-medium text-gray-600 mb-2">创建新标签:</div>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="newTag" v-model="newTag"
@@ -55,72 +77,71 @@
@keypress.enter.prevent="addTag" @keypress.enter.prevent="addTag"
> >
<button type="button" @click="addTag" <button type="button" @click="addTag"
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"> class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
</button> </button>
</div> </div>
</div>
<p class="text-xs text-gray-500">用于标记不同团队或用途方便筛选管理</p> <p class="text-xs text-gray-500">用于标记不同团队或用途方便筛选管理</p>
</div> </div>
</div> </div>
<!-- 速率限制设置 --> <!-- 速率限制设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4"> <div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-start gap-3 mb-3"> <div class="flex items-center gap-2 mb-2">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0"> <div class="w-6 h-6 bg-blue-500 rounded flex items-center justify-center flex-shrink-0">
<i class="fas fa-tachometer-alt text-white text-sm"></i> <i class="fas fa-tachometer-alt text-white text-xs"></i>
</div>
<div class="flex-1">
<h4 class="font-semibold text-gray-800 mb-1">速率限制设置</h4>
<p class="text-sm text-gray-600">控制 API Key 的使用频率和资源消耗</p>
</div> </div>
<h4 class="font-semibold text-gray-800 text-sm">速率限制设置 (可选)</h4>
</div> </div>
<div class="space-y-2">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-2">
<div> <div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (分钟)</label> <label class="block text-xs font-medium text-gray-700 mb-1">时间窗口 (分钟)</label>
<input <input
v-model="form.rateLimitWindow" v-model="form.rateLimitWindow"
type="number" type="number"
min="1" min="1"
placeholder="留空表示无限制" placeholder="无限制"
class="form-input w-full" class="form-input w-full text-sm"
> >
<p class="text-xs text-gray-500 mt-2">设置一个时间段以分钟为单位用于计算速率限制</p> <p class="text-xs text-gray-500 mt-0.5 ml-2">时间段单位</p>
</div> </div>
<div> <div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的请求次数限制</label> <label class="block text-xs font-medium text-gray-700 mb-1">请求次数限制</label>
<input <input
v-model="form.rateLimitRequests" v-model="form.rateLimitRequests"
type="number" type="number"
min="1" min="1"
placeholder="留空表示无限制" placeholder="无限制"
class="form-input w-full" class="form-input w-full text-sm"
> >
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数需要先设置时间窗口</p> <p class="text-xs text-gray-500 mt-0.5 ml-2">窗口内最大请求</p>
</div> </div>
<div> <div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的 Token 使用量限制</label> <label class="block text-xs font-medium text-gray-700 mb-1">Token 限制</label>
<input <input
v-model="form.tokenLimit" v-model="form.tokenLimit"
type="number" type="number"
min="0" placeholder="无限制"
placeholder="0 表示无限制" class="form-input w-full text-sm"
class="form-input w-full"
> >
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许消耗的最大 Token 数量需要先设置时间窗口0 或留空表示无限制</p> <p class="text-xs text-gray-500 mt-0.5 ml-2">窗口内最大Token</p>
</div>
</div> </div>
<!-- 示例说明 --> <!-- 示例说明 -->
<div class="bg-blue-100 rounded-lg p-3 mt-3"> <div class="bg-blue-100 rounded-lg p-2">
<h5 class="text-sm font-semibold text-blue-800 mb-2">💡 使用示例</h5> <h5 class="text-xs font-semibold text-blue-800 mb-1">💡 使用示例</h5>
<div class="text-xs text-blue-700 space-y-1"> <div class="text-xs text-blue-700 space-y-0.5">
<p><strong>示例1:</strong> 时间窗口=60请求次数限制=100</p> <div><strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求</div>
<p class="ml-4"> 60分钟最多允许100次请求</p> <div><strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token</div>
<p class="mt-2"><strong>示例2:</strong> 时间窗口=10Token限制=50000</p> <div><strong>示例3:</strong> 窗口=30请求=50Token=100000 每30分钟50次请求且不超10万Token</div>
<p class="ml-4"> 每10分钟内最多消耗50,000个Token</p> </div>
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30请求次数限制=50Token限制=100000</p>
<p class="ml-4"> 每30分钟内最多50次请求且总Token不超过100,000</p>
</div> </div>
</div> </div>
</div> </div>
@@ -351,10 +372,11 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useClientsStore } from '@/stores/clients' import { useClientsStore } from '@/stores/clients'
import { useApiKeysStore } from '@/stores/apiKeys'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
const props = defineProps({ const props = defineProps({
@@ -372,6 +394,7 @@ const emit = defineEmits(['close', 'success'])
const authStore = useAuthStore() const authStore = useAuthStore()
const clientsStore = useClientsStore() const clientsStore = useClientsStore()
const apiKeysStore = useApiKeysStore()
const loading = ref(false) const loading = ref(false)
// 支持的客户端列表 // 支持的客户端列表
@@ -379,6 +402,12 @@ const supportedClients = ref([])
// 标签相关 // 标签相关
const newTag = ref('') const newTag = ref('')
const availableTags = ref([])
// 计算未选择的标签
const unselectedTags = computed(() => {
return availableTags.value.filter(tag => !form.tags.includes(tag))
})
// 表单数据 // 表单数据
const form = reactive({ const form = reactive({
@@ -424,6 +453,12 @@ const addTag = () => {
} }
} }
const selectTag = (tag) => {
if (!form.tags.includes(tag)) {
form.tags.push(tag)
}
}
const removeTag = (index) => { const removeTag = (index) => {
form.tags.splice(index, 1) form.tags.splice(index, 1)
} }
@@ -471,8 +506,9 @@ const updateApiKey = async () => {
// 初始化表单数据 // 初始化表单数据
onMounted(async () => { onMounted(async () => {
// 加载支持的客户端 // 加载支持的客户端和已存在的标签
supportedClients.value = await clientsStore.loadSupportedClients() supportedClients.value = await clientsStore.loadSupportedClients()
availableTags.value = await apiKeysStore.fetchTags()
form.name = props.apiKey.name form.name = props.apiKey.name
form.tokenLimit = props.apiKey.tokenLimit || '' form.tokenLimit = props.apiKey.tokenLimit || ''

View File

@@ -159,6 +159,21 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
} }
} }
// 获取已存在的标签
const fetchTags = async () => {
try {
const response = await apiClient.get('/admin/api-keys/tags')
if (response.success) {
return response.data || []
} else {
throw new Error(response.message || '获取标签失败')
}
} catch (err) {
console.error('获取标签失败:', err)
return []
}
}
// 重置store // 重置store
const reset = () => { const reset = () => {
apiKeys.value = [] apiKeys.value = []
@@ -186,6 +201,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
renewApiKey, renewApiKey,
deleteApiKey, deleteApiKey,
fetchApiKeyStats, fetchApiKeyStats,
fetchTags,
sortApiKeys, sortApiKeys,
reset reset
} }