mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
fix: 优化多key查询卡片
This commit is contained in:
@@ -438,10 +438,9 @@ router.post('/api/batch-stats', async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = redis.getClientSafe()
|
|
||||||
const individualStats = []
|
const individualStats = []
|
||||||
const aggregated = {
|
const aggregated = {
|
||||||
totalKeys: 0,
|
totalKeys: apiIds.length,
|
||||||
activeKeys: 0,
|
activeKeys: 0,
|
||||||
usage: {
|
usage: {
|
||||||
requests: 0,
|
requests: 0,
|
||||||
@@ -475,7 +474,7 @@ router.post('/api/batch-stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 并行查询所有 API Key 数据
|
// 并行查询所有 API Key 数据(复用单key查询逻辑)
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
apiIds.map(async (apiId) => {
|
apiIds.map(async (apiId) => {
|
||||||
const keyData = await redis.getApiKey(apiId)
|
const keyData = await redis.getApiKey(apiId)
|
||||||
@@ -494,76 +493,11 @@ router.post('/api/batch-stats', async (req, res) => {
|
|||||||
return { error: 'Expired', apiId }
|
return { error: 'Expired', apiId }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取使用统计
|
// 复用单key查询的逻辑:获取使用统计
|
||||||
const usage = await redis.getUsageStats(apiId)
|
const usage = await redis.getUsageStats(apiId)
|
||||||
|
|
||||||
// 获取今日和本月统计
|
// 获取费用统计(与单key查询一致)
|
||||||
const tzDate = redis.getDateInTimezone()
|
const costStats = await redis.getCostStats(apiId)
|
||||||
const today = redis.getDateStringInTimezone()
|
|
||||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
|
||||||
|
|
||||||
// 获取今日模型统计
|
|
||||||
const dailyKeys = await client.keys(`usage:${apiId}:model:daily:*:${today}`)
|
|
||||||
const dailyStats = {
|
|
||||||
requests: 0,
|
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
cacheCreateTokens: 0,
|
|
||||||
cacheReadTokens: 0,
|
|
||||||
allTokens: 0,
|
|
||||||
cost: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of dailyKeys) {
|
|
||||||
const data = await client.hgetall(key)
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
|
||||||
dailyStats.requests += parseInt(data.requests) || 0
|
|
||||||
dailyStats.inputTokens += parseInt(data.inputTokens) || 0
|
|
||||||
dailyStats.outputTokens += parseInt(data.outputTokens) || 0
|
|
||||||
dailyStats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
|
||||||
dailyStats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
|
||||||
dailyStats.allTokens += parseInt(data.allTokens) || 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取本月模型统计
|
|
||||||
const monthlyKeys = await client.keys(`usage:${apiId}:model:monthly:*:${currentMonth}`)
|
|
||||||
const monthlyStats = {
|
|
||||||
requests: 0,
|
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
cacheCreateTokens: 0,
|
|
||||||
cacheReadTokens: 0,
|
|
||||||
allTokens: 0,
|
|
||||||
cost: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of monthlyKeys) {
|
|
||||||
const data = await client.hgetall(key)
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
|
||||||
monthlyStats.requests += parseInt(data.requests) || 0
|
|
||||||
monthlyStats.inputTokens += parseInt(data.inputTokens) || 0
|
|
||||||
monthlyStats.outputTokens += parseInt(data.outputTokens) || 0
|
|
||||||
monthlyStats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
|
||||||
monthlyStats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
|
||||||
monthlyStats.allTokens += parseInt(data.allTokens) || 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算费用
|
|
||||||
const calculateCostForStats = (stats) => {
|
|
||||||
const usageData = {
|
|
||||||
input_tokens: stats.inputTokens,
|
|
||||||
output_tokens: stats.outputTokens,
|
|
||||||
cache_creation_input_tokens: stats.cacheCreateTokens,
|
|
||||||
cache_read_input_tokens: stats.cacheReadTokens
|
|
||||||
}
|
|
||||||
const costResult = CostCalculator.calculateCost(usageData, 'claude-3-5-sonnet-20241022')
|
|
||||||
return costResult.costs.total
|
|
||||||
}
|
|
||||||
|
|
||||||
dailyStats.cost = calculateCostForStats(dailyStats)
|
|
||||||
monthlyStats.cost = calculateCostForStats(monthlyStats)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiId,
|
apiId,
|
||||||
@@ -572,8 +506,15 @@ router.post('/api/batch-stats', async (req, res) => {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: keyData.createdAt,
|
createdAt: keyData.createdAt,
|
||||||
usage: usage.total || {},
|
usage: usage.total || {},
|
||||||
dailyStats,
|
dailyStats: {
|
||||||
monthlyStats
|
...usage.daily,
|
||||||
|
cost: costStats.daily
|
||||||
|
},
|
||||||
|
monthlyStats: {
|
||||||
|
...usage.monthly,
|
||||||
|
cost: costStats.monthly
|
||||||
|
},
|
||||||
|
totalCost: costStats.total
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -594,23 +535,26 @@ router.post('/api/batch-stats', async (req, res) => {
|
|||||||
aggregated.usage.allTokens += stats.usage.allTokens || 0
|
aggregated.usage.allTokens += stats.usage.allTokens || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 聚合总费用
|
||||||
|
aggregated.usage.cost += stats.totalCost || 0
|
||||||
|
|
||||||
// 聚合今日使用量
|
// 聚合今日使用量
|
||||||
aggregated.dailyUsage.requests += stats.dailyStats.requests
|
aggregated.dailyUsage.requests += stats.dailyStats.requests || 0
|
||||||
aggregated.dailyUsage.inputTokens += stats.dailyStats.inputTokens
|
aggregated.dailyUsage.inputTokens += stats.dailyStats.inputTokens || 0
|
||||||
aggregated.dailyUsage.outputTokens += stats.dailyStats.outputTokens
|
aggregated.dailyUsage.outputTokens += stats.dailyStats.outputTokens || 0
|
||||||
aggregated.dailyUsage.cacheCreateTokens += stats.dailyStats.cacheCreateTokens
|
aggregated.dailyUsage.cacheCreateTokens += stats.dailyStats.cacheCreateTokens || 0
|
||||||
aggregated.dailyUsage.cacheReadTokens += stats.dailyStats.cacheReadTokens
|
aggregated.dailyUsage.cacheReadTokens += stats.dailyStats.cacheReadTokens || 0
|
||||||
aggregated.dailyUsage.allTokens += stats.dailyStats.allTokens
|
aggregated.dailyUsage.allTokens += stats.dailyStats.allTokens || 0
|
||||||
aggregated.dailyUsage.cost += stats.dailyStats.cost
|
aggregated.dailyUsage.cost += stats.dailyStats.cost || 0
|
||||||
|
|
||||||
// 聚合本月使用量
|
// 聚合本月使用量
|
||||||
aggregated.monthlyUsage.requests += stats.monthlyStats.requests
|
aggregated.monthlyUsage.requests += stats.monthlyStats.requests || 0
|
||||||
aggregated.monthlyUsage.inputTokens += stats.monthlyStats.inputTokens
|
aggregated.monthlyUsage.inputTokens += stats.monthlyStats.inputTokens || 0
|
||||||
aggregated.monthlyUsage.outputTokens += stats.monthlyStats.outputTokens
|
aggregated.monthlyUsage.outputTokens += stats.monthlyStats.outputTokens || 0
|
||||||
aggregated.monthlyUsage.cacheCreateTokens += stats.monthlyStats.cacheCreateTokens
|
aggregated.monthlyUsage.cacheCreateTokens += stats.monthlyStats.cacheCreateTokens || 0
|
||||||
aggregated.monthlyUsage.cacheReadTokens += stats.monthlyStats.cacheReadTokens
|
aggregated.monthlyUsage.cacheReadTokens += stats.monthlyStats.cacheReadTokens || 0
|
||||||
aggregated.monthlyUsage.allTokens += stats.monthlyStats.allTokens
|
aggregated.monthlyUsage.allTokens += stats.monthlyStats.allTokens || 0
|
||||||
aggregated.monthlyUsage.cost += stats.monthlyStats.cost
|
aggregated.monthlyUsage.cost += stats.monthlyStats.cost || 0
|
||||||
|
|
||||||
// 添加到个体统计
|
// 添加到个体统计
|
||||||
individualStats.push({
|
individualStats.push({
|
||||||
@@ -622,23 +566,8 @@ router.post('/api/batch-stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
aggregated.totalKeys = apiIds.length
|
// 格式化费用显示
|
||||||
|
aggregated.usage.formattedCost = CostCalculator.formatCost(aggregated.usage.cost)
|
||||||
// 计算总费用
|
|
||||||
const totalUsageData = {
|
|
||||||
input_tokens: aggregated.usage.inputTokens,
|
|
||||||
output_tokens: aggregated.usage.outputTokens,
|
|
||||||
cache_creation_input_tokens: aggregated.usage.cacheCreateTokens,
|
|
||||||
cache_read_input_tokens: aggregated.usage.cacheReadTokens
|
|
||||||
}
|
|
||||||
const totalCostResult = CostCalculator.calculateCost(
|
|
||||||
totalUsageData,
|
|
||||||
'claude-3-5-sonnet-20241022'
|
|
||||||
)
|
|
||||||
aggregated.usage.cost = totalCostResult.costs.total
|
|
||||||
aggregated.usage.formattedCost = totalCostResult.formatted.total
|
|
||||||
|
|
||||||
// 格式化每日和每月费用
|
|
||||||
aggregated.dailyUsage.formattedCost = CostCalculator.formatCost(aggregated.dailyUsage.cost)
|
aggregated.dailyUsage.formattedCost = CostCalculator.formatCost(aggregated.dailyUsage.cost)
|
||||||
aggregated.monthlyUsage.formattedCost = CostCalculator.formatCost(aggregated.monthlyUsage.cost)
|
aggregated.monthlyUsage.formattedCost = CostCalculator.formatCost(aggregated.monthlyUsage.cost)
|
||||||
|
|
||||||
|
|||||||
183
web/admin-spa/src/components/apistats/AggregatedStatsCard.vue
Normal file
183
web/admin-spa/src/components/apistats/AggregatedStatsCard.vue
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card h-full p-4 md:p-6">
|
||||||
|
<h3
|
||||||
|
class="mb-3 flex flex-col text-lg font-bold text-gray-900 dark:text-gray-100 sm:flex-row sm:items-center md:mb-4 md:text-xl"
|
||||||
|
>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<i class="fas fa-chart-pie mr-2 text-sm text-orange-500 md:mr-3 md:text-base" />
|
||||||
|
使用占比
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
|
||||||
|
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
||||||
|
>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div v-if="aggregatedStats && individualStats.length > 0" class="space-y-2 md:space-y-3">
|
||||||
|
<!-- 各Key使用占比列表 -->
|
||||||
|
<div v-for="(stat, index) in topKeys" :key="stat.apiId" class="relative">
|
||||||
|
<div class="mb-1 flex items-center justify-between text-sm">
|
||||||
|
<span class="truncate font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ stat.name || `Key ${index + 1}` }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{{ calculatePercentage(stat) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
class="h-2 rounded-full transition-all duration-300"
|
||||||
|
:class="getProgressColor(index)"
|
||||||
|
:style="{ width: calculatePercentage(stat) + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<span>{{ formatNumber(stat.usage?.requests || 0) }}次</span>
|
||||||
|
<span>{{ stat.usage?.formattedCost || '$0.00' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 其他Keys汇总 -->
|
||||||
|
<div v-if="otherKeysCount > 0" class="border-t border-gray-200 pt-2 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span>其他 {{ otherKeysCount }} 个Keys</span>
|
||||||
|
<span>{{ otherPercentage }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 单个Key模式提示 -->
|
||||||
|
<div
|
||||||
|
v-else-if="!multiKeyMode"
|
||||||
|
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="fas fa-chart-pie mb-2 text-2xl" />
|
||||||
|
<p>使用占比仅在多Key查询时显示</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chart-pie mr-2" />
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useApiStatsStore } from '@/stores/apistats'
|
||||||
|
|
||||||
|
const apiStatsStore = useApiStatsStore()
|
||||||
|
const { aggregatedStats, individualStats, statsPeriod, multiKeyMode } = storeToRefs(apiStatsStore)
|
||||||
|
|
||||||
|
// 获取TOP Keys(最多显示5个)
|
||||||
|
const topKeys = computed(() => {
|
||||||
|
if (!individualStats.value || individualStats.value.length === 0) return []
|
||||||
|
|
||||||
|
return [...individualStats.value]
|
||||||
|
.sort((a, b) => (b.usage?.cost || 0) - (a.usage?.cost || 0))
|
||||||
|
.slice(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算其他Keys数量
|
||||||
|
const otherKeysCount = computed(() => {
|
||||||
|
if (!individualStats.value) return 0
|
||||||
|
return Math.max(0, individualStats.value.length - 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算其他Keys的占比
|
||||||
|
const otherPercentage = computed(() => {
|
||||||
|
if (!individualStats.value || !aggregatedStats.value) return 0
|
||||||
|
|
||||||
|
const topKeysCost = topKeys.value.reduce((sum, stat) => sum + (stat.usage?.cost || 0), 0)
|
||||||
|
const totalCost =
|
||||||
|
statsPeriod.value === 'daily'
|
||||||
|
? aggregatedStats.value.dailyUsage?.cost || 0
|
||||||
|
: aggregatedStats.value.monthlyUsage?.cost || 0
|
||||||
|
|
||||||
|
if (totalCost === 0) return 0
|
||||||
|
const otherCost = totalCost - topKeysCost
|
||||||
|
return Math.max(0, Math.round((otherCost / totalCost) * 100))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算单个Key的百分比
|
||||||
|
const calculatePercentage = (stat) => {
|
||||||
|
if (!aggregatedStats.value) return 0
|
||||||
|
|
||||||
|
const totalCost =
|
||||||
|
statsPeriod.value === 'daily'
|
||||||
|
? aggregatedStats.value.dailyUsage?.cost || 0
|
||||||
|
: aggregatedStats.value.monthlyUsage?.cost || 0
|
||||||
|
|
||||||
|
if (totalCost === 0) return 0
|
||||||
|
const percentage = ((stat.usage?.cost || 0) / totalCost) * 100
|
||||||
|
return Math.round(percentage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取进度条颜色
|
||||||
|
const getProgressColor = (index) => {
|
||||||
|
const colors = ['bg-blue-500', 'bg-green-500', 'bg-purple-500', 'bg-yellow-500', 'bg-pink-500']
|
||||||
|
return colors[index] || 'bg-gray-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化数字
|
||||||
|
const formatNumber = (num) => {
|
||||||
|
if (typeof num !== 'number') {
|
||||||
|
num = parseInt(num) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (num === 0) return '0'
|
||||||
|
|
||||||
|
if (num >= 1000000) {
|
||||||
|
return (num / 1000000).toFixed(1) + 'M'
|
||||||
|
} else if (num >= 1000) {
|
||||||
|
return (num / 1000).toFixed(1) + 'K'
|
||||||
|
} else {
|
||||||
|
return num.toLocaleString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 卡片样式 - 使用CSS变量 */
|
||||||
|
.card {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||||
|
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .card:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 20px 25px -5px rgba(0, 0, 0, 0.5),
|
||||||
|
0 10px 10px -5px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -123,7 +123,10 @@
|
|||||||
<!-- Token 分布和限制配置 -->
|
<!-- Token 分布和限制配置 -->
|
||||||
<div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2">
|
<div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2">
|
||||||
<TokenDistribution />
|
<TokenDistribution />
|
||||||
<LimitConfig />
|
<!-- 单key模式下显示限制配置 -->
|
||||||
|
<LimitConfig v-if="!multiKeyMode" />
|
||||||
|
<!-- 多key模式下显示聚合统计卡片,填充右侧空白 -->
|
||||||
|
<AggregatedStatsCard v-if="multiKeyMode" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模型使用统计 -->
|
<!-- 模型使用统计 -->
|
||||||
@@ -153,6 +156,7 @@ import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue'
|
|||||||
import StatsOverview from '@/components/apistats/StatsOverview.vue'
|
import StatsOverview from '@/components/apistats/StatsOverview.vue'
|
||||||
import TokenDistribution from '@/components/apistats/TokenDistribution.vue'
|
import TokenDistribution from '@/components/apistats/TokenDistribution.vue'
|
||||||
import LimitConfig from '@/components/apistats/LimitConfig.vue'
|
import LimitConfig from '@/components/apistats/LimitConfig.vue'
|
||||||
|
import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue'
|
||||||
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
|
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
|
||||||
import TutorialView from './TutorialView.vue'
|
import TutorialView from './TutorialView.vue'
|
||||||
|
|
||||||
@@ -175,7 +179,8 @@ const {
|
|||||||
error,
|
error,
|
||||||
statsPeriod,
|
statsPeriod,
|
||||||
statsData,
|
statsData,
|
||||||
oemSettings
|
oemSettings,
|
||||||
|
multiKeyMode
|
||||||
} = storeToRefs(apiStatsStore)
|
} = storeToRefs(apiStatsStore)
|
||||||
|
|
||||||
const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore
|
const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore
|
||||||
|
|||||||
Reference in New Issue
Block a user