mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:10:52 +00:00
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:
@@ -320,7 +320,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
restrictedModels,
|
restrictedModels,
|
||||||
enableClientRestriction,
|
enableClientRestriction,
|
||||||
allowedClients,
|
allowedClients,
|
||||||
dailyCostLimit
|
dailyCostLimit,
|
||||||
|
tags
|
||||||
} = req.body;
|
} = 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' });
|
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({
|
const newKey = await apiKeyService.generateApiKey({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -386,7 +396,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
restrictedModels,
|
restrictedModels,
|
||||||
enableClientRestriction,
|
enableClientRestriction,
|
||||||
allowedClients,
|
allowedClients,
|
||||||
dailyCostLimit
|
dailyCostLimit,
|
||||||
|
tags
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.success(`🔑 Admin created new API key: ${name}`);
|
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) => {
|
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { keyId } = req.params;
|
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 = {};
|
const updates = {};
|
||||||
@@ -506,6 +517,17 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
updates.dailyCostLimit = costLimit;
|
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);
|
await apiKeyService.updateApiKey(keyId, updates);
|
||||||
|
|
||||||
logger.success(`📝 Admin updated API key: ${keyId}`);
|
logger.success(`📝 Admin updated API key: ${keyId}`);
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ class ApiKeyService {
|
|||||||
restrictedModels = [],
|
restrictedModels = [],
|
||||||
enableClientRestriction = false,
|
enableClientRestriction = false,
|
||||||
allowedClients = [],
|
allowedClients = [],
|
||||||
dailyCostLimit = 0
|
dailyCostLimit = 0,
|
||||||
|
tags = []
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// 生成简单的API Key (64字符十六进制)
|
// 生成简单的API Key (64字符十六进制)
|
||||||
@@ -53,6 +54,7 @@ class ApiKeyService {
|
|||||||
enableClientRestriction: String(enableClientRestriction || false),
|
enableClientRestriction: String(enableClientRestriction || false),
|
||||||
allowedClients: JSON.stringify(allowedClients || []),
|
allowedClients: JSON.stringify(allowedClients || []),
|
||||||
dailyCostLimit: String(dailyCostLimit || 0),
|
dailyCostLimit: String(dailyCostLimit || 0),
|
||||||
|
tags: JSON.stringify(tags || []),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
lastUsedAt: '',
|
lastUsedAt: '',
|
||||||
expiresAt: expiresAt || '',
|
expiresAt: expiresAt || '',
|
||||||
@@ -82,6 +84,7 @@ class ApiKeyService {
|
|||||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||||
allowedClients: JSON.parse(keyData.allowedClients || '[]'),
|
allowedClients: JSON.parse(keyData.allowedClients || '[]'),
|
||||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||||
|
tags: JSON.parse(keyData.tags || '[]'),
|
||||||
createdAt: keyData.createdAt,
|
createdAt: keyData.createdAt,
|
||||||
expiresAt: keyData.expiresAt,
|
expiresAt: keyData.expiresAt,
|
||||||
createdBy: keyData.createdBy
|
createdBy: keyData.createdBy
|
||||||
@@ -142,6 +145,14 @@ class ApiKeyService {
|
|||||||
allowedClients = [];
|
allowedClients = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析标签
|
||||||
|
let tags = [];
|
||||||
|
try {
|
||||||
|
tags = keyData.tags ? JSON.parse(keyData.tags) : [];
|
||||||
|
} catch (e) {
|
||||||
|
tags = [];
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
keyData: {
|
keyData: {
|
||||||
@@ -163,6 +174,7 @@ class ApiKeyService {
|
|||||||
allowedClients: allowedClients,
|
allowedClients: allowedClients,
|
||||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||||
dailyCost: dailyCost || 0,
|
dailyCost: dailyCost || 0,
|
||||||
|
tags: tags,
|
||||||
usage
|
usage
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -201,6 +213,11 @@ class ApiKeyService {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
key.allowedClients = [];
|
key.allowedClients = [];
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
key.tags = key.tags ? JSON.parse(key.tags) : [];
|
||||||
|
} catch (e) {
|
||||||
|
key.tags = [];
|
||||||
|
}
|
||||||
delete key.apiKey; // 不返回哈希后的key
|
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 };
|
const updatedData = { ...keyData };
|
||||||
|
|
||||||
for (const [field, value] of Object.entries(updates)) {
|
for (const [field, value] of Object.entries(updates)) {
|
||||||
if (allowedUpdates.includes(field)) {
|
if (allowedUpdates.includes(field)) {
|
||||||
if (field === 'restrictedModels' || field === 'allowedClients') {
|
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
|
||||||
// 特殊处理数组字段
|
// 特殊处理数组字段
|
||||||
updatedData[field] = JSON.stringify(value || []);
|
updatedData[field] = JSON.stringify(value || []);
|
||||||
} else if (field === 'enableModelRestriction' || field === 'enableClientRestriction') {
|
} else if (field === 'enableModelRestriction' || field === 'enableClientRestriction') {
|
||||||
|
|||||||
@@ -29,6 +29,40 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</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="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
|
||||||
<div class="flex items-start gap-3 mb-3">
|
<div class="flex items-start gap-3 mb-3">
|
||||||
@@ -375,6 +409,9 @@ const authStore = useAuthStore()
|
|||||||
const clientsStore = useClientsStore()
|
const clientsStore = useClientsStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 标签相关
|
||||||
|
const newTag = ref('')
|
||||||
|
|
||||||
// 支持的客户端列表
|
// 支持的客户端列表
|
||||||
const supportedClients = ref([])
|
const supportedClients = ref([])
|
||||||
|
|
||||||
@@ -397,7 +434,8 @@ const form = reactive({
|
|||||||
restrictedModels: [],
|
restrictedModels: [],
|
||||||
modelInput: '',
|
modelInput: '',
|
||||||
enableClientRestriction: false,
|
enableClientRestriction: false,
|
||||||
allowedClients: []
|
allowedClients: [],
|
||||||
|
tags: []
|
||||||
})
|
})
|
||||||
|
|
||||||
// 加载支持的客户端
|
// 加载支持的客户端
|
||||||
@@ -482,6 +520,21 @@ const removeRestrictedModel = (index) => {
|
|||||||
form.restrictedModels.splice(index, 1)
|
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
|
// 创建 API Key
|
||||||
const createApiKey = async () => {
|
const createApiKey = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -499,7 +552,8 @@ const createApiKey = async () => {
|
|||||||
expiresAt: form.expiresAt || undefined,
|
expiresAt: form.expiresAt || undefined,
|
||||||
permissions: form.permissions,
|
permissions: form.permissions,
|
||||||
claudeAccountId: form.claudeAccountId || undefined,
|
claudeAccountId: form.claudeAccountId || undefined,
|
||||||
geminiAccountId: form.geminiAccountId || undefined
|
geminiAccountId: form.geminiAccountId || undefined,
|
||||||
|
tags: form.tags.length > 0 ? form.tags : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模型限制
|
// 模型限制
|
||||||
|
|||||||
@@ -29,6 +29,40 @@
|
|||||||
<p class="text-xs text-gray-500 mt-2">名称不可修改</p>
|
<p class="text-xs text-gray-500 mt-2">名称不可修改</p>
|
||||||
</div>
|
</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="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
|
||||||
<div class="flex items-start gap-3 mb-3">
|
<div class="flex items-start gap-3 mb-3">
|
||||||
@@ -343,6 +377,9 @@ const loading = ref(false)
|
|||||||
// 支持的客户端列表
|
// 支持的客户端列表
|
||||||
const supportedClients = ref([])
|
const supportedClients = ref([])
|
||||||
|
|
||||||
|
// 标签相关
|
||||||
|
const newTag = ref('')
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -358,7 +395,8 @@ const form = reactive({
|
|||||||
restrictedModels: [],
|
restrictedModels: [],
|
||||||
modelInput: '',
|
modelInput: '',
|
||||||
enableClientRestriction: false,
|
enableClientRestriction: false,
|
||||||
allowedClients: []
|
allowedClients: [],
|
||||||
|
tags: []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -375,6 +413,21 @@ const removeRestrictedModel = (index) => {
|
|||||||
form.restrictedModels.splice(index, 1)
|
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
|
// 更新 API Key
|
||||||
const updateApiKey = async () => {
|
const updateApiKey = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -389,7 +442,8 @@ const updateApiKey = async () => {
|
|||||||
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
|
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
|
||||||
permissions: form.permissions,
|
permissions: form.permissions,
|
||||||
claudeAccountId: form.claudeAccountId || null,
|
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.geminiAccountId = props.apiKey.geminiAccountId || ''
|
||||||
form.restrictedModels = props.apiKey.restrictedModels || []
|
form.restrictedModels = props.apiKey.restrictedModels || []
|
||||||
form.allowedClients = props.apiKey.allowedClients || []
|
form.allowedClients = props.apiKey.allowedClients || []
|
||||||
|
form.tags = props.apiKey.tags || []
|
||||||
form.enableModelRestriction = form.restrictedModels.length > 0
|
form.enableModelRestriction = form.restrictedModels.length > 0
|
||||||
form.enableClientRestriction = form.allowedClients.length > 0
|
form.enableClientRestriction = form.allowedClients.length > 0
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,14 @@
|
|||||||
<option value="monthly">本月</option>
|
<option value="monthly">本月</option>
|
||||||
<option value="all">全部时间</option>
|
<option value="all">全部时间</option>
|
||||||
</select>
|
</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
|
<button
|
||||||
@click.stop="openCreateApiKeyModal"
|
@click.stop="openCreateApiKeyModal"
|
||||||
class="btn btn-primary px-6 py-3 flex items-center gap-2"
|
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-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>
|
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||||
</th>
|
</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">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')">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">
|
<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">
|
<div class="text-sm font-mono text-gray-600 bg-gray-50 px-3 py-1 rounded-lg">
|
||||||
{{ (key.apiKey || '').substring(0, 20) }}...
|
{{ (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 defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
|
||||||
const accounts = ref({ claude: [], gemini: [] })
|
const accounts = ref({ claude: [], gemini: [] })
|
||||||
|
|
||||||
|
// 标签相关
|
||||||
|
const selectedTagFilter = ref('')
|
||||||
|
const availableTags = ref([])
|
||||||
|
|
||||||
// 模态框状态
|
// 模态框状态
|
||||||
const showCreateApiKeyModal = ref(false)
|
const showCreateApiKeyModal = ref(false)
|
||||||
const showEditApiKeyModal = ref(false)
|
const showEditApiKeyModal = ref(false)
|
||||||
@@ -484,9 +507,19 @@ const newApiKeyData = ref(null)
|
|||||||
|
|
||||||
// 计算排序后的API Keys
|
// 计算排序后的API Keys
|
||||||
const sortedApiKeys = computed(() => {
|
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 aVal = a[apiKeysSortBy.value]
|
||||||
let bVal = b[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}`)
|
const data = await apiClient.get(`/admin/api-keys?timeRange=${apiKeyStatsTimeRange.value}`)
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
apiKeys.value = data.data || []
|
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) {
|
} catch (error) {
|
||||||
showToast('加载 API Keys 失败', 'error')
|
showToast('加载 API Keys 失败', 'error')
|
||||||
|
|||||||
Reference in New Issue
Block a user