diff --git a/scripts/data-transfer.js b/scripts/data-transfer.js index 19950958..39ca10d3 100644 --- a/scripts/data-transfer.js +++ b/scripts/data-transfer.js @@ -117,7 +117,7 @@ const CSV_FIELD_MAPPING = { concurrencyLimit: '并发限制', dailyCostLimit: '日费用限制(美元)', totalCostLimit: '总费用限制(美元)', - weeklyOpusCostLimit: '周Opus费用限制(美元)', + weeklyOpusCostLimit: '周Claude费用限制(美元)', // 账户绑定 claudeAccountId: 'Claude专属账户', diff --git a/src/routes/admin/apiKeys.js b/src/routes/admin/apiKeys.js index f2149ee8..0c763c03 100644 --- a/src/routes/admin/apiKeys.js +++ b/src/routes/admin/apiKeys.js @@ -1029,6 +1029,7 @@ router.post('/api-keys/batch-stats', authenticateAdmin, async (req, res) => { cost: 0, formattedCost: '$0.00', dailyCost: 0, + weeklyOpusCost: 0, currentWindowCost: 0, windowRemainingSeconds: null, allTimeCost: 0, @@ -1109,6 +1110,7 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) { // 获取实时限制数据(窗口数据不受时间范围筛选影响,始终获取当前窗口状态) let dailyCost = 0 + let weeklyOpusCost = 0 // 字段名沿用 weeklyOpusCost*,语义为“Claude 周费用” let currentWindowCost = 0 let windowRemainingSeconds = null let windowStartTime = null @@ -1121,6 +1123,7 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) { const rateLimitWindow = parseInt(apiKey?.rateLimitWindow) || 0 const dailyCostLimit = parseFloat(apiKey?.dailyCostLimit) || 0 const totalCostLimit = parseFloat(apiKey?.totalCostLimit) || 0 + const weeklyOpusCostLimit = parseFloat(apiKey?.weeklyOpusCostLimit) || 0 // 只在启用了每日费用限制时查询 if (dailyCostLimit > 0) { @@ -1133,6 +1136,11 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) { allTimeCost = parseFloat((await client.get(totalCostKey)) || '0') } + // 只在启用了 Claude 周费用限制时查询(字段名沿用 weeklyOpusCostLimit) + if (weeklyOpusCostLimit > 0) { + weeklyOpusCost = await redis.getWeeklyOpusCost(keyId) + } + // 🔧 FIX: 对于 "全部时间" 时间范围,直接使用 allTimeCost // 因为 usage:*:model:daily:* 键有 30 天 TTL,旧数据已经过期 if (timeRange === 'all' && allTimeCost > 0) { @@ -1149,6 +1157,7 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) { formattedCost: CostCalculator.formatCost(allTimeCost), // 实时限制数据(始终返回,不受时间范围影响) dailyCost, + weeklyOpusCost, currentWindowCost, windowRemainingSeconds, windowStartTime, @@ -1199,6 +1208,7 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) { formattedCost: '$0.00', // 实时限制数据(始终返回,不受时间范围影响) dailyCost, + weeklyOpusCost, currentWindowCost, windowRemainingSeconds, windowStartTime, @@ -1317,6 +1327,7 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) { formattedCost: CostCalculator.formatCost(totalCost), // 实时限制数据 dailyCost, + weeklyOpusCost, currentWindowCost, windowRemainingSeconds, windowStartTime, diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 9eb44c4d..0ca80b9f 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -762,7 +762,7 @@ class ApiKeyService { for (const key of apiKeys) { key.usage = await redis.getUsageStats(key.id) const costStats = await redis.getCostStats(key.id) - // Add cost information to usage object for frontend compatibility + // 为前端兼容性:把费用信息同步到 usage 对象里 if (key.usage && costStats) { key.usage.total = key.usage.total || {} key.usage.total.cost = costStats.total diff --git a/web/admin-spa/src/components/apikeys/LimitProgressBar.vue b/web/admin-spa/src/components/apikeys/LimitProgressBar.vue index 6f2975e6..f84e1db2 100644 --- a/web/admin-spa/src/components/apikeys/LimitProgressBar.vue +++ b/web/admin-spa/src/components/apikeys/LimitProgressBar.vue @@ -2,7 +2,7 @@
@@ -18,7 +18,7 @@ {{ label }}
${{ current.toFixed(2) }} / ${{ limit.toFixed(2) }}${{ currentValue.toFixed(2) }} / ${{ limitValue.toFixed(2) }}
@@ -57,7 +57,7 @@
- ${{ current.toFixed(2) }} / ${{ limit.toFixed(2) }} + ${{ currentValue.toFixed(2) }} / ${{ limitValue.toFixed(2) }}
@@ -95,11 +95,11 @@ const props = defineProps({ required: true }, current: { - type: Number, + type: [Number, String], default: 0 }, limit: { - type: Number, + type: [Number, String], required: true }, showShine: { @@ -109,10 +109,18 @@ const props = defineProps({ }) const isCompact = computed(() => props.variant === 'compact') +const currentValue = computed(() => { + const n = Number(props.current) + return Number.isFinite(n) ? n : 0 +}) +const limitValue = computed(() => { + const n = Number(props.limit) + return Number.isFinite(n) ? n : 0 +}) const progress = computed(() => { // 无限制时不显示进度条 - if (!props.limit || props.limit <= 0) return 0 - const percentage = (props.current / props.limit) * 100 + if (!limitValue.value || limitValue.value <= 0) return 0 + const percentage = (currentValue.value / limitValue.value) * 100 return Math.min(percentage, 100) }) diff --git a/web/admin-spa/src/components/apikeys/UsageDetailModal.vue b/web/admin-spa/src/components/apikeys/UsageDetailModal.vue index 06ac3d36..871c9c3f 100644 --- a/web/admin-spa/src/components/apikeys/UsageDetailModal.vue +++ b/web/admin-spa/src/components/apikeys/UsageDetailModal.vue @@ -155,11 +155,11 @@ 限制设置
-
+
@@ -168,11 +168,11 @@
-
+
@@ -181,11 +181,11 @@
-
+
@@ -195,7 +195,7 @@
并发限制 @@ -204,11 +204,25 @@
-
+
时间窗口限制
+
+ 未设置窗口时长(rateLimitWindow=0),窗口限制不会生效。 +
+ + +
+
+ + 访问控制 +
+ +
+
+ 服务权限 + + {{ permissionsDisplay }} + +
+
+ +
+
+ 模型限制(禁用列表) + + {{ restrictedModels.length }} + +
+
+ + {{ model }} + +
+
未配置具体模型
+
+ +
+
+ 客户端限制(允许列表) + + {{ allowedClients.length }} + +
+
+ + {{ client }} + +
+
未配置客户端
+
+
@@ -280,14 +357,48 @@ const cacheReadTokens = computed(() => props.apiKey.usage?.total?.cacheReadToken const rpm = computed(() => props.apiKey.usage?.averages?.rpm || 0) const tpm = computed(() => props.apiKey.usage?.averages?.tpm || 0) +const enableModelRestriction = computed( + () => + props.apiKey.enableModelRestriction === true || props.apiKey.enableModelRestriction === 'true' +) +const restrictedModels = computed(() => + Array.isArray(props.apiKey.restrictedModels) ? props.apiKey.restrictedModels : [] +) +const enableClientRestriction = computed( + () => + props.apiKey.enableClientRestriction === true || props.apiKey.enableClientRestriction === 'true' +) +const allowedClients = computed(() => + Array.isArray(props.apiKey.allowedClients) ? props.apiKey.allowedClients : [] +) +const permissions = computed(() => props.apiKey.permissions || []) + +const permissionsDisplay = computed(() => { + if (!Array.isArray(permissions.value) || permissions.value.length === 0) { + return '全部' + } + return permissions.value.join(', ') +}) + +const hasAccessRestrictions = computed(() => { + return ( + enableModelRestriction.value || + enableClientRestriction.value || + (Array.isArray(permissions.value) && permissions.value.length > 0) + ) +}) + const hasLimits = computed(() => { return ( - props.apiKey.dailyCostLimit > 0 || - props.apiKey.totalCostLimit > 0 || - props.apiKey.concurrencyLimit > 0 || - props.apiKey.weeklyOpusCostLimit > 0 || - props.apiKey.rateLimitWindow > 0 || - props.apiKey.tokenLimit > 0 + Number(props.apiKey.dailyCostLimit) > 0 || + Number(props.apiKey.totalCostLimit) > 0 || + Number(props.apiKey.concurrencyLimit) > 0 || + Number(props.apiKey.weeklyOpusCostLimit) > 0 || + Number(props.apiKey.rateLimitWindow) > 0 || + Number(props.apiKey.rateLimitRequests) > 0 || + Number(props.apiKey.rateLimitCost) > 0 || + Number(props.apiKey.tokenLimit) > 0 || + hasAccessRestrictions.value ) }) diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index a68ca4e4..aceeb197 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -4261,6 +4261,7 @@ const showUsageDetails = (apiKey) => { const enrichedApiKey = { ...apiKey, dailyCost: cachedStats?.dailyCost ?? apiKey.dailyCost ?? 0, + weeklyOpusCost: cachedStats?.weeklyOpusCost ?? apiKey.weeklyOpusCost ?? 0, currentWindowCost: cachedStats?.currentWindowCost ?? apiKey.currentWindowCost ?? 0, windowRemainingSeconds: cachedStats?.windowRemainingSeconds ?? apiKey.windowRemainingSeconds, windowStartTime: cachedStats?.windowStartTime ?? apiKey.windowStartTime ?? null,