mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 添加公开统计概览功能
- 新增 GET /admin/public-stats 公开端点,返回脱敏的服务统计数据 - 在 OEM 设置中添加 publicStatsEnabled 开关 - 创建 PublicStatsOverview 组件,展示服务状态、平台可用性、今日统计和模型使用分布 - 在登录页集成公开统计展示(当 publicStatsEnabled 开启时) - 在设置页品牌设置中添加公开统计开关
This commit is contained in:
@@ -4,6 +4,10 @@ const path = require('path')
|
||||
const axios = require('axios')
|
||||
const claudeCodeHeadersService = require('../../services/claudeCodeHeadersService')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const geminiAccountService = require('../../services/geminiAccountService')
|
||||
const bedrockAccountService = require('../../services/bedrockAccountService')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
@@ -254,30 +258,37 @@ router.get('/check-updates', authenticateAdmin, async (req, res) => {
|
||||
|
||||
// ==================== OEM 设置管理 ====================
|
||||
|
||||
// 获取OEM设置(公开接口,用于显示)
|
||||
// 注意:这个端点没有 authenticateAdmin 中间件,因为前端登录页也需要访问
|
||||
router.get('/oem-settings', async (req, res) => {
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
const oemSettings = await client.get('oem:settings')
|
||||
|
||||
// 默认设置
|
||||
const defaultSettings = {
|
||||
// 默认OEM设置
|
||||
const defaultOemSettings = {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '', // Base64编码的图标数据
|
||||
showAdminButton: true, // 是否显示管理后台按钮
|
||||
publicStatsEnabled: false, // 是否在首页显示公开统计概览
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
let settings = defaultSettings
|
||||
// 获取OEM设置的辅助函数
|
||||
async function getOemSettings() {
|
||||
const client = redis.getClient()
|
||||
const oemSettings = await client.get('oem:settings')
|
||||
|
||||
let settings = { ...defaultOemSettings }
|
||||
if (oemSettings) {
|
||||
try {
|
||||
settings = { ...defaultSettings, ...JSON.parse(oemSettings) }
|
||||
settings = { ...defaultOemSettings, ...JSON.parse(oemSettings) }
|
||||
} catch (err) {
|
||||
logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message)
|
||||
}
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
// 获取OEM设置(公开接口,用于显示)
|
||||
// 注意:这个端点没有 authenticateAdmin 中间件,因为前端登录页也需要访问
|
||||
router.get('/oem-settings', async (req, res) => {
|
||||
try {
|
||||
const settings = await getOemSettings()
|
||||
|
||||
// 添加 LDAP 启用状态到响应中
|
||||
return res.json({
|
||||
@@ -296,7 +307,7 @@ router.get('/oem-settings', async (req, res) => {
|
||||
// 更新OEM设置
|
||||
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { siteName, siteIcon, siteIconData, showAdminButton } = req.body
|
||||
const { siteName, siteIcon, siteIconData, showAdminButton, publicStatsEnabled } = req.body
|
||||
|
||||
// 验证输入
|
||||
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
||||
@@ -328,6 +339,7 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||
siteIcon: (siteIcon || '').trim(),
|
||||
siteIconData: (siteIconData || '').trim(), // Base64数据
|
||||
showAdminButton: showAdminButton !== false, // 默认为true
|
||||
publicStatsEnabled: publicStatsEnabled === true, // 默认为false
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
@@ -398,4 +410,214 @@ router.post('/claude-code-version/clear', authenticateAdmin, async (req, res) =>
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 公开统计概览 ====================
|
||||
|
||||
// 获取公开统计数据(无需认证,用于首页展示)
|
||||
// 只在 publicStatsEnabled 开启时返回数据
|
||||
router.get('/public-stats', async (req, res) => {
|
||||
try {
|
||||
// 检查是否启用了公开统计
|
||||
const settings = await getOemSettings()
|
||||
if (!settings.publicStatsEnabled) {
|
||||
return res.json({
|
||||
success: true,
|
||||
enabled: false,
|
||||
data: null
|
||||
})
|
||||
}
|
||||
|
||||
// 辅助函数:规范化布尔值
|
||||
const normalizeBoolean = (value) => value === true || value === 'true'
|
||||
const isRateLimitedFlag = (status) => {
|
||||
if (!status) return false
|
||||
if (typeof status === 'string') return status === 'limited'
|
||||
if (typeof status === 'object') return status.isRateLimited === true
|
||||
return false
|
||||
}
|
||||
|
||||
// 并行获取统计数据
|
||||
const [
|
||||
claudeAccounts,
|
||||
claudeConsoleAccounts,
|
||||
geminiAccounts,
|
||||
bedrockAccountsResult,
|
||||
droidAccounts,
|
||||
todayStats,
|
||||
modelStats
|
||||
] = await Promise.all([
|
||||
claudeAccountService.getAllAccounts(),
|
||||
claudeConsoleAccountService.getAllAccounts(),
|
||||
geminiAccountService.getAllAccounts(),
|
||||
bedrockAccountService.getAllAccounts(),
|
||||
droidAccountService.getAllAccounts(),
|
||||
redis.getTodayStats(),
|
||||
getPublicModelStats()
|
||||
])
|
||||
|
||||
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
|
||||
|
||||
// 计算各平台正常账户数
|
||||
const normalClaudeAccounts = claudeAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const normalClaudeConsoleAccounts = claudeConsoleAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const normalGeminiAccounts = geminiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(
|
||||
acc.rateLimitStatus === 'limited' ||
|
||||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
)
|
||||
).length
|
||||
const normalBedrockAccounts = bedrockAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const normalDroidAccounts = droidAccounts.filter(
|
||||
(acc) =>
|
||||
normalizeBoolean(acc.isActive) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
normalizeBoolean(acc.schedulable) &&
|
||||
!isRateLimitedFlag(acc.rateLimitStatus)
|
||||
).length
|
||||
|
||||
// 计算总正常账户数
|
||||
const totalNormalAccounts =
|
||||
normalClaudeAccounts +
|
||||
normalClaudeConsoleAccounts +
|
||||
normalGeminiAccounts +
|
||||
normalBedrockAccounts +
|
||||
normalDroidAccounts
|
||||
|
||||
// 判断服务状态
|
||||
const isHealthy = redis.isConnected && totalNormalAccounts > 0
|
||||
|
||||
// 构建公开统计数据(脱敏后的数据)
|
||||
const publicStats = {
|
||||
// 服务状态
|
||||
serviceStatus: isHealthy ? 'healthy' : 'degraded',
|
||||
uptime: process.uptime(),
|
||||
|
||||
// 平台可用性(只显示是否有可用账户,不显示具体数量)
|
||||
platforms: {
|
||||
claude: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
|
||||
gemini: normalGeminiAccounts > 0,
|
||||
bedrock: normalBedrockAccounts > 0,
|
||||
droid: normalDroidAccounts > 0
|
||||
},
|
||||
|
||||
// 今日统计
|
||||
todayStats: {
|
||||
requests: todayStats.requestsToday || 0,
|
||||
tokens: todayStats.tokensToday || 0,
|
||||
inputTokens: todayStats.inputTokensToday || 0,
|
||||
outputTokens: todayStats.outputTokensToday || 0
|
||||
},
|
||||
|
||||
// 模型使用分布(只返回模型名和请求占比,不返回具体数量)
|
||||
modelDistribution: modelStats,
|
||||
|
||||
// 系统时区
|
||||
systemTimezone: config.system.timezoneOffset || 8
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
enabled: true,
|
||||
data: publicStats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get public stats:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get public stats',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取公开模型统计的辅助函数
|
||||
async function getPublicModelStats() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const pattern = `usage:model:daily:*:${today}`
|
||||
const keys = await client.keys(pattern)
|
||||
|
||||
if (keys.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 模型名标准化
|
||||
const normalizeModelName = (model) => {
|
||||
if (!model || model === 'unknown') return model
|
||||
if (model.includes('.anthropic.') || model.includes('.claude')) {
|
||||
let normalized = model.replace(/^[a-z0-9-]+\./, '')
|
||||
normalized = normalized.replace('anthropic.', '')
|
||||
normalized = normalized.replace(/-v\d+:\d+$/, '')
|
||||
return normalized
|
||||
}
|
||||
return model.replace(/-v\d+:\d+|:latest$/, '')
|
||||
}
|
||||
|
||||
// 聚合模型数据
|
||||
const modelStatsMap = new Map()
|
||||
let totalRequests = 0
|
||||
|
||||
for (const key of keys) {
|
||||
const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
|
||||
if (!match) continue
|
||||
|
||||
const rawModel = match[1]
|
||||
const normalizedModel = normalizeModelName(rawModel)
|
||||
const data = await client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const requests = parseInt(data.requests) || 0
|
||||
totalRequests += requests
|
||||
|
||||
const stats = modelStatsMap.get(normalizedModel) || { requests: 0 }
|
||||
stats.requests += requests
|
||||
modelStatsMap.set(normalizedModel, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为数组并计算占比
|
||||
const modelStats = []
|
||||
for (const [model, stats] of modelStatsMap) {
|
||||
modelStats.push({
|
||||
model,
|
||||
percentage: totalRequests > 0 ? Math.round((stats.requests / totalRequests) * 100) : 0
|
||||
})
|
||||
}
|
||||
|
||||
// 按占比排序,取前5个
|
||||
modelStats.sort((a, b) => b.percentage - a.percentage)
|
||||
return modelStats.slice(0, 5)
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ Failed to get public model stats:', error.message)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router
|
||||
|
||||
266
web/admin-spa/src/components/common/PublicStatsOverview.vue
Normal file
266
web/admin-spa/src/components/common/PublicStatsOverview.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<ThemeToggle mode="dropdown" />
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user