diff --git a/README.md b/README.md
index b30faad0..f3faacf8 100644
--- a/README.md
+++ b/README.md
@@ -946,6 +946,27 @@ proxy_request_buffering off;
---
+## ❤️ 赞助支持
+
+如果您觉得这个项目对您有帮助,请考虑赞助支持项目的持续开发。您的支持是我们最大的动力!
+
+
+
+
+
+
+
+
+
+  |
+  |
+
+
+
+
+
+---
+
## 📄 许可证
本项目采用 [MIT许可证](LICENSE)。
diff --git a/docs/sponsoring/alipay.jpg b/docs/sponsoring/alipay.jpg
new file mode 100644
index 00000000..95e08a8b
Binary files /dev/null and b/docs/sponsoring/alipay.jpg differ
diff --git a/docs/sponsoring/wechat.jpg b/docs/sponsoring/wechat.jpg
new file mode 100644
index 00000000..52543aa6
Binary files /dev/null and b/docs/sponsoring/wechat.jpg differ
diff --git a/src/models/redis.js b/src/models/redis.js
index 97bf0dec..b39578bb 100644
--- a/src/models/redis.js
+++ b/src/models/redis.js
@@ -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. 排序
diff --git a/src/routes/admin.js b/src/routes/admin.js
index 09198d5b..a0b410dc 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -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 {
diff --git a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue
index a580d628..f699b442 100644
--- a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue
+++ b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue
@@ -1021,8 +1021,7 @@ onMounted(async () => {
}
}
- // 自动加载账号数据
- await refreshAccounts()
+ // 使用缓存的账号数据,不自动刷新(用户可点击"刷新账号"按钮手动刷新)
})
// 刷新账号列表
diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
index 0a5591ba..782b52ac 100644
--- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
+++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
@@ -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'
diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue
index 74c5b7b5..d166fb4d 100644
--- a/web/admin-spa/src/views/ApiKeysView.vue
+++ b/web/admin-spa/src/views/ApiKeysView.vue
@@ -427,84 +427,98 @@
-
+
-
+ 加载中...
+
+
+
+
+
-
- Claude
-
-
- {{ getClaudeBindingInfo(key) }}
-
-
-
-
-
+
+ Claude
+
+
+ {{ getClaudeBindingInfo(key) }}
+
+
+
+
+
+
+ Gemini
+
+
+ {{ getGeminiBindingInfo(key) }}
+
+
+
+
+
+
+ OpenAI
+
+
+ {{ getOpenAIBindingInfo(key) }}
+
+
+
+
-
- Gemini
-
-
- {{ getGeminiBindingInfo(key) }}
-
-
-
-
-
+
+ Bedrock
+
+
+ {{ getBedrockBindingInfo(key) }}
+
+
+
+
+
+
+ Droid
+
+
+ {{ getDroidBindingInfo(key) }}
+
+
+
+
-
- OpenAI
-
-
- {{ getOpenAIBindingInfo(key) }}
-
-
-
-
-
-
- Bedrock
-
-
- {{ getBedrockBindingInfo(key) }}
-
-
-
-
-
-
- Droid
-
-
- {{ getDroidBindingInfo(key) }}
-
-
-
-
-
- 共享池
-
+
+ 共享池
+
+
|
@@ -544,10 +558,12 @@
-
+
@@ -567,83 +583,108 @@
|
-
-
-
-
-
-
-
- 0 ||
+ key.totalCostLimit > 0 ||
+ (key.rateLimitWindow > 0 && key.rateLimitCost > 0))
"
- class="space-y-1.5"
>
-
+
+
+
+
+
-
-
-
-
- {{ key.rateLimitWindow }}分钟窗口
-
-
- {{
- key.windowRemainingSeconds > 0
- ? formatWindowTime(key.windowRemainingSeconds)
- : '未激活'
- }}
-
-
-
-
-
-
- 无限制
-
+
+
+
+
+
+
+
+
+
+
+
+ {{ key.rateLimitWindow }}分钟窗口
+
+
+ {{
+ (getCachedStats(key.id)?.windowRemainingSeconds || 0) > 0
+ ? formatWindowTime(
+ getCachedStats(key.id)?.windowRemainingSeconds || 0
+ )
+ : '未激活'
+ }}
+
+
+
+
+
+
+
+ 无限制
+
+
|
-
+
@@ -664,10 +705,12 @@
|
-
+
@@ -702,8 +745,16 @@
{{ formatLastUsed(key.lastUsedAt) }}
从未使用
+
+
+ 加载中...
+
+
@@ -1272,9 +1323,9 @@
-
-
-
+
@@ -1289,9 +1340,9 @@
-
-
-
+
@@ -1313,8 +1364,16 @@
账号
+
+
+ 加载中...
+
+
-
-
-
-
-
-
-
- 0 ||
+ key.totalCostLimit > 0 ||
+ (key.rateLimitWindow > 0 && key.rateLimitCost > 0))
"
- class="space-y-2"
>
-
+
+
+
+
+
-
-
-
-
- {{ key.rateLimitWindow }}分钟窗口
-
-
- {{
- key.windowRemainingSeconds > 0
- ? formatWindowTime(key.windowRemainingSeconds)
- : '未激活'
- }}
-
-
-
-
-
-
- 无限制
-
+
+
+
+
+
+
+
+
+
+
+
+ {{ key.rateLimitWindow }}分钟窗口
+
+
+ {{
+ (getCachedStats(key.id)?.windowRemainingSeconds || 0) > 0
+ ? formatWindowTime(
+ getCachedStats(key.id)?.windowRemainingSeconds || 0
+ )
+ : '未激活'
+ }}
+
+
+
+
+
+
+
+ 无限制
+
+
@@ -1840,8 +1922,16 @@
{{ formatLastUsed(key.lastUsedAt) }}
从未使用
+
+
+ 加载中...
+
+
@@ -2039,6 +2129,10 @@ const serverPagination = ref({
const statsCache = ref(new Map())
// 正在加载统计的 keyIds
const statsLoading = ref(new Set())
+// 最后使用账号缓存: Map
+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()
})
|