Files
claude-relay-service/web/admin-spa/src/components/common/PublicStatsOverview.vue
Chapoly1305 cfeb4658ad feat: 模型使用分布支持自定义时间范围
- 后端:getPublicModelStats 支持 today/24h/7d/30d/all 五种时间范围
- 后端:新增 publicStatsModelDistributionPeriod 设置项
- 前端:设置页面添加横向选项卡式时间范围选择器
- 前端:公开统计组件显示当前数据时间范围标签

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:43:26 -05:00

633 lines
16 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 v-if="authStore.publicStats" class="public-stats-overview">
<!-- 服务状态徽章 -->
<div class="mb-4 flex items-center justify-center gap-2">
<div
class="status-badge"
:class="{
'status-healthy': authStore.publicStats.serviceStatus === 'healthy',
'status-degraded': authStore.publicStats.serviceStatus === 'degraded'
}"
>
<span class="status-dot"></span>
<span class="status-text">{{
authStore.publicStats.serviceStatus === 'healthy' ? '服务正常' : '服务降级'
}}</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">
运行 {{ formatUptime(authStore.publicStats.uptime) }}
</span>
</div>
<!-- 平台可用性指示器 -->
<div class="mb-4 flex flex-wrap justify-center gap-2">
<div
v-for="(available, platform) in authStore.publicStats.platforms"
:key="platform"
class="platform-badge"
:class="{ available: available, unavailable: !available }"
>
<i class="mr-1" :class="getPlatformIcon(platform)"></i>
<span>{{ getPlatformName(platform) }}</span>
</div>
</div>
<!-- 今日统计 -->
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">{{ formatNumber(authStore.publicStats.todayStats.requests) }}</div>
<div class="stat-label">今日请求</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ formatTokens(authStore.publicStats.todayStats.tokens) }}</div>
<div class="stat-label">今日 Tokens</div>
</div>
</div>
<!-- 模型使用分布 -->
<div
v-if="
authStore.publicStats.showOptions?.modelDistribution &&
authStore.publicStats.modelDistribution?.length > 0
"
class="mt-4"
>
<div class="section-title">
模型使用分布
<span class="period-label">{{
formatPeriodLabel(authStore.publicStats.modelDistributionPeriod)
}}</span>
</div>
<div class="model-distribution">
<div
v-for="model in authStore.publicStats.modelDistribution"
:key="model.model"
class="model-bar-item"
>
<div class="model-name">{{ formatModelName(model.model) }}</div>
<div class="model-bar">
<div class="model-bar-fill" :style="{ width: `${model.percentage}%` }"></div>
</div>
<div class="model-percentage">{{ model.percentage }}%</div>
</div>
</div>
</div>
<!-- 趋势图表三合一双Y轴折线图 -->
<div v-if="hasAnyTrendData" class="mt-4">
<div class="section-title">使用趋势近7天</div>
<div class="chart-container">
<Line :data="chartData" :options="chartOptions" />
</div>
<!-- 图例 -->
<div class="chart-legend">
<div v-if="authStore.publicStats.showOptions?.tokenTrends" class="legend-item">
<span class="legend-dot legend-tokens"></span>
<span class="legend-text">Tokens</span>
</div>
<div v-if="authStore.publicStats.showOptions?.apiKeysTrends" class="legend-item">
<span class="legend-dot legend-keys"></span>
<span class="legend-text">活跃 Keys</span>
</div>
<div v-if="authStore.publicStats.showOptions?.accountTrends" class="legend-item">
<span class="legend-dot legend-accounts"></span>
<span class="legend-text">活跃账号</span>
</div>
</div>
</div>
<!-- 暂无趋势数据 -->
<div v-else-if="hasTrendOptionsEnabled" class="empty-state mt-4">
<i class="fas fa-chart-line empty-icon"></i>
<p class="empty-text">暂无趋势数据</p>
<p class="empty-hint">数据将在有请求后自动更新</p>
</div>
</div>
<!-- 加载状态 -->
<div v-else-if="authStore.publicStatsLoading" class="public-stats-loading">
<div class="loading-spinner"></div>
</div>
<!-- 无数据状态 -->
<div v-else class="public-stats-empty">
<i class="fas fa-chart-pie empty-icon"></i>
<p class="empty-text">暂无统计数据</p>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
// 注册 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
const authStore = useAuthStore()
// 检查是否有任何趋势选项启用
const hasTrendOptionsEnabled = computed(() => {
const opts = authStore.publicStats?.showOptions
return opts?.tokenTrends || opts?.apiKeysTrends || opts?.accountTrends
})
// 检查是否有实际趋势数据
const hasAnyTrendData = computed(() => {
const stats = authStore.publicStats
if (!stats) return false
const opts = stats.showOptions || {}
const hasTokens = opts.tokenTrends && stats.tokenTrends?.length > 0
const hasKeys = opts.apiKeysTrends && stats.apiKeysTrends?.length > 0
const hasAccounts = opts.accountTrends && stats.accountTrends?.length > 0
return hasTokens || hasKeys || hasAccounts
})
// 图表数据
const chartData = computed(() => {
const stats = authStore.publicStats
if (!stats) return { labels: [], datasets: [] }
const opts = stats.showOptions || {}
// 获取日期标签(优先使用 tokenTrends
const labels =
stats.tokenTrends?.map((t) => formatDateShort(t.date)) ||
stats.apiKeysTrends?.map((t) => formatDateShort(t.date)) ||
stats.accountTrends?.map((t) => formatDateShort(t.date)) ||
[]
const datasets = []
// Token 趋势左Y轴
if (opts.tokenTrends && stats.tokenTrends?.length > 0) {
datasets.push({
label: 'Tokens',
data: stats.tokenTrends.map((t) => t.tokens),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
yAxisID: 'y',
tension: 0.3,
fill: true,
pointRadius: 3,
pointHoverRadius: 5
})
}
// API Keys 趋势右Y轴
if (opts.apiKeysTrends && stats.apiKeysTrends?.length > 0) {
datasets.push({
label: '活跃 Keys',
data: stats.apiKeysTrends.map((t) => t.activeKeys),
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
yAxisID: 'y1',
tension: 0.3,
fill: false,
pointRadius: 3,
pointHoverRadius: 5
})
}
// 账号趋势右Y轴
if (opts.accountTrends && stats.accountTrends?.length > 0) {
datasets.push({
label: '活跃账号',
data: stats.accountTrends.map((t) => t.activeAccounts),
borderColor: 'rgb(168, 85, 247)',
backgroundColor: 'rgba(168, 85, 247, 0.1)',
yAxisID: 'y1',
tension: 0.3,
fill: false,
pointRadius: 3,
pointHoverRadius: 5
})
}
return { labels, datasets }
})
// 图表配置
const chartOptions = computed(() => {
const isDark = document.documentElement.classList.contains('dark')
const textColor = isDark ? 'rgba(156, 163, 175, 1)' : 'rgba(107, 114, 128, 1)'
const gridColor = isDark ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)'
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: isDark ? 'rgba(31, 41, 55, 0.9)' : 'rgba(255, 255, 255, 0.9)',
titleColor: isDark ? '#e5e7eb' : '#1f2937',
bodyColor: isDark ? '#d1d5db' : '#4b5563',
borderColor: isDark ? 'rgba(75, 85, 99, 0.5)' : 'rgba(229, 231, 235, 1)',
borderWidth: 1,
padding: 10,
displayColors: true,
callbacks: {
label: function (context) {
let label = context.dataset.label || ''
if (label) {
label += ': '
}
if (context.dataset.yAxisID === 'y') {
label += formatTokens(context.parsed.y)
} else {
label += context.parsed.y
}
return label
}
}
}
},
scales: {
x: {
grid: {
color: gridColor,
drawBorder: false
},
ticks: {
color: textColor,
font: {
size: 10
}
}
},
y: {
type: 'linear',
display: true,
position: 'left',
min: 0,
beginAtZero: true,
title: {
display: true,
text: 'Tokens',
color: 'rgb(59, 130, 246)',
font: {
size: 10
}
},
grid: {
color: gridColor,
drawBorder: false
},
ticks: {
color: textColor,
font: {
size: 10
},
callback: function (value) {
return formatTokensShort(value)
}
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
min: 0,
beginAtZero: true,
title: {
display: true,
text: '数量',
color: 'rgb(34, 197, 94)',
font: {
size: 10
}
},
grid: {
drawOnChartArea: false
},
ticks: {
color: textColor,
font: {
size: 10
},
stepSize: 1
}
}
}
}
})
// 格式化运行时间
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) {
return `${days}${hours}小时`
} else if (hours > 0) {
return `${hours}小时 ${minutes}分钟`
} else {
return `${minutes}分钟`
}
}
// 格式化数字
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
// 格式化 tokens
function formatTokens(tokens) {
if (tokens >= 1000000000) {
return (tokens / 1000000000).toFixed(2) + 'B'
} else if (tokens >= 1000000) {
return (tokens / 1000000).toFixed(2) + 'M'
} else if (tokens >= 1000) {
return (tokens / 1000).toFixed(1) + 'K'
}
return tokens.toString()
}
// 格式化 tokens简短版用于Y轴
function formatTokensShort(tokens) {
if (tokens >= 1000000000) {
return (tokens / 1000000000).toFixed(0) + 'B'
} else if (tokens >= 1000000) {
return (tokens / 1000000).toFixed(0) + 'M'
} else if (tokens >= 1000) {
return (tokens / 1000).toFixed(0) + 'K'
}
return tokens.toString()
}
// 格式化时间范围标签
function formatPeriodLabel(period) {
const labels = {
today: '今天',
'24h': '过去24小时',
'7d': '过去7天',
'30d': '过去30天',
all: '全部'
}
return labels[period] || labels['today']
}
// 获取平台图标
function getPlatformIcon(platform) {
const icons = {
claude: 'fas fa-robot',
gemini: 'fas fa-gem',
bedrock: 'fab fa-aws',
droid: 'fas fa-microchip'
}
return icons[platform] || 'fas fa-server'
}
// 获取平台名称
function getPlatformName(platform) {
const names = {
claude: 'Claude',
gemini: 'Gemini',
bedrock: 'Bedrock',
droid: 'Droid'
}
return names[platform] || platform
}
// 格式化模型名称
function formatModelName(model) {
if (!model) return 'Unknown'
// 简化长模型名称
const parts = model.split('-')
if (parts.length > 2) {
return parts.slice(0, 2).join('-')
}
return model
}
// 格式化日期(短格式)
function formatDateShort(dateStr) {
if (!dateStr) return ''
const parts = dateStr.split('-')
if (parts.length === 3) {
return `${parts[1]}/${parts[2]}`
}
return dateStr
}
</script>
<style scoped>
.public-stats-overview {
@apply rounded-xl border border-gray-200/50 bg-white/80 p-4 backdrop-blur-sm dark:border-gray-700/50 dark:bg-gray-800/80;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 章节标题 */
.section-title {
@apply mb-2 text-center text-xs text-gray-600 dark:text-gray-400;
}
/* 时间范围标签 */
.period-label {
@apply ml-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500 dark:bg-gray-700 dark:text-gray-400;
}
/* 状态徽章 */
.status-badge {
@apply inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium;
}
.status-healthy {
@apply bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400;
}
.status-degraded {
@apply bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400;
}
.status-dot {
@apply inline-block h-2 w-2 rounded-full;
}
.status-healthy .status-dot {
@apply bg-green-500;
animation: pulse 2s infinite;
}
.status-degraded .status-dot {
@apply bg-yellow-500;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* 平台徽章 */
.platform-badge {
@apply inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium transition-all;
}
.platform-badge.available {
@apply bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400;
}
.platform-badge.unavailable {
@apply bg-gray-100 text-gray-400 line-through dark:bg-gray-800 dark:text-gray-600;
}
/* 统计网格 */
.stats-grid {
@apply grid grid-cols-2 gap-3;
}
.stat-item {
@apply rounded-lg bg-gray-50 p-3 text-center dark:bg-gray-700/50;
}
.stat-value {
@apply text-lg font-bold text-gray-900 dark:text-gray-100;
}
.stat-label {
@apply text-xs text-gray-500 dark:text-gray-400;
}
/* 模型分布 */
.model-distribution {
@apply space-y-2;
}
.model-bar-item {
@apply flex items-center gap-2 text-xs;
}
.model-name {
@apply w-20 truncate text-gray-600 dark:text-gray-400;
}
.model-bar {
@apply relative h-2 flex-1 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700;
}
.model-bar-fill {
@apply absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-blue-500 to-purple-500;
transition: width 0.5s ease-out;
}
.model-percentage {
@apply w-10 text-right text-gray-500 dark:text-gray-400;
}
/* 图表容器 */
.chart-container {
@apply rounded-lg bg-gray-50 p-3 dark:bg-gray-700/50;
height: 180px;
}
/* 图例 */
.chart-legend {
@apply mt-2 flex flex-wrap items-center justify-center gap-4;
}
.legend-item {
@apply flex items-center gap-1.5;
}
.legend-dot {
@apply inline-block h-2.5 w-2.5 rounded-full;
}
.legend-tokens {
@apply bg-blue-500;
}
.legend-keys {
@apply bg-green-500;
}
.legend-accounts {
@apply bg-purple-500;
}
.legend-text {
@apply text-xs text-gray-600 dark:text-gray-400;
}
/* 空状态 */
.empty-state {
@apply flex flex-col items-center justify-center rounded-lg bg-gray-50 py-6 dark:bg-gray-700/50;
}
.empty-icon {
@apply mb-2 text-2xl text-gray-400 dark:text-gray-500;
}
.empty-text {
@apply text-sm text-gray-500 dark:text-gray-400;
}
.empty-hint {
@apply mt-1 text-xs text-gray-400 dark:text-gray-500;
}
/* 加载状态 */
.public-stats-loading {
@apply flex items-center justify-center py-8;
}
.public-stats-empty {
@apply flex flex-col items-center justify-center rounded-xl border border-gray-200/50 bg-white/80 py-8 backdrop-blur-sm dark:border-gray-700/50 dark:bg-gray-800/80;
}
.loading-spinner {
@apply h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent;
}
</style>