diff --git a/src/routes/admin/system.js b/src/routes/admin/system.js
index 5692103c..01a20891 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,43 @@ router.get('/check-updates', authenticateAdmin, async (req, res) => {
// ==================== OEM 设置管理 ====================
+// 默认OEM设置
+const defaultOemSettings = {
+ siteName: 'Claude Relay Service',
+ siteIcon: '',
+ siteIconData: '', // Base64编码的图标数据
+ showAdminButton: true, // 是否显示管理后台按钮
+ publicStatsEnabled: false, // 是否在首页显示公开统计概览
+ // 公开统计显示选项
+ publicStatsShowModelDistribution: true, // 显示模型使用分布
+ publicStatsModelDistributionPeriod: 'today', // 模型使用分布时间范围: today, 24h, 7d, 30d, all
+ publicStatsShowTokenTrends: false, // 显示Token使用趋势
+ publicStatsShowApiKeysTrends: false, // 显示API Keys使用趋势
+ publicStatsShowAccountTrends: 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 +313,18 @@ 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,
+ publicStatsShowModelDistribution,
+ publicStatsModelDistributionPeriod,
+ publicStatsShowTokenTrends,
+ publicStatsShowApiKeysTrends,
+ publicStatsShowAccountTrends
+ } = req.body
// 验证输入
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
@@ -323,11 +351,24 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
}
}
+ // 验证时间范围值
+ const validPeriods = ['today', '24h', '7d', '30d', 'all']
+ const periodValue = validPeriods.includes(publicStatsModelDistributionPeriod)
+ ? publicStatsModelDistributionPeriod
+ : 'today'
+
const settings = {
siteName: siteName.trim(),
siteIcon: (siteIcon || '').trim(),
siteIconData: (siteIconData || '').trim(), // Base64数据
showAdminButton: showAdminButton !== false, // 默认为true
+ publicStatsEnabled: publicStatsEnabled === true, // 默认为false
+ // 公开统计显示选项
+ publicStatsShowModelDistribution: publicStatsShowModelDistribution !== false, // 默认为true
+ publicStatsModelDistributionPeriod: periodValue, // 时间范围
+ publicStatsShowTokenTrends: publicStatsShowTokenTrends === true, // 默认为false
+ publicStatsShowApiKeysTrends: publicStatsShowApiKeysTrends === true, // 默认为false
+ publicStatsShowAccountTrends: publicStatsShowAccountTrends === true, // 默认为false
updatedAt: new Date().toISOString()
}
@@ -398,4 +439,420 @@ 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(settings.publicStatsModelDistributionPeriod || 'today')
+ ])
+
+ 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
+ },
+
+ // 系统时区
+ systemTimezone: config.system.timezoneOffset || 8,
+
+ // 显示选项
+ showOptions: {
+ modelDistribution: settings.publicStatsShowModelDistribution !== false,
+ tokenTrends: settings.publicStatsShowTokenTrends === true,
+ apiKeysTrends: settings.publicStatsShowApiKeysTrends === true,
+ accountTrends: settings.publicStatsShowAccountTrends === true
+ }
+ }
+
+ // 根据设置添加可选数据
+ if (settings.publicStatsShowModelDistribution !== false) {
+ // modelStats 现在返回 { stats: [], period }
+ publicStats.modelDistribution = modelStats.stats
+ publicStats.modelDistributionPeriod = modelStats.period
+ }
+
+ // 获取趋势数据(最近7天)
+ if (
+ settings.publicStatsShowTokenTrends ||
+ settings.publicStatsShowApiKeysTrends ||
+ settings.publicStatsShowAccountTrends
+ ) {
+ const trendData = await getPublicTrendData(settings)
+ if (settings.publicStatsShowTokenTrends && trendData.tokenTrends) {
+ publicStats.tokenTrends = trendData.tokenTrends
+ }
+ if (settings.publicStatsShowApiKeysTrends && trendData.apiKeysTrends) {
+ publicStats.apiKeysTrends = trendData.apiKeysTrends
+ }
+ if (settings.publicStatsShowAccountTrends && trendData.accountTrends) {
+ publicStats.accountTrends = trendData.accountTrends
+ }
+ }
+
+ 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
+ })
+ }
+})
+
+// 获取公开模型统计的辅助函数
+// period: 'today' | '24h' | '7d' | '30d' | 'all'
+async function getPublicModelStats(period = 'today') {
+ try {
+ const client = redis.getClientSafe()
+ const today = redis.getDateStringInTimezone()
+ const tzDate = redis.getDateInTimezone()
+
+ // 根据period生成日期范围
+ const getDatePatterns = () => {
+ const patterns = []
+
+ if (period === 'today') {
+ patterns.push(`usage:model:daily:*:${today}`)
+ } else if (period === '24h') {
+ // 过去24小时 = 今天 + 昨天
+ patterns.push(`usage:model:daily:*:${today}`)
+ const yesterday = new Date(tzDate)
+ yesterday.setDate(yesterday.getDate() - 1)
+ patterns.push(`usage:model:daily:*:${redis.getDateStringInTimezone(yesterday)}`)
+ } else if (period === '7d') {
+ // 过去7天
+ for (let i = 0; i < 7; i++) {
+ const date = new Date(tzDate)
+ date.setDate(date.getDate() - i)
+ patterns.push(`usage:model:daily:*:${redis.getDateStringInTimezone(date)}`)
+ }
+ } else if (period === '30d') {
+ // 过去30天
+ for (let i = 0; i < 30; i++) {
+ const date = new Date(tzDate)
+ date.setDate(date.getDate() - i)
+ patterns.push(`usage:model:daily:*:${redis.getDateStringInTimezone(date)}`)
+ }
+ } else if (period === 'all') {
+ // 所有数据
+ patterns.push('usage:model:daily:*')
+ } else {
+ // 默认今天
+ patterns.push(`usage:model:daily:*:${today}`)
+ }
+
+ return patterns
+ }
+
+ const patterns = getDatePatterns()
+ let allKeys = []
+
+ for (const pattern of patterns) {
+ const keys = await client.keys(pattern)
+ allKeys.push(...keys)
+ }
+
+ // 去重
+ allKeys = [...new Set(allKeys)]
+
+ if (allKeys.length === 0) {
+ return { stats: [], period }
+ }
+
+ // 模型名标准化
+ 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 allKeys) {
+ 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 { stats: modelStats.slice(0, 5), period }
+ } catch (error) {
+ logger.warn('⚠️ Failed to get public model stats:', error.message)
+ return { stats: [], period }
+ }
+}
+
+// 获取公开趋势数据的辅助函数(最近7天)
+async function getPublicTrendData(settings) {
+ const result = {
+ tokenTrends: null,
+ apiKeysTrends: null,
+ accountTrends: null
+ }
+
+ try {
+ const client = redis.getClientSafe()
+ const days = 7
+
+ // 生成最近7天的日期列表
+ const dates = []
+ for (let i = days - 1; i >= 0; i--) {
+ const date = new Date()
+ date.setDate(date.getDate() - i)
+ dates.push(redis.getDateStringInTimezone(date))
+ }
+
+ // Token使用趋势
+ if (settings.publicStatsShowTokenTrends) {
+ const tokenTrends = []
+ for (const dateStr of dates) {
+ const pattern = `usage:model:daily:*:${dateStr}`
+ const keys = await client.keys(pattern)
+
+ let dayTokens = 0
+ let dayRequests = 0
+ for (const key of keys) {
+ const data = await client.hgetall(key)
+ if (data) {
+ dayTokens += (parseInt(data.inputTokens) || 0) + (parseInt(data.outputTokens) || 0)
+ dayRequests += parseInt(data.requests) || 0
+ }
+ }
+
+ tokenTrends.push({
+ date: dateStr,
+ tokens: dayTokens,
+ requests: dayRequests
+ })
+ }
+ result.tokenTrends = tokenTrends
+ }
+
+ // API Keys使用趋势(脱敏:只显示总数,不显示具体Key)
+ if (settings.publicStatsShowApiKeysTrends) {
+ const apiKeysTrends = []
+ for (const dateStr of dates) {
+ const pattern = `usage:apikey:daily:*:${dateStr}`
+ const keys = await client.keys(pattern)
+
+ let dayRequests = 0
+ let dayTokens = 0
+ let activeKeys = 0
+
+ for (const key of keys) {
+ const data = await client.hgetall(key)
+ if (data) {
+ const requests = parseInt(data.requests) || 0
+ if (requests > 0) {
+ activeKeys++
+ dayRequests += requests
+ dayTokens += (parseInt(data.inputTokens) || 0) + (parseInt(data.outputTokens) || 0)
+ }
+ }
+ }
+
+ apiKeysTrends.push({
+ date: dateStr,
+ activeKeys,
+ requests: dayRequests,
+ tokens: dayTokens
+ })
+ }
+ result.apiKeysTrends = apiKeysTrends
+ }
+
+ // 账号使用趋势(脱敏:只显示总数,不显示具体账号)
+ if (settings.publicStatsShowAccountTrends) {
+ const accountTrends = []
+ for (const dateStr of dates) {
+ const pattern = `usage:account:daily:*:${dateStr}`
+ const keys = await client.keys(pattern)
+
+ let dayRequests = 0
+ let dayTokens = 0
+ let activeAccounts = 0
+
+ for (const key of keys) {
+ const data = await client.hgetall(key)
+ if (data) {
+ const requests = parseInt(data.requests) || 0
+ if (requests > 0) {
+ activeAccounts++
+ dayRequests += requests
+ dayTokens += (parseInt(data.inputTokens) || 0) + (parseInt(data.outputTokens) || 0)
+ }
+ }
+ }
+
+ accountTrends.push({
+ date: dateStr,
+ activeAccounts,
+ requests: dayRequests,
+ tokens: dayTokens
+ })
+ }
+ result.accountTrends = accountTrends
+ }
+ } catch (error) {
+ logger.warn('⚠️ Failed to get public trend data:', error.message)
+ }
+
+ return result
+}
+
module.exports = router
diff --git a/web/admin-spa/package-lock.json b/web/admin-spa/package-lock.json
index 481df56a..3ece6a49 100644
--- a/web/admin-spa/package-lock.json
+++ b/web/admin-spa/package-lock.json
@@ -15,6 +15,7 @@
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.3.4",
+ "vue-chartjs": "^5.3.3",
"vue-router": "^4.2.5",
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
@@ -5131,6 +5132,16 @@
}
}
},
+ "node_modules/vue-chartjs": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
+ "integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "chart.js": "^4.1.1",
+ "vue": "^3.0.0-0 || ^2.7.0"
+ }
+ },
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
diff --git a/web/admin-spa/package.json b/web/admin-spa/package.json
index af353d80..a6e1df25 100644
--- a/web/admin-spa/package.json
+++ b/web/admin-spa/package.json
@@ -18,6 +18,7 @@
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.3.4",
+ "vue-chartjs": "^5.3.3",
"vue-router": "^4.2.5",
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
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..f0c4a82c
--- /dev/null
+++ b/web/admin-spa/src/components/common/PublicStatsOverview.vue
@@ -0,0 +1,750 @@
+
+ 暂无趋势数据 数据将在有请求后自动更新 暂无统计数据
+ 配置未登录用户可见的统计数据 +
++ + 选择要公开显示的数据: +
+