fix: 修复apikeys页面部分bug

This commit is contained in:
shaw
2025-11-25 20:38:52 +08:00
parent 255b3a0a0d
commit dea6964116
8 changed files with 658 additions and 295 deletions

View File

@@ -946,6 +946,27 @@ proxy_request_buffering off;
---
## ❤️ 赞助支持
如果您觉得这个项目对您有帮助,请考虑赞助支持项目的持续开发。您的支持是我们最大的动力!
<div align="center">
<a href="https://afdian.com/a/claude-relay-service" target="_blank">
<img src="https://img.shields.io/badge/☕_请我喝杯咖啡-爱发电-946ce6?style=for-the-badge&logo=buy-me-a-coffee&logoColor=white" alt="Sponsor">
</a>
<table>
<tr>
<td><img src="docs/sponsoring/wechat.jpg" width="200" alt="wechat" /></td>
<td><img src="docs/sponsoring/alipay.jpg" width="200" alt="alipay" /></td>
</tr>
</table>
</div>
---
## 📄 许可证
本项目采用 [MIT许可证](LICENSE)。

BIN
docs/sponsoring/alipay.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

BIN
docs/sponsoring/wechat.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

View File

@@ -315,14 +315,21 @@ class RedisClient {
})
}
// 搜索apiKey 模式在这里处理bindingAccount 模式在路由层处理)
if (search && searchMode === 'apiKey') {
// 搜索
if (search) {
const lowerSearch = search.toLowerCase().trim()
filteredKeys = filteredKeys.filter(
(k) =>
(k.name && k.name.toLowerCase().includes(lowerSearch)) ||
(k.ownerDisplayName && k.ownerDisplayName.toLowerCase().includes(lowerSearch))
)
if (searchMode === 'apiKey') {
// apiKey 模式:搜索名称和拥有者
filteredKeys = filteredKeys.filter(
(k) =>
(k.name && k.name.toLowerCase().includes(lowerSearch)) ||
(k.ownerDisplayName && k.ownerDisplayName.toLowerCase().includes(lowerSearch))
)
} else if (searchMode === 'bindingAccount') {
// bindingAccount 模式直接在Redis层处理避免路由层加载10000条
const accountNameCacheService = require('../services/accountNameCacheService')
filteredKeys = accountNameCacheService.searchByBindingAccount(filteredKeys, lowerSearch)
}
}
// 4. 排序

View File

@@ -222,57 +222,24 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
// 获取用户服务来补充owner信息
const userService = require('../services/userService')
// 使用优化的分页方法获取数据
let result = await redis.getApiKeysPaginated({
// 如果是绑定账号搜索模式,先刷新账户名称缓存
if (searchMode === 'bindingAccount' && search) {
const accountNameCacheService = require('../services/accountNameCacheService')
await accountNameCacheService.refreshIfNeeded()
}
// 使用优化的分页方法获取数据bindingAccount搜索现在在Redis层处理
const result = await redis.getApiKeysPaginated({
page: pageNum,
pageSize: pageSizeNum,
searchMode,
search: searchMode === 'apiKey' ? search : '', // apiKey 模式的搜索在 redis 层处理
search,
tag,
isActive,
sortBy: validSortBy,
sortOrder: validSortOrder
})
// 如果是绑定账号搜索模式,需要在这里处理
if (searchMode === 'bindingAccount' && search) {
const accountNameCacheService = require('../services/accountNameCacheService')
await accountNameCacheService.refreshIfNeeded()
// 获取所有数据进行绑定账号搜索
const allResult = await redis.getApiKeysPaginated({
page: 1,
pageSize: 10000, // 获取所有数据
searchMode: 'apiKey',
search: '',
tag,
isActive,
sortBy: validSortBy,
sortOrder: validSortOrder
})
// 使用缓存服务进行绑定账号搜索
const filteredKeys = accountNameCacheService.searchByBindingAccount(allResult.items, search)
// 重新分页
const total = filteredKeys.length
const totalPages = Math.ceil(total / pageSizeNum) || 1
const validPage = Math.min(Math.max(1, pageNum), totalPages)
const start = (validPage - 1) * pageSizeNum
const items = filteredKeys.slice(start, start + pageSizeNum)
result = {
items,
pagination: {
page: validPage,
pageSize: pageSizeNum,
total,
totalPages
},
availableTags: allResult.availableTags
}
}
// 为每个API Key添加owner的displayName
for (const apiKey of result.items) {
if (apiKey.userId) {
@@ -547,6 +514,10 @@ router.post('/api-keys/batch-stats', authenticateAdmin, async (req, res) => {
cacheReadTokens: 0,
cost: 0,
formattedCost: '$0.00',
dailyCost: 0,
currentWindowCost: 0,
windowRemainingSeconds: null,
allTimeCost: 0,
error: error.message
}
}
@@ -732,6 +703,50 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 获取实时限制数据
let dailyCost = 0
let currentWindowCost = 0
let windowRemainingSeconds = null
let allTimeCost = 0
try {
// 获取当日费用
dailyCost = await redis.getDailyCost(keyId)
// 获取历史总费用(用于总费用限制进度条,不受时间范围影响)
const totalCostKey = `usage:cost:total:${keyId}`
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
// 获取 API Key 配置信息以判断是否需要窗口数据
const apiKey = await redis.getApiKeyById(keyId)
if (apiKey && apiKey.rateLimitWindow > 0) {
const costCountKey = `rate_limit:cost:${keyId}`
const windowStartKey = `rate_limit:window_start:${keyId}`
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
// 获取窗口开始时间和计算剩余时间
const windowStart = await client.get(windowStartKey)
if (windowStart) {
const now = Date.now()
const windowStartTime = parseInt(windowStart)
const windowDuration = apiKey.rateLimitWindow * 60 * 1000 // 转换为毫秒
const windowEndTime = windowStartTime + windowDuration
// 如果窗口还有效
if (now < windowEndTime) {
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
} else {
// 窗口已过期
windowRemainingSeconds = 0
currentWindowCost = 0
}
}
}
} catch (error) {
logger.debug(`获取实时限制数据失败 (key: ${keyId}):`, error.message)
}
return {
requests: totalRequests,
tokens,
@@ -740,10 +755,109 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
cacheCreateTokens,
cacheReadTokens,
cost: totalCost,
formattedCost: CostCalculator.formatCost(totalCost)
formattedCost: CostCalculator.formatCost(totalCost),
// 实时限制数据
dailyCost,
currentWindowCost,
windowRemainingSeconds,
allTimeCost // 历史总费用(用于总费用限制)
}
}
/**
* 批量获取指定 Keys 的最后使用账号信息
* POST /admin/api-keys/batch-last-usage
*
* 用于 API Keys 列表页面异步加载最后使用账号数据
*/
router.post('/api-keys/batch-last-usage', authenticateAdmin, async (req, res) => {
try {
const { keyIds } = req.body
// 参数验证
if (!Array.isArray(keyIds) || keyIds.length === 0) {
return res.status(400).json({
success: false,
error: 'keyIds is required and must be a non-empty array'
})
}
// 限制单次最多处理 100 个 Key
if (keyIds.length > 100) {
return res.status(400).json({
success: false,
error: 'Max 100 keys per request'
})
}
logger.debug(`📊 Batch last-usage request: ${keyIds.length} keys`)
const client = redis.getClientSafe()
const lastUsageData = {}
const accountInfoCache = new Map()
// 并行获取每个 Key 的最后使用记录
await Promise.all(
keyIds.map(async (keyId) => {
try {
// 获取最新的使用记录
const usageRecords = await redis.getUsageRecords(keyId, 1)
if (!Array.isArray(usageRecords) || usageRecords.length === 0) {
lastUsageData[keyId] = null
return
}
const lastUsageRecord = usageRecords[0]
if (!lastUsageRecord || (!lastUsageRecord.accountId && !lastUsageRecord.accountType)) {
lastUsageData[keyId] = null
return
}
// 解析账号信息
const resolvedAccount = await apiKeyService._resolveAccountByUsageRecord(
lastUsageRecord,
accountInfoCache,
client
)
if (resolvedAccount) {
lastUsageData[keyId] = {
accountId: resolvedAccount.accountId,
rawAccountId: lastUsageRecord.accountId || resolvedAccount.accountId,
accountType: resolvedAccount.accountType,
accountCategory: resolvedAccount.accountCategory,
accountName: resolvedAccount.accountName,
recordedAt: lastUsageRecord.timestamp || null
}
} else {
// 账号已删除
lastUsageData[keyId] = {
accountId: null,
rawAccountId: lastUsageRecord.accountId || null,
accountType: 'deleted',
accountCategory: 'deleted',
accountName: '已删除',
recordedAt: lastUsageRecord.timestamp || null
}
}
} catch (error) {
logger.debug(`获取 API Key ${keyId} 的最后使用记录失败:`, error)
lastUsageData[keyId] = null
}
})
)
return res.json({ success: true, data: lastUsageData })
} catch (error) {
logger.error('❌ Failed to get batch last-usage:', error)
return res.status(500).json({
success: false,
error: 'Failed to get last-usage data',
message: error.message
})
}
})
// 创建新的API Key
router.post('/api-keys', authenticateAdmin, async (req, res) => {
try {

View File

@@ -1021,8 +1021,7 @@ onMounted(async () => {
}
}
// 自动加载账号数据
await refreshAccounts()
// 使用缓存的账号数据,不自动刷新(用户可点击"刷新账号"按钮手动刷新)
})
// 刷新账号列表

View File

@@ -1233,8 +1233,7 @@ onMounted(async () => {
}
}
// 自动加载账号数据
await refreshAccounts()
// 使用缓存的账号数据,不自动刷新(用户可点击"刷新账号"按钮手动刷新)
form.name = props.apiKey.name
@@ -1271,11 +1270,16 @@ onMounted(async () => {
form.restrictedModels = props.apiKey.restrictedModels || []
form.allowedClients = props.apiKey.allowedClients || []
form.tags = props.apiKey.tags || []
// 从后端数据中获取实际的启用状态,而不是根据数组长度推断
form.enableModelRestriction = props.apiKey.enableModelRestriction || false
form.enableClientRestriction = props.apiKey.enableClientRestriction || false
// 初始化活跃状态,默认为 true
form.isActive = props.apiKey.isActive !== undefined ? props.apiKey.isActive : true
// 从后端数据中获取实际的启用状态,强制转换为布尔值Redis返回的是字符串
form.enableModelRestriction =
props.apiKey.enableModelRestriction === true || props.apiKey.enableModelRestriction === 'true'
form.enableClientRestriction =
props.apiKey.enableClientRestriction === true || props.apiKey.enableClientRestriction === 'true'
// 初始化活跃状态,默认为 true强制转换为布尔值因为Redis返回字符串
form.isActive =
props.apiKey.isActive === undefined ||
props.apiKey.isActive === true ||
props.apiKey.isActive === 'true'
// 初始化所有者
form.ownerId = props.apiKey.userId || 'admin'

View File

@@ -427,84 +427,98 @@
<!-- 所属账号列 -->
<td class="px-3 py-3">
<div class="space-y-1">
<!-- Claude 绑定 -->
<!-- 账号数据加载中 -->
<div
v-if="key.claudeAccountId || key.claudeConsoleAccountId"
class="flex items-center gap-1 text-xs"
v-if="accountsLoading && hasAnyBinding(key)"
class="flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500"
>
<span
class="inline-flex items-center rounded bg-indigo-100 px-1.5 py-0.5 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300"
<i class="fas fa-spinner fa-spin mr-1"></i>
加载中...
</div>
<!-- 账号数据已加载或无绑定 -->
<template v-else>
<!-- Claude 绑定 -->
<div
v-if="key.claudeAccountId || key.claudeConsoleAccountId"
class="flex items-center gap-1 text-xs"
>
<i class="fas fa-brain mr-1 text-[10px]" />
Claude
</span>
<span class="truncate text-gray-600 dark:text-gray-400">
{{ getClaudeBindingInfo(key) }}
</span>
</div>
<!-- Gemini 绑定 -->
<div v-if="key.geminiAccountId" class="flex items-center gap-1 text-xs">
<span
class="inline-flex items-center rounded bg-yellow-100 px-1.5 py-0.5 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300"
<span
class="inline-flex items-center rounded bg-indigo-100 px-1.5 py-0.5 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300"
>
<i class="fas fa-brain mr-1 text-[10px]" />
Claude
</span>
<span class="truncate text-gray-600 dark:text-gray-400">
{{ getClaudeBindingInfo(key) }}
</span>
</div>
<!-- Gemini 绑定 -->
<div v-if="key.geminiAccountId" class="flex items-center gap-1 text-xs">
<span
class="inline-flex items-center rounded bg-yellow-100 px-1.5 py-0.5 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300"
>
<i class="fas fa-robot mr-1 text-[10px]" />
Gemini
</span>
<span class="truncate text-gray-600 dark:text-gray-400">
{{ getGeminiBindingInfo(key) }}
</span>
</div>
<!-- OpenAI 绑定 -->
<div v-if="key.openaiAccountId" class="flex items-center gap-1 text-xs">
<span
class="inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
>
<i class="fa-openai mr-1 text-[10px]" />
OpenAI
</span>
<span class="truncate text-gray-600 dark:text-gray-400">
{{ getOpenAIBindingInfo(key) }}
</span>
</div>
<!-- Bedrock 绑定 -->
<div
v-if="key.bedrockAccountId"
class="flex items-center gap-1 text-xs"
>
<i class="fas fa-robot mr-1 text-[10px]" />
Gemini
</span>
<span class="truncate text-gray-600 dark:text-gray-400">
{{ getGeminiBindingInfo(key) }}
</span>
</div>
<!-- OpenAI 绑定 -->
<div v-if="key.openaiAccountId" class="flex items-center gap-1 text-xs">
<span
class="inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
<span
class="inline-flex items-center rounded bg-orange-100 px-1.5 py-0.5 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300"
>
<i class="fas fa-cloud mr-1 text-[10px]" />
Bedrock
</span>
<span class="truncate text-gray-600 dark:text-gray-400">
{{ getBedrockBindingInfo(key) }}
</span>
</div>
<!-- Droid 绑定 -->
<div v-if="key.droidAccountId" class="flex items-center gap-1 text-xs">
<span
class="inline-flex items-center rounded bg-cyan-100 px-1.5 py-0.5 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300"
>
<i class="fas fa-robot mr-1 text-[10px]" />
Droid
</span>
<span class="truncate text-gray-600 dark:text-gray-400">
{{ getDroidBindingInfo(key) }}
</span>
</div>
<!-- 共享池 -->
<div
v-if="
!key.claudeAccountId &&
!key.claudeConsoleAccountId &&
!key.geminiAccountId &&
!key.openaiAccountId &&
!key.bedrockAccountId &&
!key.droidAccountId
"
class="text-xs text-gray-500 dark:text-gray-400"
>
<i class="fa-openai mr-1 text-[10px]" />
OpenAI
</span>
<span class="truncate text-gray-600 dark:text-gray-400">
{{ getOpenAIBindingInfo(key) }}
</span>
</div>
<!-- Bedrock 绑定 -->
<div v-if="key.bedrockAccountId" class="flex items-center gap-1 text-xs">
<span
class="inline-flex items-center rounded bg-orange-100 px-1.5 py-0.5 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300"
>
<i class="fas fa-cloud mr-1 text-[10px]" />
Bedrock
</span>
<span class="truncate text-gray-600 dark:text-gray-400">
{{ getBedrockBindingInfo(key) }}
</span>
</div>
<!-- Droid 绑定 -->
<div v-if="key.droidAccountId" class="flex items-center gap-1 text-xs">
<span
class="inline-flex items-center rounded bg-cyan-100 px-1.5 py-0.5 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300"
>
<i class="fas fa-robot mr-1 text-[10px]" />
Droid
</span>
<span class="truncate text-gray-600 dark:text-gray-400">
{{ getDroidBindingInfo(key) }}
</span>
</div>
<!-- 共享池 -->
<div
v-if="
!key.claudeAccountId &&
!key.claudeConsoleAccountId &&
!key.geminiAccountId &&
!key.openaiAccountId &&
!key.bedrockAccountId &&
!key.droidAccountId
"
class="text-xs text-gray-500 dark:text-gray-400"
>
<i class="fas fa-share-alt mr-1" />
共享池
</div>
<i class="fas fa-share-alt mr-1" />
共享池
</div>
</template>
</div>
</td>
<!-- 标签列 -->
@@ -544,10 +558,12 @@
</td>
<!-- 费用 -->
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
<!-- 加载中状态 -->
<!-- 加载中状态 - 骨架屏 -->
<template v-if="isStatsLoading(key.id)">
<div class="flex items-center justify-end">
<i class="fas fa-spinner fa-spin text-gray-400"></i>
<div
class="h-5 w-14 animate-pulse rounded bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700"
/>
</div>
</template>
<!-- 已加载状态 -->
@@ -567,83 +583,108 @@
<!-- 限制 -->
<td class="px-2 py-2" style="font-size: 12px">
<div class="flex flex-col gap-2">
<!-- 每日费用限制进度条 -->
<LimitProgressBar
v-if="key.dailyCostLimit > 0"
:current="key.dailyCost || 0"
label="每日限制"
:limit="key.dailyCostLimit"
type="daily"
variant="compact"
/>
<!-- 总费用限制进度条(无每日限制时展示) -->
<LimitProgressBar
v-else-if="key.totalCostLimit > 0"
:current="getCachedStats(key.id)?.cost || key.usage?.total?.cost || 0"
label="总费用限制"
:limit="key.totalCostLimit"
type="total"
variant="compact"
/>
<!-- 时间窗口费用限制(无每日和总费用限制时展示) -->
<div
v-else-if="
key.rateLimitWindow > 0 &&
key.rateLimitCost > 0 &&
(!key.dailyCostLimit || key.dailyCostLimit === 0) &&
(!key.totalCostLimit || key.totalCostLimit === 0)
<!-- 加载中状态 - 骨架屏(仅在有费用限制配置时显示) -->
<template
v-if="
isStatsLoading(key.id) &&
(key.dailyCostLimit > 0 ||
key.totalCostLimit > 0 ||
(key.rateLimitWindow > 0 && key.rateLimitCost > 0))
"
class="space-y-1.5"
>
<!-- 费用进度条 -->
<div class="space-y-2">
<div
class="h-4 w-full animate-pulse rounded bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700"
/>
<div
class="h-3 w-2/3 animate-pulse rounded bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700"
/>
</div>
</template>
<!-- 已加载状态 -->
<template v-else>
<!-- 每日费用限制进度条 -->
<LimitProgressBar
:current="key.currentWindowCost || 0"
label="窗口费用"
:limit="key.rateLimitCost"
type="window"
v-if="key.dailyCostLimit > 0"
:current="getCachedStats(key.id)?.dailyCost || 0"
label="每日限制"
:limit="key.dailyCostLimit"
type="daily"
variant="compact"
/>
<!-- 重置倒计时 -->
<div class="flex items-center justify-between text-[10px]">
<div class="flex items-center gap-1 text-sky-600 dark:text-sky-300">
<i class="fas fa-clock text-[10px]" />
<span class="font-medium">{{ key.rateLimitWindow }}分钟窗口</span>
</div>
<span
class="font-bold"
:class="
key.windowRemainingSeconds > 0
? 'text-sky-700 dark:text-sky-300'
: 'text-gray-400 dark:text-gray-500'
"
>
{{
key.windowRemainingSeconds > 0
? formatWindowTime(key.windowRemainingSeconds)
: '未激活'
}}
</span>
</div>
</div>
<!-- 如果没有任何限制 -->
<div
v-else
class="flex items-center justify-center gap-1.5 py-2 text-gray-500 dark:text-gray-400"
>
<i class="fas fa-infinity text-base" />
<span class="text-xs font-medium">无限制</span>
</div>
<!-- 总费用限制进度条(无每日限制时展示) -->
<LimitProgressBar
v-else-if="key.totalCostLimit > 0"
:current="getCachedStats(key.id)?.allTimeCost || 0"
label="总费用限制"
:limit="key.totalCostLimit"
type="total"
variant="compact"
/>
<!-- 时间窗口费用限制(无每日和总费用限制时展示) -->
<div
v-else-if="
key.rateLimitWindow > 0 &&
key.rateLimitCost > 0 &&
(!key.dailyCostLimit || key.dailyCostLimit === 0) &&
(!key.totalCostLimit || key.totalCostLimit === 0)
"
class="space-y-1.5"
>
<!-- 费用进度条 -->
<LimitProgressBar
:current="getCachedStats(key.id)?.currentWindowCost || 0"
label="窗口费用"
:limit="key.rateLimitCost"
type="window"
variant="compact"
/>
<!-- 重置倒计时 -->
<div class="flex items-center justify-between text-[10px]">
<div class="flex items-center gap-1 text-sky-600 dark:text-sky-300">
<i class="fas fa-clock text-[10px]" />
<span class="font-medium">{{ key.rateLimitWindow }}分钟窗口</span>
</div>
<span
class="font-bold"
:class="
(getCachedStats(key.id)?.windowRemainingSeconds || 0) > 0
? 'text-sky-700 dark:text-sky-300'
: 'text-gray-400 dark:text-gray-500'
"
>
{{
(getCachedStats(key.id)?.windowRemainingSeconds || 0) > 0
? formatWindowTime(
getCachedStats(key.id)?.windowRemainingSeconds || 0
)
: '未激活'
}}
</span>
</div>
</div>
<!-- 如果没有任何限制 -->
<div
v-else
class="flex items-center justify-center gap-1.5 py-2 text-gray-500 dark:text-gray-400"
>
<i class="fas fa-infinity text-base" />
<span class="text-xs font-medium">无限制</span>
</div>
</template>
</div>
</td>
<!-- Token数量 -->
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
<!-- 加载中状态 -->
<!-- 加载中状态 - 骨架屏 -->
<template v-if="isStatsLoading(key.id)">
<div class="flex items-center justify-end">
<i class="fas fa-spinner fa-spin text-gray-400"></i>
<div
class="h-5 w-16 animate-pulse rounded bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700"
/>
</div>
</template>
<!-- 已加载状态 -->
@@ -664,10 +705,12 @@
</td>
<!-- 请求数 -->
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
<!-- 加载中状态 -->
<!-- 加载中状态 - 骨架屏 -->
<template v-if="isStatsLoading(key.id)">
<div class="flex items-center justify-end">
<i class="fas fa-spinner fa-spin text-gray-400"></i>
<div
class="h-5 w-12 animate-pulse rounded bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700"
/>
</div>
</template>
<!-- 已加载状态 -->
@@ -702,8 +745,16 @@
{{ formatLastUsed(key.lastUsedAt) }}
</span>
<span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
<!-- 最后使用账号 loading 状态 -->
<span
v-if="hasLastUsageAccount(key)"
v-if="key.lastUsedAt && isLastUsageLoading(key.id)"
class="mt-1 text-xs text-gray-400 dark:text-gray-500"
>
<i class="fas fa-spinner fa-spin mr-1"></i>
加载中...
</span>
<span
v-else-if="hasLastUsageAccount(key)"
class="mt-1 text-xs text-gray-500 dark:text-gray-400"
:title="getLastUsageFullName(key)"
>
@@ -1272,9 +1323,9 @@
<div>
<!-- 请求数 - 使用缓存统计 -->
<template v-if="isStatsLoading(key.id)">
<p class="text-sm font-semibold text-gray-400">
<i class="fas fa-spinner fa-spin"></i>
</p>
<div
class="h-5 w-12 animate-pulse rounded bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700"
/>
</template>
<template v-else-if="getCachedStats(key.id)">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
@@ -1289,9 +1340,9 @@
<div>
<!-- 费用 - 使用缓存统计 -->
<template v-if="isStatsLoading(key.id)">
<p class="text-sm font-semibold text-gray-400">
<i class="fas fa-spinner fa-spin"></i>
</p>
<div
class="h-5 w-14 animate-pulse rounded bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700"
/>
</template>
<template v-else-if="getCachedStats(key.id)">
<p class="text-sm font-semibold text-green-600">
@@ -1313,8 +1364,16 @@
</div>
<div class="mt-1 flex items-center justify-between">
<span>账号</span>
<!-- 最后使用账号 loading 状态 -->
<span
v-if="hasLastUsageAccount(key)"
v-if="key.lastUsedAt && isLastUsageLoading(key.id)"
class="text-gray-400 dark:text-gray-500"
>
<i class="fas fa-spinner fa-spin mr-1"></i>
加载中...
</span>
<span
v-else-if="hasLastUsageAccount(key)"
class="truncate text-gray-500 dark:text-gray-400"
style="max-width: 180px"
:title="getLastUsageFullName(key)"
@@ -1334,75 +1393,98 @@
<!-- 限制进度条 -->
<div class="space-y-2">
<!-- 每日费用限制 -->
<LimitProgressBar
v-if="key.dailyCostLimit > 0"
:current="key.dailyCost || 0"
label="每日限制"
:limit="key.dailyCostLimit"
type="daily"
variant="compact"
/>
<!-- 总费用限制(无每日限制时展示) -->
<LimitProgressBar
v-else-if="key.totalCostLimit > 0"
:current="getCachedStats(key.id)?.cost || key.usage?.total?.cost || 0"
label="总费用限制"
:limit="key.totalCostLimit"
type="total"
variant="compact"
/>
<!-- 时间窗口费用限制(无每日和总费用限制时展示) -->
<div
v-else-if="
key.rateLimitWindow > 0 &&
key.rateLimitCost > 0 &&
(!key.dailyCostLimit || key.dailyCostLimit === 0) &&
(!key.totalCostLimit || key.totalCostLimit === 0)
<!-- 加载中状态 - 骨架屏(仅在有费用限制配置时显示) -->
<template
v-if="
isStatsLoading(key.id) &&
(key.dailyCostLimit > 0 ||
key.totalCostLimit > 0 ||
(key.rateLimitWindow > 0 && key.rateLimitCost > 0))
"
class="space-y-2"
>
<!-- 费用进度条 -->
<div class="space-y-2">
<div
class="h-4 w-full animate-pulse rounded bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700"
/>
<div
class="h-3 w-2/3 animate-pulse rounded bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700"
/>
</div>
</template>
<!-- 已加载状态 -->
<template v-else>
<!-- 每日费用限制 -->
<LimitProgressBar
:current="key.currentWindowCost || 0"
label="窗口费用"
:limit="key.rateLimitCost"
type="window"
v-if="key.dailyCostLimit > 0"
:current="getCachedStats(key.id)?.dailyCost || 0"
label="每日限制"
:limit="key.dailyCostLimit"
type="daily"
variant="compact"
/>
<!-- 重置倒计时 -->
<div class="flex items-center justify-between text-xs">
<div class="flex items-center gap-1.5 text-sky-600 dark:text-sky-300">
<i class="fas fa-clock text-xs" />
<span class="font-medium">{{ key.rateLimitWindow }}分钟窗口</span>
</div>
<span
class="font-bold"
:class="
key.windowRemainingSeconds > 0
? 'text-sky-700 dark:text-sky-300'
: 'text-gray-400 dark:text-gray-500'
"
>
{{
key.windowRemainingSeconds > 0
? formatWindowTime(key.windowRemainingSeconds)
: '未激活'
}}
</span>
</div>
</div>
<!-- 无限制显示 -->
<div
v-else
class="flex items-center justify-center gap-1.5 py-2 text-gray-500 dark:text-gray-400"
>
<i class="fas fa-infinity text-base" />
<span class="text-xs font-medium">无限制</span>
</div>
<!-- 总费用限制(无每日限制时展示) -->
<LimitProgressBar
v-else-if="key.totalCostLimit > 0"
:current="getCachedStats(key.id)?.allTimeCost || 0"
label="总费用限制"
:limit="key.totalCostLimit"
type="total"
variant="compact"
/>
<!-- 时间窗口费用限制(无每日和总费用限制时展示) -->
<div
v-else-if="
key.rateLimitWindow > 0 &&
key.rateLimitCost > 0 &&
(!key.dailyCostLimit || key.dailyCostLimit === 0) &&
(!key.totalCostLimit || key.totalCostLimit === 0)
"
class="space-y-2"
>
<!-- 费用进度条 -->
<LimitProgressBar
:current="getCachedStats(key.id)?.currentWindowCost || 0"
label="窗口费用"
:limit="key.rateLimitCost"
type="window"
variant="compact"
/>
<!-- 重置倒计时 -->
<div class="flex items-center justify-between text-xs">
<div class="flex items-center gap-1.5 text-sky-600 dark:text-sky-300">
<i class="fas fa-clock text-xs" />
<span class="font-medium">{{ key.rateLimitWindow }}分钟窗口</span>
</div>
<span
class="font-bold"
:class="
(getCachedStats(key.id)?.windowRemainingSeconds || 0) > 0
? 'text-sky-700 dark:text-sky-300'
: 'text-gray-400 dark:text-gray-500'
"
>
{{
(getCachedStats(key.id)?.windowRemainingSeconds || 0) > 0
? formatWindowTime(
getCachedStats(key.id)?.windowRemainingSeconds || 0
)
: '未激活'
}}
</span>
</div>
</div>
<!-- 无限制显示 -->
<div
v-else
class="flex items-center justify-center gap-1.5 py-2 text-gray-500 dark:text-gray-400"
>
<i class="fas fa-infinity text-base" />
<span class="text-xs font-medium">无限制</span>
</div>
</template>
</div>
</div>
@@ -1840,8 +1922,16 @@
{{ formatLastUsed(key.lastUsedAt) }}
</span>
<span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
<!-- 最后使用账号 loading 状态 -->
<span
v-if="hasLastUsageAccount(key)"
v-if="key.lastUsedAt && isLastUsageLoading(key.id)"
class="mt-1 text-xs text-gray-400 dark:text-gray-500"
>
<i class="fas fa-spinner fa-spin mr-1"></i>
加载中...
</span>
<span
v-else-if="hasLastUsageAccount(key)"
class="mt-1 text-xs text-gray-500 dark:text-gray-400"
:title="getLastUsageFullName(key)"
>
@@ -2039,6 +2129,10 @@ const serverPagination = ref({
const statsCache = ref(new Map())
// 正在加载统计的 keyIds
const statsLoading = ref(new Set())
// 最后使用账号缓存: Map<keyId, lastUsageInfo>
const lastUsageCache = ref(new Map())
// 正在加载最后使用账号的 keyIds
const lastUsageLoading = ref(new Set())
const apiKeyModelStats = ref({})
const apiKeyDateFilters = ref({})
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
@@ -2054,6 +2148,9 @@ const accounts = ref({
openaiGroups: [],
droidGroups: []
})
// 账号数据加载状态
const accountsLoading = ref(false)
const accountsLoaded = ref(false)
const editingExpiryKey = ref(null)
const expiryEditModalRef = ref(null)
const showUsageDetailModal = ref(false)
@@ -2186,8 +2283,14 @@ const paginatedApiKeys = computed(() => {
return apiKeys.value
})
// 加载账户列表
const loadAccounts = async () => {
// 加载账户列表(支持缓存和强制刷新)
const loadAccounts = async (forceRefresh = false) => {
// 如果已加载且不强制刷新,则跳过
if (accountsLoaded.value && !forceRefresh) {
return
}
accountsLoading.value = true
try {
const [
claudeData,
@@ -2298,8 +2401,13 @@ const loadAccounts = async () => {
accounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
accounts.value.droidGroups = allGroups.filter((g) => g.platform === 'droid')
}
} catch (error) {
// console.error('加载账户列表失败:', error)
// 标记账号数据已加载
accountsLoaded.value = true
} catch {
// 静默处理错误
} finally {
accountsLoading.value = false
}
}
@@ -2307,9 +2415,10 @@ const loadAccounts = async () => {
const loadApiKeys = async (clearStatsCache = true) => {
apiKeysLoading.value = true
try {
// 清除统计缓存(刷新时)
// 清除缓存(刷新时)
if (clearStatsCache) {
statsCache.value.clear()
lastUsageCache.value.clear()
}
// 构建请求参数
@@ -2375,11 +2484,12 @@ const loadApiKeys = async (clearStatsCache = true) => {
availableTags.value = data.data.availableTags
}
// 异步加载当前页的统计数据
await loadPageStats()
// 异步加载当前页的统计数据(不等待,让页面先显示基础数据)
loadPageStats()
// 异步加载当前页的最后使用账号数据
loadPageLastUsage()
}
} catch (error) {
console.error('加载 API Keys 失败:', error)
} catch {
showToast('加载 API Keys 失败', 'error')
} finally {
apiKeysLoading.value = false
@@ -2466,6 +2576,53 @@ const isStatsLoading = (keyId) => {
return statsLoading.value.has(keyId)
}
// 异步加载当前页的最后使用账号数据
const loadPageLastUsage = async () => {
const currentPageKeys = apiKeys.value
if (!currentPageKeys || currentPageKeys.length === 0) return
// 筛选出需要加载的 keys未缓存且有 lastUsedAt 的)
const keysNeedLastUsage = currentPageKeys.filter((key) => {
// 没有使用过的不需要加载
if (!key.lastUsedAt) return false
// 已经有缓存的不需要加载
if (lastUsageCache.value.has(key.id)) return false
return true
})
if (keysNeedLastUsage.length === 0) return
// 标记为加载中
const keyIds = keysNeedLastUsage.map((k) => k.id)
keyIds.forEach((id) => lastUsageLoading.value.add(id))
try {
const response = await apiClient.post('/admin/api-keys/batch-last-usage', { keyIds })
if (response.success && response.data) {
// 更新缓存
for (const [keyId, lastUsage] of Object.entries(response.data)) {
lastUsageCache.value.set(keyId, lastUsage)
}
}
} catch (error) {
console.error('加载最后使用账号数据失败:', error)
// 不显示 toast避免打扰用户
} finally {
keyIds.forEach((id) => lastUsageLoading.value.delete(id))
}
}
// 获取缓存的最后使用账号数据
const getCachedLastUsage = (keyId) => {
return lastUsageCache.value.get(keyId) || null
}
// 检查是否正在加载最后使用账号
const isLastUsageLoading = (keyId) => {
return lastUsageLoading.value.has(keyId)
}
// 加载已删除的API Keys
const loadDeletedApiKeys = async () => {
activeTab.value = 'deleted'
@@ -2609,6 +2766,18 @@ const getBoundAccountName = (accountId) => {
return `${accountId.substring(0, 8)}`
}
// 检查 API Key 是否有任何账号绑定
const hasAnyBinding = (key) => {
return !!(
key.claudeAccountId ||
key.claudeConsoleAccountId ||
key.geminiAccountId ||
key.openaiAccountId ||
key.bedrockAccountId ||
key.droidAccountId
)
}
// 获取Claude绑定信息
const getClaudeBindingInfo = (key) => {
if (key.claudeAccountId) {
@@ -3304,18 +3473,24 @@ const resetApiKeyDateFilter = (keyId) => {
}
// 打开创建模态框
const openCreateApiKeyModal = async () => {
// 重新加载账号数据,确保显示最新的专属账号
await loadAccounts()
const openCreateApiKeyModal = () => {
// 使用缓存的账号数据(如果需要最新数据,用户可以点击"刷新账号"按钮)
showCreateApiKeyModal.value = true
// 如果账号数据未加载,异步加载
if (!accountsLoaded.value) {
loadAccounts()
}
}
// 打开编辑模态框
const openEditApiKeyModal = async (apiKey) => {
// 重新加载账号数据,确保显示最新的专属账号
await loadAccounts()
const openEditApiKeyModal = (apiKey) => {
// 使用缓存的账号数据(如果需要最新数据,用户可以点击"刷新账号"按钮)
editingApiKey.value = apiKey
showEditApiKeyModal.value = true
// 如果账号数据未加载,异步加载
if (!accountsLoaded.value) {
loadAccounts()
}
}
// 打开续期模态框
@@ -3341,15 +3516,18 @@ const handleBatchCreateSuccess = (data) => {
}
// 打开批量编辑模态框
const openBatchEditModal = async () => {
const openBatchEditModal = () => {
if (selectedApiKeys.value.length === 0) {
showToast('请先选择要编辑的 API Keys', 'warning')
return
}
// 重新加载账号数据,确保显示最新的专属账号
await loadAccounts()
// 使用缓存的账号数据(如果需要最新数据,用户可以点击"刷新账号"按钮)
showBatchEditModal.value = true
// 如果账号数据未加载,异步加载
if (!accountsLoaded.value) {
loadAccounts()
}
}
// 处理批量编辑成功
@@ -3781,7 +3959,34 @@ const formatWindowTime = (seconds) => {
// 显示使用详情
const showUsageDetails = (apiKey) => {
selectedApiKeyForDetail.value = apiKey
// 获取异步加载的统计数据
const cachedStats = getCachedStats(apiKey.id)
// 合并异步统计数据到 apiKey 对象
const enrichedApiKey = {
...apiKey,
// 合并实时限制数据
dailyCost: cachedStats?.dailyCost ?? apiKey.dailyCost ?? 0,
currentWindowCost: cachedStats?.currentWindowCost ?? apiKey.currentWindowCost ?? 0,
windowRemainingSeconds: cachedStats?.windowRemainingSeconds ?? apiKey.windowRemainingSeconds,
// 合并 usage 数据(用于详情弹窗中的统计卡片)
usage: {
...apiKey.usage,
total: {
...apiKey.usage?.total,
requests: cachedStats?.requests ?? apiKey.usage?.total?.requests ?? 0,
tokens: cachedStats?.tokens ?? apiKey.usage?.total?.tokens ?? 0,
cost: cachedStats?.allTimeCost ?? apiKey.usage?.total?.cost ?? 0,
inputTokens: cachedStats?.inputTokens ?? apiKey.usage?.total?.inputTokens ?? 0,
outputTokens: cachedStats?.outputTokens ?? apiKey.usage?.total?.outputTokens ?? 0,
cacheCreateTokens:
cachedStats?.cacheCreateTokens ?? apiKey.usage?.total?.cacheCreateTokens ?? 0,
cacheReadTokens: cachedStats?.cacheReadTokens ?? apiKey.usage?.total?.cacheReadTokens ?? 0
}
}
}
selectedApiKeyForDetail.value = enrichedApiKey
showUsageDetailModal.value = true
}
@@ -3852,9 +4057,19 @@ const normalizeFrontendAccountCategory = (type) => {
return 'other'
}
const getLastUsageInfo = (apiKey) => apiKey?.lastUsage || null
// 获取最后使用账号信息(优先从缓存获取)
const getLastUsageInfo = (apiKey) => {
if (!apiKey) return null
// 优先从缓存获取
const cached = getCachedLastUsage(apiKey.id)
if (cached !== null) return cached
// 兼容旧数据(如果后端直接返回了 lastUsage
return apiKey.lastUsage || null
}
const hasLastUsageAccount = (apiKey) => {
// 如果正在加载,返回 false让 loading 状态显示)
if (isLastUsageLoading(apiKey?.id)) return false
const info = getLastUsageInfo(apiKey)
return !!(info && (info.accountName || info.accountId || info.rawAccountId))
}
@@ -4285,11 +4500,14 @@ watch(apiKeys, () => {
})
onMounted(async () => {
// 并行加载所有需要的数据
await Promise.all([clientsStore.loadSupportedClients(), loadAccounts(), loadApiKeys()])
// 先加载 API Keys优先显示列表
await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys()])
// 初始化全选状态
updateSelectAllState()
// 异步加载账号数据(不阻塞页面显示)
loadAccounts()
})
</script>