feat: API Keys图标系统和UI优化

主要功能增强:
- 实现API Key自定义图标功能,支持图片上传、裁剪和智能压缩
- 新增IconPicker组件,提供内置图标选择和图片上传功能
- 支持固定尺寸裁剪区域,可拖拽定位选择头像区域
- 智能图片压缩:PNG保留透明度,JPEG用于不透明图片

UI/UX改进:
- 优化表格布局:移除账号列,在名称下方显示账号绑定信息
- 调整行高和字体大小,提升信息密度
- 最后使用时间改为相对时间显示,悬浮显示具体时间
- 过期时间编辑改为点击文本触发,带悬浮下划线效果
- 更新默认API Key图标为蓝色渐变设计
- 修复表格悬浮偏移和横向滚动条问题
- 将"TOKEN 数量"改为"Token数"

后端支持:
- apiKeyService增加icon字段持久化
- admin路由增加图标数据处理和验证

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Edric Li
2025-09-07 11:41:22 +08:00
parent fc5c60a9b4
commit 8c9d6381f3
6 changed files with 1313 additions and 207 deletions

View File

@@ -534,7 +534,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
weeklyOpusCostLimit,
tags,
activationDays, // 新增:激活后有效天数
expirationMode // 新增:过期模式
expirationMode, // 新增:过期模式
icon // 新增图标base64编码
} = req.body
// 输入验证
@@ -660,7 +661,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
weeklyOpusCostLimit,
tags,
activationDays,
expirationMode
expirationMode,
icon
})
logger.success(`🔑 Admin created new API key: ${name}`)
@@ -696,7 +698,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
weeklyOpusCostLimit,
tags,
activationDays,
expirationMode
expirationMode,
icon
} = req.body
// 输入验证
@@ -742,7 +745,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
weeklyOpusCostLimit,
tags,
activationDays,
expirationMode
expirationMode,
icon
})
// 保留原始 API Key 供返回
@@ -985,7 +989,8 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
dailyCostLimit,
weeklyOpusCostLimit,
tags,
ownerId // 新增所有者ID字段
ownerId, // 新增所有者ID字段
icon // 新增图标base64编码
} = req.body
// 只允许更新指定字段
@@ -1159,6 +1164,19 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
updates.tags = tags
}
// 处理图标
if (icon !== undefined) {
// icon 可以是空字符串(清除图标)或 base64 编码的字符串
if (icon !== '' && typeof icon !== 'string') {
return res.status(400).json({ error: 'Icon must be a string' })
}
// 简单验证 base64 格式(如果不为空)
if (icon && !icon.startsWith('data:image/')) {
return res.status(400).json({ error: 'Icon must be a valid base64 image' })
}
updates.icon = icon
}
// 处理活跃/禁用状态状态, 放在过期处理后以确保后续增加禁用key功能
if (isActive !== undefined) {
if (typeof isActive !== 'boolean') {

View File

@@ -36,7 +36,8 @@ class ApiKeyService {
weeklyOpusCostLimit = 0,
tags = [],
activationDays = 0, // 新增激活后有效天数0表示不使用此功能
expirationMode = 'fixed' // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
icon = '' // 新增图标base64编码
} = options
// 生成简单的API Key (64字符十六进制)
@@ -78,7 +79,8 @@ class ApiKeyService {
expiresAt: expirationMode === 'fixed' ? expiresAt || '' : '', // 固定模式才设置过期时间
createdBy: options.createdBy || 'admin',
userId: options.userId || '',
userUsername: options.userUsername || ''
userUsername: options.userUsername || '',
icon: icon || '' // 新增图标base64编码
}
// 保存API Key数据并建立哈希映射
@@ -410,7 +412,8 @@ class ApiKeyService {
'tags',
'userId', // 新增用户ID所有者变更
'userUsername', // 新增:用户名(所有者变更)
'createdBy' // 新增:创建者(所有者变更)
'createdBy', // 新增:创建者(所有者变更)
'icon' // 新增图标base64编码
]
const updatedData = { ...keyData }

View File

@@ -110,19 +110,23 @@
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm"
>名称 <span class="text-red-500">*</span></label
>
<input
v-model="form.name"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.name }"
:placeholder="
form.createType === 'batch'
? '输入基础名称(将自动添加序号)'
: '为您的 API Key 取一个名称'
"
required
type="text"
@input="errors.name = ''"
/>
<div class="flex items-center gap-2">
<!-- 图标选择器 -->
<IconPicker v-model="form.icon" size="medium" />
<input
v-model="form.name"
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.name }"
:placeholder="
form.createType === 'batch'
? '输入基础名称(将自动添加序号)'
: '为您的 API Key 取一个名称'
"
required
type="text"
@input="errors.name = ''"
/>
</div>
<p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.name }}
</p>
@@ -807,6 +811,7 @@ import { useClientsStore } from '@/stores/clients'
import { useApiKeysStore } from '@/stores/apiKeys'
import { apiClient } from '@/config/api'
import AccountSelector from '@/components/common/AccountSelector.vue'
import IconPicker from '@/components/common/IconPicker.vue'
const props = defineProps({
accounts: {
@@ -853,6 +858,7 @@ const form = reactive({
createType: 'single',
batchCount: 10,
name: '',
icon: '',
description: '',
rateLimitWindow: '',
rateLimitRequests: '',
@@ -1198,7 +1204,8 @@ const createApiKey = async () => {
// 单个创建
const data = {
...baseData,
name: form.name
name: form.name,
icon: form.icon || ''
}
const result = await apiClient.post('/admin/api-keys', data)
@@ -1216,7 +1223,8 @@ const createApiKey = async () => {
...baseData,
createType: 'batch',
baseName: form.name,
count: form.batchCount
count: form.batchCount,
icon: form.icon || ''
}
const result = await apiClient.post('/admin/api-keys/batch', data)

View File

@@ -32,14 +32,18 @@
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
>名称</label
>
<input
v-model="form.name"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
maxlength="100"
placeholder="请输入API Key名称"
required
type="text"
/>
<div class="flex items-center gap-2">
<!-- 图标选择器 -->
<IconPicker v-model="form.icon" size="medium" />
<input
v-model="form.name"
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
maxlength="100"
placeholder="请输入API Key名称"
required
type="text"
/>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
用于识别此 API Key 的用途
</p>
@@ -658,6 +662,7 @@ import { useClientsStore } from '@/stores/clients'
import { useApiKeysStore } from '@/stores/apiKeys'
import { apiClient } from '@/config/api'
import AccountSelector from '@/components/common/AccountSelector.vue'
import IconPicker from '@/components/common/IconPicker.vue'
const props = defineProps({
apiKey: {
@@ -705,6 +710,7 @@ const unselectedTags = computed(() => {
// 表单数据
const form = reactive({
name: '',
icon: '',
tokenLimit: '', // 保留用于检测历史数据
rateLimitWindow: '',
rateLimitRequests: '',
@@ -803,6 +809,7 @@ const updateApiKey = async () => {
// 准备提交的数据
const data = {
name: form.name, // 添加名称字段
icon: form.icon || '', // 添加图标字段
tokenLimit: 0, // 清除历史token限制
rateLimitWindow:
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
@@ -1035,6 +1042,7 @@ onMounted(async () => {
}
form.name = props.apiKey.name
form.icon = props.apiKey.icon || ''
// 处理速率限制迁移如果有tokenLimit且没有rateLimitCost提示用户
form.tokenLimit = props.apiKey.tokenLimit || ''

File diff suppressed because it is too large Load Diff

View File

@@ -125,7 +125,7 @@
<div class="relative flex items-center">
<input
v-model="searchKeyword"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 pl-9 text-sm text-gray-700 placeholder-gray-400 shadow-sm transition-all duration-200 hover:border-gray-300 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:placeholder-gray-500 dark:hover:border-gray-500"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-1.5 pl-9 text-sm text-gray-700 placeholder-gray-400 shadow-sm transition-all duration-200 hover:border-gray-300 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:placeholder-gray-500 dark:hover:border-gray-500"
:placeholder="isLdapEnabled ? '搜索名称或所有者...' : '搜索名称...'"
type="text"
@input="currentPage = 1"
@@ -209,7 +209,7 @@
<!-- 创建按钮 - 独立在右侧 -->
<button
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-blue-500 to-blue-600 px-5 py-2.5 text-sm font-medium text-white shadow-md transition-all duration-200 hover:from-blue-600 hover:to-blue-700 hover:shadow-lg sm:w-auto"
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-blue-500 to-blue-600 px-5 py-2 text-sm font-medium text-white shadow-md transition-all duration-200 hover:from-blue-600 hover:to-blue-700 hover:shadow-lg sm:w-auto"
@click.stop="openCreateApiKeyModal"
>
<i class="fas fa-plus"></i>
@@ -250,7 +250,7 @@
</div>
</th>
<th
class="w-[20%] min-w-[160px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
class="w-[18%] min-w-[140px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortApiKeys('name')"
>
名称
@@ -264,11 +264,6 @@
/>
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</th>
<th
class="w-[10%] min-w-[90px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
账号
</th>
<th
class="w-[10%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
@@ -323,7 +318,7 @@
class="w-[7%] min-w-[70px] cursor-pointer px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortApiKeys('periodTokens')"
>
Token数
Token数
<i
v-if="apiKeysSortBy === 'periodTokens'"
:class="[
@@ -390,7 +385,7 @@
<template v-for="key in paginatedApiKeys" :key="key.id">
<!-- API Key 主行 -->
<tr class="table-row">
<td v-if="shouldShowCheckboxes" class="px-3 py-2.5">
<td v-if="shouldShowCheckboxes" class="px-3 py-1.5">
<div class="flex items-center">
<input
v-model="selectedApiKeys"
@@ -401,16 +396,149 @@
/>
</div>
</td>
<td class="px-3 py-2.5">
<td class="px-3 py-1.5">
<div class="min-w-0">
<div
class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"
:title="key.name"
>
{{ key.name }}
<div class="flex items-start gap-2.5">
<!-- API Key 图标 -->
<IconPicker
v-model="key.icon"
class="mt-0.5"
size="small"
@update:model-value="(val) => updateApiKeyIcon(key.id, val)"
/>
<div class="min-w-0 flex-1">
<!-- 名称 -->
<div
class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"
:title="key.name"
>
{{ key.name }}
</div>
<!-- 次要信息显示:所属账号 -->
<div class="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
<!-- Claude OAuth 账号 -->
<span
v-if="
key.claudeAccountId && !key.claudeAccountId.startsWith('group:')
"
class="inline-flex items-center gap-1"
>
<i class="fas fa-robot text-[9px] text-blue-500" />
<span>{{
getClaudeBindingInfo(key)
.replace(/^🔒\s*专属-/, '')
.replace(/^⚠️\s*/, '')
}}</span>
</span>
<!-- Claude Console 账号 -->
<span
v-else-if="key.claudeConsoleAccountId"
class="inline-flex items-center gap-1"
>
<i class="fas fa-terminal text-[9px] text-purple-500" />
<span>{{
getClaudeBindingInfo(key)
.replace(/^🔒\s*专属-/, '')
.replace(/^⚠️\s*/, '')
}}</span>
</span>
<!-- Claude 分组 -->
<span
v-else-if="
key.claudeAccountId && key.claudeAccountId.startsWith('group:')
"
class="inline-flex items-center gap-1"
>
<i class="fas fa-layer-group text-[9px] text-blue-500" />
<span>{{ getClaudeBindingInfo(key) }}</span>
</span>
<!-- Gemini 账号 -->
<span
v-else-if="
key.geminiAccountId && !key.geminiAccountId.startsWith('group:')
"
class="inline-flex items-center gap-1"
>
<i class="fas fa-gem text-[9px] text-green-500" />
<span>{{
getGeminiBindingInfo(key)
.replace(/^🔒\s*专属-/, '')
.replace(/^⚠️\s*/, '')
}}</span>
</span>
<!-- Gemini 分组 -->
<span
v-else-if="
key.geminiAccountId && key.geminiAccountId.startsWith('group:')
"
class="inline-flex items-center gap-1"
>
<i class="fas fa-layer-group text-[9px] text-green-500" />
<span>{{ getGeminiBindingInfo(key) }}</span>
</span>
<!-- OpenAI 账号 -->
<span
v-else-if="
key.openaiAccountId && !key.openaiAccountId.startsWith('group:')
"
class="inline-flex items-center gap-1"
>
<i class="fas fa-brain text-[9px] text-orange-500" />
<span>{{
getOpenAIBindingInfo(key)
.replace(/^🔒\s*专属-/, '')
.replace(/^⚠️\s*/, '')
}}</span>
</span>
<!-- OpenAI 分组 -->
<span
v-else-if="
key.openaiAccountId && key.openaiAccountId.startsWith('group:')
"
class="inline-flex items-center gap-1"
>
<i class="fas fa-layer-group text-[9px] text-orange-500" />
<span>{{ getOpenAIBindingInfo(key) }}</span>
</span>
<!-- Bedrock 账号 -->
<span
v-else-if="
key.bedrockAccountId &&
!key.bedrockAccountId.startsWith('group:')
"
class="inline-flex items-center gap-1"
>
<i class="fas fa-cube text-[9px] text-indigo-500" />
<span>{{
getBedrockBindingInfo(key)
.replace(/^🔒\s*专属-/, '')
.replace(/^⚠️\s*/, '')
}}</span>
</span>
<!-- Bedrock 分组 -->
<span
v-else-if="
key.bedrockAccountId &&
key.bedrockAccountId.startsWith('group:')
"
class="inline-flex items-center gap-1"
>
<i class="fas fa-layer-group text-[9px] text-indigo-500" />
<span>{{ getBedrockBindingInfo(key) }}</span>
</span>
<!-- 共享池 -->
<span
v-else
class="inline-flex items-center gap-1 text-gray-400 dark:text-gray-500"
>
<i class="fas fa-share-alt text-[9px]" />
<span>共享池</span>
</span>
</div>
</div>
</div>
<!-- 账户绑定信息 -->
<div class="mt-1.5 space-y-1">
<div class="mt-1.5 space-y-1 pl-12">
<!-- Claude 绑定 -->
<div
v-if="key.claudeAccountId || key.claudeConsoleAccountId"
@@ -469,65 +597,14 @@
<!-- 显示所有者信息 -->
<div
v-if="isLdapEnabled && key.ownerDisplayName"
class="mt-1 text-xs text-red-600"
class="mt-1 pl-12 text-xs text-red-600"
>
<i class="fas fa-user mr-1" />
{{ key.ownerDisplayName }}
</div>
</div>
</td>
<!-- 账号 -->
<td class="whitespace-nowrap px-3 py-2.5 text-sm">
<div class="text-gray-700 dark:text-gray-300">
<!-- Claude 账号 -->
<span v-if="key.claudeAccountId" class="inline-flex items-center">
<i class="fas fa-robot mr-1 text-xs text-blue-500" />
<span class="text-xs">{{
getClaudeAccountName(key.claudeAccountId)
}}</span>
</span>
<!-- Claude Console 账号 -->
<span
v-else-if="key.claudeConsoleAccountId"
class="inline-flex items-center"
>
<i class="fas fa-terminal mr-1 text-xs text-purple-500" />
<span class="text-xs">{{
getClaudeConsoleAccountName(key.claudeConsoleAccountId)
}}</span>
</span>
<!-- Gemini 账号 -->
<span v-else-if="key.geminiAccountId" class="inline-flex items-center">
<i class="fas fa-gem mr-1 text-xs text-green-500" />
<span class="text-xs">{{
getGeminiAccountName(key.geminiAccountId)
}}</span>
</span>
<!-- OpenAI 账号 -->
<span v-else-if="key.openaiAccountId" class="inline-flex items-center">
<i class="fas fa-brain mr-1 text-xs text-orange-500" />
<span class="text-xs">{{
getOpenAIAccountName(key.openaiAccountId)
}}</span>
</span>
<!-- Bedrock 账号 -->
<span v-else-if="key.bedrockAccountId" class="inline-flex items-center">
<i class="fas fa-layer-group mr-1 text-xs text-indigo-500" />
<span class="text-xs">{{
getBedrockAccountName(key.bedrockAccountId)
}}</span>
</span>
<!-- 共享池 -->
<span
v-else
class="inline-flex items-center text-gray-500 dark:text-gray-400"
>
<i class="fas fa-share-alt mr-1 text-xs" />
<span class="text-xs">共享池</span>
</span>
</div>
</td>
<td class="px-3 py-2.5">
<td class="px-3 py-1.5">
<div class="flex flex-wrap gap-1">
<span
v-for="tag in key.tags || []"
@@ -543,7 +620,7 @@
>
</div>
</td>
<td class="whitespace-nowrap px-3 py-2.5">
<td class="whitespace-nowrap px-3 py-1.5">
<span
:class="[
'inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold',
@@ -562,7 +639,7 @@
</span>
</td>
<!-- 请求数 -->
<td class="whitespace-nowrap px-3 py-2.5 text-right text-sm">
<td class="whitespace-nowrap px-3 py-1.5 text-right text-sm">
<div class="flex items-center justify-end gap-1">
<span class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(getPeriodRequests(key)) }}
@@ -571,7 +648,7 @@
</div>
</td>
<!-- 费用 -->
<td class="whitespace-nowrap px-3 py-2.5 text-right text-sm">
<td class="whitespace-nowrap px-3 py-1.5 text-right text-sm">
<div class="space-y-2">
<span class="font-medium text-blue-600 dark:text-blue-400">
${{ getPeriodCost(key).toFixed(4) }}
@@ -634,7 +711,7 @@
</div>
</td>
<!-- Token数量 -->
<td class="whitespace-nowrap px-3 py-2.5 text-right text-sm">
<td class="whitespace-nowrap px-3 py-1.5 text-right text-sm">
<div class="flex items-center justify-end gap-1">
<span class="font-medium text-purple-600 dark:text-purple-400">
{{ formatTokenCount(getPeriodTokens(key)) }}
@@ -643,17 +720,24 @@
</td>
<!-- 最后使用 -->
<td
class="whitespace-nowrap px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300"
class="whitespace-nowrap px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300"
>
{{ formatLastUsed(key.lastUsedAt) }}
<span
v-if="key.lastUsedAt"
class="cursor-help"
:title="new Date(key.lastUsedAt).toLocaleString('zh-CN')"
>
{{ formatLastUsed(key.lastUsedAt) }}
</span>
<span v-else class="text-gray-400">从未使用</span>
</td>
<!-- 创建时间 -->
<td
class="whitespace-nowrap px-3 py-2.5 text-sm text-gray-500 dark:text-gray-400"
class="whitespace-nowrap px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400"
>
{{ new Date(key.createdAt).toLocaleDateString() }}
</td>
<td class="whitespace-nowrap px-3 py-2.5 text-sm">
<td class="whitespace-nowrap px-3 py-1.5 text-sm">
<div class="inline-flex items-center gap-1.5">
<!-- 未激活状态 -->
<span
@@ -667,52 +751,40 @@
<span v-else-if="key.expiresAt">
<span
v-if="isApiKeyExpired(key.expiresAt)"
class="inline-flex items-center text-red-600"
class="inline-flex cursor-pointer items-center text-red-600 hover:underline"
@click.stop="startEditExpiry(key)"
>
<i class="fas fa-exclamation-circle mr-1" />
已过期
</span>
<span
v-else-if="isApiKeyExpiringSoon(key.expiresAt)"
class="inline-flex items-center text-orange-600"
class="inline-flex cursor-pointer items-center text-orange-600 hover:underline"
@click.stop="startEditExpiry(key)"
>
<i class="fas fa-clock mr-1" />
{{ formatExpireDate(key.expiresAt) }}
</span>
<span v-else class="text-gray-600 dark:text-gray-400">
<span
v-else
class="cursor-pointer text-gray-600 hover:underline dark:text-gray-400"
@click.stop="startEditExpiry(key)"
>
{{ formatExpireDate(key.expiresAt) }}
</span>
</span>
<!-- 永不过期 -->
<span
v-else
class="inline-flex items-center text-gray-400 dark:text-gray-500"
class="inline-flex cursor-pointer items-center text-gray-400 hover:underline dark:text-gray-500"
@click.stop="startEditExpiry(key)"
>
<i class="fas fa-infinity mr-1" />
永不过期
</span>
<button
class="inline-flex h-6 w-6 items-center justify-center rounded-md text-gray-300 transition-all duration-200 hover:bg-blue-50 hover:text-blue-500 dark:hover:bg-blue-900/20"
title="编辑过期时间"
@click.stop="startEditExpiry(key)"
>
<svg
class="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
></path>
</svg>
</button>
</div>
</td>
<td class="whitespace-nowrap px-3 py-2.5 text-sm">
<td class="whitespace-nowrap px-3 py-1.5 text-sm">
<div class="flex gap-1">
<button
class="rounded px-2 py-1 text-xs font-medium text-purple-600 transition-colors hover:bg-purple-50 hover:text-purple-900 dark:hover:bg-purple-900/20"
@@ -1065,11 +1137,12 @@
:value="key.id"
@change="updateSelectAllState"
/>
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600"
>
<i class="fas fa-key text-sm text-white" />
</div>
<!-- API Key 图标 -->
<IconPicker
v-model="key.icon"
size="medium"
@update:model-value="(val) => updateApiKeyIcon(key.id, val)"
/>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ key.name }}
@@ -1294,14 +1367,14 @@
<!-- 操作按钮 -->
<div class="mt-3 flex gap-2 border-t border-gray-100 pt-3 dark:border-gray-600">
<button
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-600 transition-colors hover:bg-blue-100 dark:bg-blue-900/30 dark:hover:bg-blue-900/50"
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-blue-50 px-3 py-1.5 text-xs text-blue-600 transition-colors hover:bg-blue-100 dark:bg-blue-900/30 dark:hover:bg-blue-900/50"
@click="showUsageDetails(key)"
>
<i class="fas fa-chart-line" />
查看详情
</button>
<button
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
class="flex-1 rounded-lg bg-gray-50 px-3 py-1.5 text-xs text-gray-600 transition-colors hover:bg-gray-100 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
@click="openEditApiKeyModal(key)"
>
<i class="fas fa-edit mr-1" />
@@ -1312,7 +1385,7 @@
key.expiresAt &&
(isApiKeyExpired(key.expiresAt) || isApiKeyExpiringSoon(key.expiresAt))
"
class="flex-1 rounded-lg bg-orange-50 px-3 py-2 text-xs text-orange-600 transition-colors hover:bg-orange-100 dark:bg-orange-900/30 dark:hover:bg-orange-900/50"
class="flex-1 rounded-lg bg-orange-50 px-3 py-1.5 text-xs text-orange-600 transition-colors hover:bg-orange-100 dark:bg-orange-900/30 dark:hover:bg-orange-900/50"
@click="openRenewApiKeyModal(key)"
>
<i class="fas fa-clock mr-1" />
@@ -1323,7 +1396,7 @@
key.isActive
? 'bg-orange-50 text-orange-600 hover:bg-orange-100 dark:bg-orange-900/30 dark:hover:bg-orange-900/50'
: 'bg-green-50 text-green-600 hover:bg-green-100 dark:bg-green-900/30 dark:hover:bg-green-900/50',
'rounded-lg px-3 py-2 text-xs transition-colors'
'rounded-lg px-3 py-1.5 text-xs transition-colors'
]"
@click="toggleApiKeyStatus(key)"
>
@@ -1331,7 +1404,7 @@
{{ key.isActive ? '禁用' : '激活' }}
</button>
<button
class="rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600 transition-colors hover:bg-red-100 dark:bg-red-900/30 dark:hover:bg-red-900/50"
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs text-red-600 transition-colors hover:bg-red-100 dark:bg-red-900/30 dark:hover:bg-red-900/50"
@click="deleteApiKey(key.id)"
>
<i class="fas fa-trash" />
@@ -1512,7 +1585,7 @@
<th
class="w-[7%] min-w-[70px] px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
Token数
Token数
</th>
<th
class="w-[9%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
@@ -1528,7 +1601,7 @@
</thead>
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-600/50">
<tr v-for="key in deletedApiKeys" :key="key.id" class="table-row">
<td class="px-3 py-2.5">
<td class="px-3 py-1.5">
<div class="flex items-center">
<div
class="mr-2 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-red-500 to-red-600"
@@ -1545,58 +1618,7 @@
</div>
</div>
</td>
<!-- 账号 -->
<td class="whitespace-nowrap px-3 py-2.5 text-sm">
<div class="text-gray-700 dark:text-gray-300">
<!-- Claude 账号 -->
<span v-if="key.claudeAccountId" class="inline-flex items-center">
<i class="fas fa-robot mr-1 text-xs text-blue-500" />
<span class="text-xs">{{
getClaudeAccountName(key.claudeAccountId)
}}</span>
</span>
<!-- Claude Console 账号 -->
<span
v-else-if="key.claudeConsoleAccountId"
class="inline-flex items-center"
>
<i class="fas fa-terminal mr-1 text-xs text-purple-500" />
<span class="text-xs">{{
getClaudeConsoleAccountName(key.claudeConsoleAccountId)
}}</span>
</span>
<!-- Gemini 账号 -->
<span v-else-if="key.geminiAccountId" class="inline-flex items-center">
<i class="fas fa-gem mr-1 text-xs text-green-500" />
<span class="text-xs">{{
getGeminiAccountName(key.geminiAccountId)
}}</span>
</span>
<!-- OpenAI 账号 -->
<span v-else-if="key.openaiAccountId" class="inline-flex items-center">
<i class="fas fa-brain mr-1 text-xs text-orange-500" />
<span class="text-xs">{{
getOpenAIAccountName(key.openaiAccountId)
}}</span>
</span>
<!-- Bedrock 账号 -->
<span v-else-if="key.bedrockAccountId" class="inline-flex items-center">
<i class="fas fa-layer-group mr-1 text-xs text-indigo-500" />
<span class="text-xs">{{
getBedrockAccountName(key.bedrockAccountId)
}}</span>
</span>
<!-- 共享池 -->
<span
v-else
class="inline-flex items-center text-gray-500 dark:text-gray-400"
>
<i class="fas fa-share-alt mr-1 text-xs" />
<span class="text-xs">共享池</span>
</span>
</div>
</td>
<td v-if="isLdapEnabled" class="px-3 py-2.5">
<td v-if="isLdapEnabled" class="px-3 py-1.5">
<div class="text-sm">
<span v-if="key.createdBy === 'admin'" class="text-blue-600">
<i class="fas fa-user-shield mr-1" />
@@ -1613,11 +1635,11 @@
</div>
</td>
<td
class="whitespace-nowrap px-3 py-2.5 text-sm text-gray-500 dark:text-gray-400"
class="whitespace-nowrap px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400"
>
{{ formatDate(key.createdAt) }}
</td>
<td class="px-3 py-2.5">
<td class="px-3 py-1.5">
<div class="text-sm">
<span v-if="key.deletedByType === 'admin'" class="text-blue-600">
<i class="fas fa-user-shield mr-1" />
@@ -1634,12 +1656,12 @@
</div>
</td>
<td
class="whitespace-nowrap px-3 py-2.5 text-sm text-gray-500 dark:text-gray-400"
class="whitespace-nowrap px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400"
>
{{ formatDate(key.deletedAt) }}
</td>
<!-- 请求数 -->
<td class="whitespace-nowrap px-3 py-2.5 text-right text-sm">
<td class="whitespace-nowrap px-3 py-1.5 text-right text-sm">
<div class="flex items-center justify-end gap-1">
<span class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(key.usage?.total?.requests || 0) }}
@@ -1648,26 +1670,26 @@
</div>
</td>
<!-- 费用 -->
<td class="whitespace-nowrap px-3 py-2.5 text-right text-sm">
<td class="whitespace-nowrap px-3 py-1.5 text-right text-sm">
<span class="font-medium text-green-600 dark:text-green-400">
${{ (key.usage?.total?.cost || 0).toFixed(4) }}
</span>
</td>
<!-- Token数量 -->
<td class="whitespace-nowrap px-3 py-2.5 text-right text-sm">
<td class="whitespace-nowrap px-3 py-1.5 text-right text-sm">
<span class="font-medium text-purple-600 dark:text-purple-400">
{{ formatTokenCount(key.usage?.total?.tokens || 0) }}
</span>
</td>
<td
class="whitespace-nowrap px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300"
class="whitespace-nowrap px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300"
>
<span v-if="key.lastUsedAt">
{{ formatLastUsed(key.lastUsedAt) }}
</span>
<span v-else class="text-gray-400">从未使用</span>
</td>
<td class="px-3 py-2.5">
<td class="px-3 py-1.5">
<div class="flex items-center gap-2">
<button
v-if="key.canRestore"
@@ -1767,6 +1789,7 @@ import { apiClient } from '@/config/api'
import { useClientsStore } from '@/stores/clients'
import { useAuthStore } from '@/stores/auth'
import * as XLSX from 'xlsx-js-style'
import IconPicker from '@/components/common/IconPicker.vue'
import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue'
import EditApiKeyModal from '@/components/apikeys/EditApiKeyModal.vue'
import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue'
@@ -2980,6 +3003,29 @@ const toggleApiKeyStatus = async (key) => {
}
}
// 更新API Key图标
const updateApiKeyIcon = async (keyId, icon) => {
try {
const data = await apiClient.put(`/admin/api-keys/${keyId}`, {
icon: icon
})
if (data.success) {
// 更新本地数据
const localKey = apiKeys.value.find((k) => k.id === keyId)
if (localKey) {
localKey.icon = icon
}
showToast('图标已更新', 'success')
} else {
showToast(data.message || '更新图标失败', 'error')
}
} catch (error) {
console.error('更新图标失败:', error)
showToast('更新图标失败,请重试', 'error')
}
}
// 删除API Key
const deleteApiKey = async (keyId) => {
let confirmed = false
@@ -3592,7 +3638,7 @@ onMounted(async () => {
}
.table-container {
overflow-x: auto;
overflow-x: hidden;
overflow-y: hidden;
margin: 0;
padding: 0;
@@ -3625,7 +3671,6 @@ onMounted(async () => {
.table-row {
transition: background-color 0.2s ease;
position: relative;
}
.table-row:hover {