mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 08:32:17 +00:00
fix: 修复apikeys页面部分bug
This commit is contained in:
21
README.md
21
README.md
@@ -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)。
|
本项目采用 [MIT许可证](LICENSE)。
|
||||||
|
|||||||
BIN
docs/sponsoring/alipay.jpg
Normal file
BIN
docs/sponsoring/alipay.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
BIN
docs/sponsoring/wechat.jpg
Normal file
BIN
docs/sponsoring/wechat.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
@@ -315,14 +315,21 @@ class RedisClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索(apiKey 模式在这里处理,bindingAccount 模式在路由层处理)
|
// 搜索
|
||||||
if (search && searchMode === 'apiKey') {
|
if (search) {
|
||||||
const lowerSearch = search.toLowerCase().trim()
|
const lowerSearch = search.toLowerCase().trim()
|
||||||
filteredKeys = filteredKeys.filter(
|
if (searchMode === 'apiKey') {
|
||||||
(k) =>
|
// apiKey 模式:搜索名称和拥有者
|
||||||
(k.name && k.name.toLowerCase().includes(lowerSearch)) ||
|
filteredKeys = filteredKeys.filter(
|
||||||
(k.ownerDisplayName && k.ownerDisplayName.toLowerCase().includes(lowerSearch))
|
(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. 排序
|
// 4. 排序
|
||||||
|
|||||||
@@ -222,57 +222,24 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
// 获取用户服务来补充owner信息
|
// 获取用户服务来补充owner信息
|
||||||
const userService = require('../services/userService')
|
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,
|
page: pageNum,
|
||||||
pageSize: pageSizeNum,
|
pageSize: pageSizeNum,
|
||||||
searchMode,
|
searchMode,
|
||||||
search: searchMode === 'apiKey' ? search : '', // apiKey 模式的搜索在 redis 层处理
|
search,
|
||||||
tag,
|
tag,
|
||||||
isActive,
|
isActive,
|
||||||
sortBy: validSortBy,
|
sortBy: validSortBy,
|
||||||
sortOrder: validSortOrder
|
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
|
// 为每个API Key添加owner的displayName
|
||||||
for (const apiKey of result.items) {
|
for (const apiKey of result.items) {
|
||||||
if (apiKey.userId) {
|
if (apiKey.userId) {
|
||||||
@@ -547,6 +514,10 @@ router.post('/api-keys/batch-stats', authenticateAdmin, async (req, res) => {
|
|||||||
cacheReadTokens: 0,
|
cacheReadTokens: 0,
|
||||||
cost: 0,
|
cost: 0,
|
||||||
formattedCost: '$0.00',
|
formattedCost: '$0.00',
|
||||||
|
dailyCost: 0,
|
||||||
|
currentWindowCost: 0,
|
||||||
|
windowRemainingSeconds: null,
|
||||||
|
allTimeCost: 0,
|
||||||
error: error.message
|
error: error.message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -732,6 +703,50 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
|
|
||||||
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
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 {
|
return {
|
||||||
requests: totalRequests,
|
requests: totalRequests,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -740,10 +755,109 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
cacheCreateTokens,
|
cacheCreateTokens,
|
||||||
cacheReadTokens,
|
cacheReadTokens,
|
||||||
cost: totalCost,
|
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
|
// 创建新的API Key
|
||||||
router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1021,8 +1021,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动加载账号数据
|
// 使用缓存的账号数据,不自动刷新(用户可点击"刷新账号"按钮手动刷新)
|
||||||
await refreshAccounts()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 刷新账号列表
|
// 刷新账号列表
|
||||||
|
|||||||
@@ -1233,8 +1233,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动加载账号数据
|
// 使用缓存的账号数据,不自动刷新(用户可点击"刷新账号"按钮手动刷新)
|
||||||
await refreshAccounts()
|
|
||||||
|
|
||||||
form.name = props.apiKey.name
|
form.name = props.apiKey.name
|
||||||
|
|
||||||
@@ -1271,11 +1270,16 @@ onMounted(async () => {
|
|||||||
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.tags = props.apiKey.tags || []
|
||||||
// 从后端数据中获取实际的启用状态,而不是根据数组长度推断
|
// 从后端数据中获取实际的启用状态,强制转换为布尔值(Redis返回的是字符串)
|
||||||
form.enableModelRestriction = props.apiKey.enableModelRestriction || false
|
form.enableModelRestriction =
|
||||||
form.enableClientRestriction = props.apiKey.enableClientRestriction || false
|
props.apiKey.enableModelRestriction === true || props.apiKey.enableModelRestriction === 'true'
|
||||||
// 初始化活跃状态,默认为 true
|
form.enableClientRestriction =
|
||||||
form.isActive = props.apiKey.isActive !== undefined ? props.apiKey.isActive : true
|
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'
|
form.ownerId = props.apiKey.userId || 'admin'
|
||||||
|
|||||||
@@ -427,84 +427,98 @@
|
|||||||
<!-- 所属账号列 -->
|
<!-- 所属账号列 -->
|
||||||
<td class="px-3 py-3">
|
<td class="px-3 py-3">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<!-- Claude 绑定 -->
|
<!-- 账号数据加载中 -->
|
||||||
<div
|
<div
|
||||||
v-if="key.claudeAccountId || key.claudeConsoleAccountId"
|
v-if="accountsLoading && hasAnyBinding(key)"
|
||||||
class="flex items-center gap-1 text-xs"
|
class="flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500"
|
||||||
>
|
>
|
||||||
<span
|
<i class="fas fa-spinner fa-spin mr-1"></i>
|
||||||
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"
|
加载中...
|
||||||
|
</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]" />
|
<span
|
||||||
Claude
|
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"
|
||||||
</span>
|
>
|
||||||
<span class="truncate text-gray-600 dark:text-gray-400">
|
<i class="fas fa-brain mr-1 text-[10px]" />
|
||||||
{{ getClaudeBindingInfo(key) }}
|
Claude
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span class="truncate text-gray-600 dark:text-gray-400">
|
||||||
<!-- Gemini 绑定 -->
|
{{ getClaudeBindingInfo(key) }}
|
||||||
<div v-if="key.geminiAccountId" class="flex items-center gap-1 text-xs">
|
</span>
|
||||||
<span
|
</div>
|
||||||
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"
|
<!-- 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]" />
|
<span
|
||||||
Gemini
|
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"
|
||||||
</span>
|
>
|
||||||
<span class="truncate text-gray-600 dark:text-gray-400">
|
<i class="fas fa-cloud mr-1 text-[10px]" />
|
||||||
{{ getGeminiBindingInfo(key) }}
|
Bedrock
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span class="truncate text-gray-600 dark:text-gray-400">
|
||||||
<!-- OpenAI 绑定 -->
|
{{ getBedrockBindingInfo(key) }}
|
||||||
<div v-if="key.openaiAccountId" class="flex items-center gap-1 text-xs">
|
</span>
|
||||||
<span
|
</div>
|
||||||
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"
|
<!-- 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]" />
|
<i class="fas fa-share-alt mr-1" />
|
||||||
OpenAI
|
共享池
|
||||||
</span>
|
</div>
|
||||||
<span class="truncate text-gray-600 dark:text-gray-400">
|
</template>
|
||||||
{{ 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>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<!-- 标签列 -->
|
<!-- 标签列 -->
|
||||||
@@ -544,10 +558,12 @@
|
|||||||
</td>
|
</td>
|
||||||
<!-- 费用 -->
|
<!-- 费用 -->
|
||||||
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
|
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
|
||||||
<!-- 加载中状态 -->
|
<!-- 加载中状态 - 骨架屏 -->
|
||||||
<template v-if="isStatsLoading(key.id)">
|
<template v-if="isStatsLoading(key.id)">
|
||||||
<div class="flex items-center justify-end">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- 已加载状态 -->
|
<!-- 已加载状态 -->
|
||||||
@@ -567,83 +583,108 @@
|
|||||||
<!-- 限制 -->
|
<!-- 限制 -->
|
||||||
<td class="px-2 py-2" style="font-size: 12px">
|
<td class="px-2 py-2" style="font-size: 12px">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<!-- 每日费用限制进度条 -->
|
<!-- 加载中状态 - 骨架屏(仅在有费用限制配置时显示) -->
|
||||||
<LimitProgressBar
|
<template
|
||||||
v-if="key.dailyCostLimit > 0"
|
v-if="
|
||||||
:current="key.dailyCost || 0"
|
isStatsLoading(key.id) &&
|
||||||
label="每日限制"
|
(key.dailyCostLimit > 0 ||
|
||||||
:limit="key.dailyCostLimit"
|
key.totalCostLimit > 0 ||
|
||||||
type="daily"
|
(key.rateLimitWindow > 0 && key.rateLimitCost > 0))
|
||||||
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)
|
|
||||||
"
|
"
|
||||||
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
|
<LimitProgressBar
|
||||||
:current="key.currentWindowCost || 0"
|
v-if="key.dailyCostLimit > 0"
|
||||||
label="窗口费用"
|
:current="getCachedStats(key.id)?.dailyCost || 0"
|
||||||
:limit="key.rateLimitCost"
|
label="每日限制"
|
||||||
type="window"
|
:limit="key.dailyCostLimit"
|
||||||
|
type="daily"
|
||||||
variant="compact"
|
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
|
<LimitProgressBar
|
||||||
v-else
|
v-else-if="key.totalCostLimit > 0"
|
||||||
class="flex items-center justify-center gap-1.5 py-2 text-gray-500 dark:text-gray-400"
|
:current="getCachedStats(key.id)?.allTimeCost || 0"
|
||||||
>
|
label="总费用限制"
|
||||||
<i class="fas fa-infinity text-base" />
|
:limit="key.totalCostLimit"
|
||||||
<span class="text-xs font-medium">无限制</span>
|
type="total"
|
||||||
</div>
|
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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<!-- Token数量 -->
|
<!-- Token数量 -->
|
||||||
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
|
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
|
||||||
<!-- 加载中状态 -->
|
<!-- 加载中状态 - 骨架屏 -->
|
||||||
<template v-if="isStatsLoading(key.id)">
|
<template v-if="isStatsLoading(key.id)">
|
||||||
<div class="flex items-center justify-end">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- 已加载状态 -->
|
<!-- 已加载状态 -->
|
||||||
@@ -664,10 +705,12 @@
|
|||||||
</td>
|
</td>
|
||||||
<!-- 请求数 -->
|
<!-- 请求数 -->
|
||||||
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
|
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
|
||||||
<!-- 加载中状态 -->
|
<!-- 加载中状态 - 骨架屏 -->
|
||||||
<template v-if="isStatsLoading(key.id)">
|
<template v-if="isStatsLoading(key.id)">
|
||||||
<div class="flex items-center justify-end">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- 已加载状态 -->
|
<!-- 已加载状态 -->
|
||||||
@@ -702,8 +745,16 @@
|
|||||||
{{ formatLastUsed(key.lastUsedAt) }}
|
{{ formatLastUsed(key.lastUsedAt) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
|
<span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
|
||||||
|
<!-- 最后使用账号 loading 状态 -->
|
||||||
<span
|
<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"
|
class="mt-1 text-xs text-gray-500 dark:text-gray-400"
|
||||||
:title="getLastUsageFullName(key)"
|
:title="getLastUsageFullName(key)"
|
||||||
>
|
>
|
||||||
@@ -1272,9 +1323,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<!-- 请求数 - 使用缓存统计 -->
|
<!-- 请求数 - 使用缓存统计 -->
|
||||||
<template v-if="isStatsLoading(key.id)">
|
<template v-if="isStatsLoading(key.id)">
|
||||||
<p class="text-sm font-semibold text-gray-400">
|
<div
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
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"
|
||||||
</p>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="getCachedStats(key.id)">
|
<template v-else-if="getCachedStats(key.id)">
|
||||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
@@ -1289,9 +1340,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<!-- 费用 - 使用缓存统计 -->
|
<!-- 费用 - 使用缓存统计 -->
|
||||||
<template v-if="isStatsLoading(key.id)">
|
<template v-if="isStatsLoading(key.id)">
|
||||||
<p class="text-sm font-semibold text-gray-400">
|
<div
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
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"
|
||||||
</p>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="getCachedStats(key.id)">
|
<template v-else-if="getCachedStats(key.id)">
|
||||||
<p class="text-sm font-semibold text-green-600">
|
<p class="text-sm font-semibold text-green-600">
|
||||||
@@ -1313,8 +1364,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-1 flex items-center justify-between">
|
<div class="mt-1 flex items-center justify-between">
|
||||||
<span>账号</span>
|
<span>账号</span>
|
||||||
|
<!-- 最后使用账号 loading 状态 -->
|
||||||
<span
|
<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"
|
class="truncate text-gray-500 dark:text-gray-400"
|
||||||
style="max-width: 180px"
|
style="max-width: 180px"
|
||||||
:title="getLastUsageFullName(key)"
|
:title="getLastUsageFullName(key)"
|
||||||
@@ -1334,75 +1393,98 @@
|
|||||||
|
|
||||||
<!-- 限制进度条 -->
|
<!-- 限制进度条 -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<!-- 每日费用限制 -->
|
<!-- 加载中状态 - 骨架屏(仅在有费用限制配置时显示) -->
|
||||||
<LimitProgressBar
|
<template
|
||||||
v-if="key.dailyCostLimit > 0"
|
v-if="
|
||||||
:current="key.dailyCost || 0"
|
isStatsLoading(key.id) &&
|
||||||
label="每日限制"
|
(key.dailyCostLimit > 0 ||
|
||||||
:limit="key.dailyCostLimit"
|
key.totalCostLimit > 0 ||
|
||||||
type="daily"
|
(key.rateLimitWindow > 0 && key.rateLimitCost > 0))
|
||||||
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)
|
|
||||||
"
|
"
|
||||||
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
|
<LimitProgressBar
|
||||||
:current="key.currentWindowCost || 0"
|
v-if="key.dailyCostLimit > 0"
|
||||||
label="窗口费用"
|
:current="getCachedStats(key.id)?.dailyCost || 0"
|
||||||
:limit="key.rateLimitCost"
|
label="每日限制"
|
||||||
type="window"
|
:limit="key.dailyCostLimit"
|
||||||
|
type="daily"
|
||||||
variant="compact"
|
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
|
<LimitProgressBar
|
||||||
v-else
|
v-else-if="key.totalCostLimit > 0"
|
||||||
class="flex items-center justify-center gap-1.5 py-2 text-gray-500 dark:text-gray-400"
|
:current="getCachedStats(key.id)?.allTimeCost || 0"
|
||||||
>
|
label="总费用限制"
|
||||||
<i class="fas fa-infinity text-base" />
|
:limit="key.totalCostLimit"
|
||||||
<span class="text-xs font-medium">无限制</span>
|
type="total"
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1840,8 +1922,16 @@
|
|||||||
{{ formatLastUsed(key.lastUsedAt) }}
|
{{ formatLastUsed(key.lastUsedAt) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
|
<span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
|
||||||
|
<!-- 最后使用账号 loading 状态 -->
|
||||||
<span
|
<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"
|
class="mt-1 text-xs text-gray-500 dark:text-gray-400"
|
||||||
:title="getLastUsageFullName(key)"
|
:title="getLastUsageFullName(key)"
|
||||||
>
|
>
|
||||||
@@ -2039,6 +2129,10 @@ const serverPagination = ref({
|
|||||||
const statsCache = ref(new Map())
|
const statsCache = ref(new Map())
|
||||||
// 正在加载统计的 keyIds
|
// 正在加载统计的 keyIds
|
||||||
const statsLoading = ref(new Set())
|
const statsLoading = ref(new Set())
|
||||||
|
// 最后使用账号缓存: Map<keyId, lastUsageInfo>
|
||||||
|
const lastUsageCache = ref(new Map())
|
||||||
|
// 正在加载最后使用账号的 keyIds
|
||||||
|
const lastUsageLoading = ref(new Set())
|
||||||
const apiKeyModelStats = ref({})
|
const apiKeyModelStats = ref({})
|
||||||
const apiKeyDateFilters = ref({})
|
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)])
|
||||||
@@ -2054,6 +2148,9 @@ const accounts = ref({
|
|||||||
openaiGroups: [],
|
openaiGroups: [],
|
||||||
droidGroups: []
|
droidGroups: []
|
||||||
})
|
})
|
||||||
|
// 账号数据加载状态
|
||||||
|
const accountsLoading = ref(false)
|
||||||
|
const accountsLoaded = ref(false)
|
||||||
const editingExpiryKey = ref(null)
|
const editingExpiryKey = ref(null)
|
||||||
const expiryEditModalRef = ref(null)
|
const expiryEditModalRef = ref(null)
|
||||||
const showUsageDetailModal = ref(false)
|
const showUsageDetailModal = ref(false)
|
||||||
@@ -2186,8 +2283,14 @@ const paginatedApiKeys = computed(() => {
|
|||||||
return apiKeys.value
|
return apiKeys.value
|
||||||
})
|
})
|
||||||
|
|
||||||
// 加载账户列表
|
// 加载账户列表(支持缓存和强制刷新)
|
||||||
const loadAccounts = async () => {
|
const loadAccounts = async (forceRefresh = false) => {
|
||||||
|
// 如果已加载且不强制刷新,则跳过
|
||||||
|
if (accountsLoaded.value && !forceRefresh) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountsLoading.value = true
|
||||||
try {
|
try {
|
||||||
const [
|
const [
|
||||||
claudeData,
|
claudeData,
|
||||||
@@ -2298,8 +2401,13 @@ const loadAccounts = async () => {
|
|||||||
accounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
accounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
||||||
accounts.value.droidGroups = allGroups.filter((g) => g.platform === 'droid')
|
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) => {
|
const loadApiKeys = async (clearStatsCache = true) => {
|
||||||
apiKeysLoading.value = true
|
apiKeysLoading.value = true
|
||||||
try {
|
try {
|
||||||
// 清除统计缓存(刷新时)
|
// 清除缓存(刷新时)
|
||||||
if (clearStatsCache) {
|
if (clearStatsCache) {
|
||||||
statsCache.value.clear()
|
statsCache.value.clear()
|
||||||
|
lastUsageCache.value.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建请求参数
|
// 构建请求参数
|
||||||
@@ -2375,11 +2484,12 @@ const loadApiKeys = async (clearStatsCache = true) => {
|
|||||||
availableTags.value = data.data.availableTags
|
availableTags.value = data.data.availableTags
|
||||||
}
|
}
|
||||||
|
|
||||||
// 异步加载当前页的统计数据
|
// 异步加载当前页的统计数据(不等待,让页面先显示基础数据)
|
||||||
await loadPageStats()
|
loadPageStats()
|
||||||
|
// 异步加载当前页的最后使用账号数据
|
||||||
|
loadPageLastUsage()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('加载 API Keys 失败:', error)
|
|
||||||
showToast('加载 API Keys 失败', 'error')
|
showToast('加载 API Keys 失败', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
apiKeysLoading.value = false
|
apiKeysLoading.value = false
|
||||||
@@ -2466,6 +2576,53 @@ const isStatsLoading = (keyId) => {
|
|||||||
return statsLoading.value.has(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
|
// 加载已删除的API Keys
|
||||||
const loadDeletedApiKeys = async () => {
|
const loadDeletedApiKeys = async () => {
|
||||||
activeTab.value = 'deleted'
|
activeTab.value = 'deleted'
|
||||||
@@ -2609,6 +2766,18 @@ const getBoundAccountName = (accountId) => {
|
|||||||
return `${accountId.substring(0, 8)}`
|
return `${accountId.substring(0, 8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 API Key 是否有任何账号绑定
|
||||||
|
const hasAnyBinding = (key) => {
|
||||||
|
return !!(
|
||||||
|
key.claudeAccountId ||
|
||||||
|
key.claudeConsoleAccountId ||
|
||||||
|
key.geminiAccountId ||
|
||||||
|
key.openaiAccountId ||
|
||||||
|
key.bedrockAccountId ||
|
||||||
|
key.droidAccountId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取Claude绑定信息
|
// 获取Claude绑定信息
|
||||||
const getClaudeBindingInfo = (key) => {
|
const getClaudeBindingInfo = (key) => {
|
||||||
if (key.claudeAccountId) {
|
if (key.claudeAccountId) {
|
||||||
@@ -3304,18 +3473,24 @@ const resetApiKeyDateFilter = (keyId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 打开创建模态框
|
// 打开创建模态框
|
||||||
const openCreateApiKeyModal = async () => {
|
const openCreateApiKeyModal = () => {
|
||||||
// 重新加载账号数据,确保显示最新的专属账号
|
// 使用缓存的账号数据(如果需要最新数据,用户可以点击"刷新账号"按钮)
|
||||||
await loadAccounts()
|
|
||||||
showCreateApiKeyModal.value = true
|
showCreateApiKeyModal.value = true
|
||||||
|
// 如果账号数据未加载,异步加载
|
||||||
|
if (!accountsLoaded.value) {
|
||||||
|
loadAccounts()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开编辑模态框
|
// 打开编辑模态框
|
||||||
const openEditApiKeyModal = async (apiKey) => {
|
const openEditApiKeyModal = (apiKey) => {
|
||||||
// 重新加载账号数据,确保显示最新的专属账号
|
// 使用缓存的账号数据(如果需要最新数据,用户可以点击"刷新账号"按钮)
|
||||||
await loadAccounts()
|
|
||||||
editingApiKey.value = apiKey
|
editingApiKey.value = apiKey
|
||||||
showEditApiKeyModal.value = true
|
showEditApiKeyModal.value = true
|
||||||
|
// 如果账号数据未加载,异步加载
|
||||||
|
if (!accountsLoaded.value) {
|
||||||
|
loadAccounts()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开续期模态框
|
// 打开续期模态框
|
||||||
@@ -3341,15 +3516,18 @@ const handleBatchCreateSuccess = (data) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 打开批量编辑模态框
|
// 打开批量编辑模态框
|
||||||
const openBatchEditModal = async () => {
|
const openBatchEditModal = () => {
|
||||||
if (selectedApiKeys.value.length === 0) {
|
if (selectedApiKeys.value.length === 0) {
|
||||||
showToast('请先选择要编辑的 API Keys', 'warning')
|
showToast('请先选择要编辑的 API Keys', 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新加载账号数据,确保显示最新的专属账号
|
// 使用缓存的账号数据(如果需要最新数据,用户可以点击"刷新账号"按钮)
|
||||||
await loadAccounts()
|
|
||||||
showBatchEditModal.value = true
|
showBatchEditModal.value = true
|
||||||
|
// 如果账号数据未加载,异步加载
|
||||||
|
if (!accountsLoaded.value) {
|
||||||
|
loadAccounts()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理批量编辑成功
|
// 处理批量编辑成功
|
||||||
@@ -3781,7 +3959,34 @@ const formatWindowTime = (seconds) => {
|
|||||||
|
|
||||||
// 显示使用详情
|
// 显示使用详情
|
||||||
const showUsageDetails = (apiKey) => {
|
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
|
showUsageDetailModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3852,9 +4057,19 @@ const normalizeFrontendAccountCategory = (type) => {
|
|||||||
return 'other'
|
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) => {
|
const hasLastUsageAccount = (apiKey) => {
|
||||||
|
// 如果正在加载,返回 false(让 loading 状态显示)
|
||||||
|
if (isLastUsageLoading(apiKey?.id)) return false
|
||||||
const info = getLastUsageInfo(apiKey)
|
const info = getLastUsageInfo(apiKey)
|
||||||
return !!(info && (info.accountName || info.accountId || info.rawAccountId))
|
return !!(info && (info.accountName || info.accountId || info.rawAccountId))
|
||||||
}
|
}
|
||||||
@@ -4285,11 +4500,14 @@ watch(apiKeys, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 并行加载所有需要的数据
|
// 先加载 API Keys(优先显示列表)
|
||||||
await Promise.all([clientsStore.loadSupportedClients(), loadAccounts(), loadApiKeys()])
|
await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys()])
|
||||||
|
|
||||||
// 初始化全选状态
|
// 初始化全选状态
|
||||||
updateSelectAllState()
|
updateSelectAllState()
|
||||||
|
|
||||||
|
// 异步加载账号数据(不阻塞页面显示)
|
||||||
|
loadAccounts()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user