feat: 添加公开统计概览功能

- 新增 GET /admin/public-stats 公开端点,返回脱敏的服务统计数据
- 在 OEM 设置中添加 publicStatsEnabled 开关
- 创建 PublicStatsOverview 组件,展示服务状态、平台可用性、今日统计和模型使用分布
- 在登录页集成公开统计展示(当 publicStatsEnabled 开启时)
- 在设置页品牌设置中添加公开统计开关
This commit is contained in:
Claude
2025-12-23 01:48:55 +00:00
parent 0173ab224b
commit 82d1489a55
6 changed files with 632 additions and 27 deletions

View File

@@ -0,0 +1,266 @@
<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="getPlatformIcon(platform)" class="mr-1"></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.modelDistribution?.length > 0" class="mt-4">
<div class="mb-2 text-center text-xs text-gray-600 dark:text-gray-400">模型使用分布</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>
</div>
<!-- 加载状态 -->
<div v-else-if="authStore.publicStatsLoading" class="public-stats-loading">
<div class="loading-spinner"></div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
// 格式化运行时间
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()
}
// 获取平台图标
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
}
</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);
}
}
/* 状态徽章 */
.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;
}
/* 加载状态 */
.public-stats-loading {
@apply flex items-center justify-center py-8;
}
.loading-spinner {
@apply h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent;
}
</style>

View File

@@ -14,10 +14,15 @@ export const useAuthStore = defineStore('auth', () => {
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: '',
faviconData: ''
faviconData: '',
publicStatsEnabled: false
})
const oemLoading = ref(true)
// 公开统计数据
const publicStats = ref(null)
const publicStatsLoading = ref(false)
// 计算属性
const isAuthenticated = computed(() => !!authToken.value && isLoggedIn.value)
const token = computed(() => authToken.value)
@@ -104,6 +109,11 @@ export const useAuthStore = defineStore('auth', () => {
if (result.data.siteName) {
document.title = `${result.data.siteName} - 管理后台`
}
// 如果公开统计已启用,加载统计数据
if (result.data.publicStatsEnabled) {
loadPublicStats()
}
}
} catch (error) {
console.error('加载OEM设置失败:', error)
@@ -112,6 +122,23 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function loadPublicStats() {
publicStatsLoading.value = true
try {
const result = await apiClient.get('/admin/public-stats')
if (result.success && result.enabled && result.data) {
publicStats.value = result.data
} else {
publicStats.value = null
}
} catch (error) {
console.error('加载公开统计失败:', error)
publicStats.value = null
} finally {
publicStatsLoading.value = false
}
}
return {
// 状态
isLoggedIn,
@@ -121,6 +148,8 @@ export const useAuthStore = defineStore('auth', () => {
loginLoading,
oemSettings,
oemLoading,
publicStats,
publicStatsLoading,
// 计算属性
isAuthenticated,
@@ -131,6 +160,7 @@ export const useAuthStore = defineStore('auth', () => {
login,
logout,
checkAuth,
loadOemSettings
loadOemSettings,
loadPublicStats
}
})

View File

@@ -9,6 +9,7 @@ export const useSettingsStore = defineStore('settings', () => {
siteIcon: '',
siteIconData: '',
showAdminButton: true, // 控制管理后台按钮的显示
publicStatsEnabled: false, // 是否在首页显示公开统计概览
updatedAt: null
})
@@ -66,6 +67,7 @@ export const useSettingsStore = defineStore('settings', () => {
siteIcon: '',
siteIconData: '',
showAdminButton: true,
publicStatsEnabled: false,
updatedAt: null
}

View File

@@ -5,9 +5,11 @@
<ThemeToggle mode="dropdown" />
</div>
<div
class="glass-strong w-full max-w-md rounded-xl p-6 shadow-2xl sm:rounded-2xl sm:p-8 md:rounded-3xl md:p-10"
>
<div class="flex w-full max-w-4xl flex-col items-center gap-6 lg:flex-row lg:items-start">
<!-- 登录卡片 -->
<div
class="glass-strong w-full max-w-md rounded-xl p-6 shadow-2xl sm:rounded-2xl sm:p-8 md:rounded-3xl md:p-10"
>
<div class="mb-6 text-center sm:mb-8">
<!-- 使用自定义布局来保持登录页面的居中大logo样式 -->
<div
@@ -92,6 +94,15 @@
<i class="fas fa-exclamation-triangle mr-2" />{{ authStore.loginError }}
</div>
</div>
<!-- 公开统计概览 -->
<div
v-if="authStore.oemSettings.publicStatsEnabled && authStore.publicStats"
class="w-full max-w-md lg:max-w-sm"
>
<PublicStatsOverview />
</div>
</div>
</div>
</template>
@@ -100,6 +111,7 @@ import { ref, onMounted, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
import PublicStatsOverview from '@/components/common/PublicStatsOverview.vue'
const authStore = useAuthStore()
const themeStore = useThemeStore()

View File

@@ -194,6 +194,45 @@
</td>
</tr>
<!-- 公开统计概览 -->
<tr class="table-row">
<td class="w-48 whitespace-nowrap px-6 py-4">
<div class="flex items-center">
<div
class="mr-3 flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-green-500 to-emerald-600"
>
<i class="fas fa-chart-bar text-xs text-white" />
</div>
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
公开统计
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">登录页展示</div>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="flex items-center">
<label class="inline-flex cursor-pointer items-center">
<input
v-model="oemSettings.publicStatsEnabled"
class="peer sr-only"
type="checkbox"
/>
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-green-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-green-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-green-800"
></div>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{{
oemSettings.publicStatsEnabled ? '显示统计概览' : '隐藏统计概览'
}}</span>
</label>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
启用后未登录用户可在首页看到服务状态模型使用分布等概览信息
</p>
</td>
</tr>
<!-- 操作按钮 -->
<tr>
<td class="px-6 py-6" colspan="2">
@@ -346,6 +385,39 @@
</div>
</div>
<!-- 公开统计卡片 -->
<div class="glass-card p-4">
<div class="mb-3 flex items-center gap-3">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-emerald-600 text-white shadow-md"
>
<i class="fas fa-chart-bar"></i>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">公开统计</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">在登录页展示统计概览</p>
</div>
</div>
<div class="space-y-2">
<label class="inline-flex cursor-pointer items-center">
<input
v-model="oemSettings.publicStatsEnabled"
class="peer sr-only"
type="checkbox"
/>
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-green-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-green-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-green-800"
></div>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{{
oemSettings.publicStatsEnabled ? '显示统计概览' : '隐藏统计概览'
}}</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
启用后未登录用户可在首页看到服务状态模型使用分布等概览信息
</p>
</div>
</div>
<!-- 操作按钮卡片 -->
<div class="glass-card p-4">
<div class="flex flex-col gap-3">
@@ -2467,7 +2539,8 @@ const saveOemSettings = async () => {
siteName: oemSettings.value.siteName,
siteIcon: oemSettings.value.siteIcon,
siteIconData: oemSettings.value.siteIconData,
showAdminButton: oemSettings.value.showAdminButton
showAdminButton: oemSettings.value.showAdminButton,
publicStatsEnabled: oemSettings.value.publicStatsEnabled
}
const result = await settingsStore.saveOemSettings(settings)
if (result && result.success) {