From 82d1489a5591340048c2e1843ba4c2ad59293892 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Dec 2025 01:48:55 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E7=BB=9F=E8=AE=A1=E6=A6=82=E8=A7=88=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GET /admin/public-stats 公开端点,返回脱敏的服务统计数据 - 在 OEM 设置中添加 publicStatsEnabled 开关 - 创建 PublicStatsOverview 组件,展示服务状态、平台可用性、今日统计和模型使用分布 - 在登录页集成公开统计展示(当 publicStatsEnabled 开启时) - 在设置页品牌设置中添加公开统计开关 --- src/routes/admin/system.js | 264 +++++++++++++++-- .../components/common/PublicStatsOverview.vue | 266 ++++++++++++++++++ web/admin-spa/src/stores/auth.js | 34 ++- web/admin-spa/src/stores/settings.js | 2 + web/admin-spa/src/views/LoginView.vue | 18 +- web/admin-spa/src/views/SettingsView.vue | 75 ++++- 6 files changed, 632 insertions(+), 27 deletions(-) create mode 100644 web/admin-spa/src/components/common/PublicStatsOverview.vue diff --git a/src/routes/admin/system.js b/src/routes/admin/system.js index 5692103c..6f8e2382 100644 --- a/src/routes/admin/system.js +++ b/src/routes/admin/system.js @@ -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设置 +const defaultOemSettings = { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', // Base64编码的图标数据 + showAdminButton: true, // 是否显示管理后台按钮 + publicStatsEnabled: false, // 是否在首页显示公开统计概览 + updatedAt: new Date().toISOString() +} + +// 获取OEM设置的辅助函数 +async function getOemSettings() { + const client = redis.getClient() + const oemSettings = await client.get('oem:settings') + + let settings = { ...defaultOemSettings } + if (oemSettings) { + try { + 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 client = redis.getClient() - const oemSettings = await client.get('oem:settings') - - // 默认设置 - const defaultSettings = { - siteName: 'Claude Relay Service', - siteIcon: '', - siteIconData: '', // Base64编码的图标数据 - showAdminButton: true, // 是否显示管理后台按钮 - updatedAt: new Date().toISOString() - } - - let settings = defaultSettings - if (oemSettings) { - try { - settings = { ...defaultSettings, ...JSON.parse(oemSettings) } - } catch (err) { - logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message) - } - } + 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 diff --git a/web/admin-spa/src/components/common/PublicStatsOverview.vue b/web/admin-spa/src/components/common/PublicStatsOverview.vue new file mode 100644 index 00000000..81a52042 --- /dev/null +++ b/web/admin-spa/src/components/common/PublicStatsOverview.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/web/admin-spa/src/stores/auth.js b/web/admin-spa/src/stores/auth.js index bbd39c62..e36083e5 100644 --- a/web/admin-spa/src/stores/auth.js +++ b/web/admin-spa/src/stores/auth.js @@ -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 } }) diff --git a/web/admin-spa/src/stores/settings.js b/web/admin-spa/src/stores/settings.js index 986ce327..fb1f2d7b 100644 --- a/web/admin-spa/src/stores/settings.js +++ b/web/admin-spa/src/stores/settings.js @@ -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 } diff --git a/web/admin-spa/src/views/LoginView.vue b/web/admin-spa/src/views/LoginView.vue index 62138897..fc2e8380 100644 --- a/web/admin-spa/src/views/LoginView.vue +++ b/web/admin-spa/src/views/LoginView.vue @@ -5,9 +5,11 @@ -
+
+ +
{{ authStore.loginError }}
+ + +
+ +
+
@@ -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() diff --git a/web/admin-spa/src/views/SettingsView.vue b/web/admin-spa/src/views/SettingsView.vue index b9b260a2..0806302c 100644 --- a/web/admin-spa/src/views/SettingsView.vue +++ b/web/admin-spa/src/views/SettingsView.vue @@ -194,6 +194,45 @@ + + + +
+
+ +
+
+
+ 公开统计 +
+
登录页展示
+
+
+ + +
+ +
+

+ 启用后,未登录用户可在首页看到服务状态、模型使用分布等概览信息 +

+ + + @@ -346,6 +385,39 @@
+ +
+
+
+ +
+
+

公开统计

+

在登录页展示统计概览

+
+
+
+ +

+ 启用后,未登录用户可在首页看到服务状态、模型使用分布等概览信息 +

+
+
+
@@ -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) { From ab474c33226f73c3c256260cef508c14d2727a20 Mon Sep 17 00:00:00 2001 From: Chapoly1305 Date: Tue, 23 Dec 2025 17:30:00 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E5=B0=86=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E6=A6=82=E8=A7=88=E7=A7=BB=E8=87=B3=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E7=8A=B6=E6=80=81=E6=A6=82=E8=A7=88=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在首页添加"状态概览"标签,作为默认显示页面 - 修复 PublicStatsOverview.vue 属性顺序 lint 错误 - 修复 LoginView.vue prettier 格式问题 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/common/PublicStatsOverview.vue | 2 +- web/admin-spa/src/views/ApiStatsView.vue | 38 ++++- web/admin-spa/src/views/LoginView.vue | 158 +++++++++--------- 3 files changed, 115 insertions(+), 83 deletions(-) diff --git a/web/admin-spa/src/components/common/PublicStatsOverview.vue b/web/admin-spa/src/components/common/PublicStatsOverview.vue index 81a52042..cee9665c 100644 --- a/web/admin-spa/src/components/common/PublicStatsOverview.vue +++ b/web/admin-spa/src/components/common/PublicStatsOverview.vue @@ -27,7 +27,7 @@ class="platform-badge" :class="{ available: available, unavailable: !available }" > - + {{ getPlatformName(platform) }}
diff --git a/web/admin-spa/src/views/ApiStatsView.vue b/web/admin-spa/src/views/ApiStatsView.vue index c5b0d51a..306e194e 100644 --- a/web/admin-spa/src/views/ApiStatsView.vue +++ b/web/admin-spa/src/views/ApiStatsView.vue @@ -6,7 +6,13 @@
@@ -49,6 +55,13 @@
+
+ +
+
+ +
+
+
@@ -174,6 +194,7 @@ import { useRoute } from 'vue-router' import { storeToRefs } from 'pinia' import { useApiStatsStore } from '@/stores/apistats' import { useThemeStore } from '@/stores/theme' +import { useAuthStore } from '@/stores/auth' import LogoTitle from '@/components/common/LogoTitle.vue' import ThemeToggle from '@/components/common/ThemeToggle.vue' import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue' @@ -184,13 +205,15 @@ import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue' import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue' import TutorialView from './TutorialView.vue' import ApiKeyTestModal from '@/components/apikeys/ApiKeyTestModal.vue' +import PublicStatsOverview from '@/components/common/PublicStatsOverview.vue' const route = useRoute() const apiStatsStore = useApiStatsStore() const themeStore = useThemeStore() +const authStore = useAuthStore() -// 当前标签页 -const currentTab = ref('stats') +// 当前标签页 - 默认显示状态概览 +const currentTab = ref('overview') // 主题相关 const isDarkMode = computed(() => themeStore.isDarkMode) @@ -223,6 +246,12 @@ const closeTestModal = () => { showTestModal.value = false } +// 切换到状态概览并加载数据 +const switchToOverview = () => { + currentTab.value = 'overview' + authStore.loadPublicStats() +} + // 处理键盘快捷键 const handleKeyDown = (event) => { // Ctrl/Cmd + Enter 查询 @@ -249,6 +278,9 @@ onMounted(() => { // 加载 OEM 设置 loadOemSettings() + // 默认加载公开统计数据 + authStore.loadPublicStats() + // 检查 URL 参数 const urlApiId = route.query.apiId const urlApiKey = route.query.apiKey diff --git a/web/admin-spa/src/views/LoginView.vue b/web/admin-spa/src/views/LoginView.vue index fc2e8380..4a26653f 100644 --- a/web/admin-spa/src/views/LoginView.vue +++ b/web/admin-spa/src/views/LoginView.vue @@ -10,90 +10,90 @@
-
- -
-