Files
claude-relay-service/web/admin-spa/src/components/apistats/AggregatedStatsCard.vue
Wangnov 4aae4aaec0 feat: 完成API统计组件完整国际化支持
- 完成6个apistats组件的全面国际化改造
  * ModelUsageStats.vue - 模型使用统计
  * AggregatedStatsCard.vue - 聚合统计卡片
  * StatsOverview.vue - 统计概览
  * LimitConfig.vue - 限制配置
  * TokenDistribution.vue - Token使用分布
  * ApiKeyInput.vue - API Key输入组件

- 扩展三语言翻译支持(zh-cn/zh-tw/en)
  * 新增100+专业翻译键涵盖所有UI文字
  * 台湾本地化的繁体中文翻译
  * 技术专业的英文术语翻译
  * 支持参数化翻译处理动态内容

- 技术优化
  * 统一使用Vue 3 Composition API的useI18n()模式
  * 智能日期格式国际化处理
  * 完全消除硬编码中文文字
  * 支持条件性翻译和动态时间段显示

现在整个API统计功能模块支持完整的多语言切换体验
2025-09-12 00:03:02 +08:00

206 lines
6.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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" />
{{ t('apiStats.usageRatio') }}
</span>
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
>({{ statsPeriod === 'daily' ? t('apiStats.today') : t('apiStats.thisMonth') }})</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(getStatUsage(stat)?.requests || 0) }}{{ t('apiStats.requests') }}</span>
<span>{{ getStatUsage(stat)?.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>{{ t('apiStats.otherKeys') }} {{ otherKeysCount }} {{ t('apiStats.individual') }}{{ t('apiStats.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>{{ t('apiStats.usageRatioOnlyInMultiMode') }}</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" />
{{ t('apiStats.noData') }}
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useApiStatsStore } from '@/stores/apistats'
const { t } = useI18n()
const apiStatsStore = useApiStatsStore()
const { aggregatedStats, individualStats, statsPeriod, multiKeyMode } = storeToRefs(apiStatsStore)
// 获取当前时间段的使用数据
const getStatUsage = (stat) => {
if (!stat) return null
if (statsPeriod.value === 'daily') {
return stat.dailyUsage || stat.usage
} else {
return stat.monthlyUsage || stat.usage
}
}
// 获取TOP Keys最多显示5个
const topKeys = computed(() => {
if (!individualStats.value || individualStats.value.length === 0) return []
return [...individualStats.value]
.sort((a, b) => {
const aUsage = getStatUsage(a)
const bUsage = getStatUsage(b)
return (bUsage?.cost || 0) - (aUsage?.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) => {
const usage = getStatUsage(stat)
return sum + (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 usage = getStatUsage(stat)
const percentage = ((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>