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 @@ + + + + + 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..468ca9bf 100644 --- a/web/admin-spa/src/stores/settings.js +++ b/web/admin-spa/src/stores/settings.js @@ -9,6 +9,12 @@ export const useSettingsStore = defineStore('settings', () => { siteIcon: '', siteIconData: '', showAdminButton: true, // 控制管理后台按钮的显示 + publicStatsEnabled: false, // 是否在首页显示公开统计概览 + publicStatsShowModelDistribution: true, + publicStatsModelDistributionPeriod: 'today', // 时间范围: today, 24h, 7d, 30d, all + publicStatsShowTokenTrends: false, + publicStatsShowApiKeysTrends: false, + publicStatsShowAccountTrends: false, updatedAt: null }) @@ -66,6 +72,7 @@ export const useSettingsStore = defineStore('settings', () => { siteIcon: '', siteIconData: '', showAdminButton: true, + publicStatsEnabled: false, updatedAt: null } diff --git a/web/admin-spa/src/views/ApiStatsView.vue b/web/admin-spa/src/views/ApiStatsView.vue index c5b0d51a..a496dab6 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 +192,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 +203,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 +244,12 @@ const closeTestModal = () => { showTestModal.value = false } +// 切换到状态概览并加载数据 +const switchToOverview = () => { + currentTab.value = 'overview' + authStore.loadPublicStats() +} + // 处理键盘快捷键 const handleKeyDown = (event) => { // Ctrl/Cmd + Enter 查询 @@ -249,6 +276,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 62138897..0ec84e78 100644 --- a/web/admin-spa/src/views/LoginView.vue +++ b/web/admin-spa/src/views/LoginView.vue @@ -5,6 +5,7 @@
+
diff --git a/web/admin-spa/src/views/SettingsView.vue b/web/admin-spa/src/views/SettingsView.vue index b9b260a2..ce631fb2 100644 --- a/web/admin-spa/src/views/SettingsView.vue +++ b/web/admin-spa/src/views/SettingsView.vue @@ -48,6 +48,18 @@ Claude 转发 +
@@ -1025,6 +1037,158 @@
+ + +
+
+
+
+
+ +
+
+

+ 公开统计概览 +

+

+ 配置未登录用户可见的统计数据 +

+
+
+ +
+ + +
+

+ + 选择要公开显示的数据: +

+
+
+ +
+
时间范围
+
+ +
+
+
+ + + +
+
+ + +
+
+ +
+
+ + 最后更新:{{ formatDateTime(oemSettings.updatedAt) }} +
+
+
+
@@ -1622,6 +1786,15 @@ defineOptions({ const settingsStore = useSettingsStore() const { loading, saving, oemSettings } = storeToRefs(settingsStore) +// 模型使用分布时间范围选项 +const modelDistributionPeriodOptions = [ + { value: 'today', label: '今天' }, + { value: '24h', label: '24小时' }, + { value: '7d', label: '7天' }, + { value: '30d', label: '30天' }, + { value: 'all', label: '全部' } +] + // 组件refs const iconFileInput = ref() @@ -2467,7 +2640,14 @@ 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, + publicStatsShowModelDistribution: oemSettings.value.publicStatsShowModelDistribution, + publicStatsModelDistributionPeriod: + oemSettings.value.publicStatsModelDistributionPeriod || 'today', + publicStatsShowTokenTrends: oemSettings.value.publicStatsShowTokenTrends, + publicStatsShowApiKeysTrends: oemSettings.value.publicStatsShowApiKeysTrends, + publicStatsShowAccountTrends: oemSettings.value.publicStatsShowAccountTrends } const result = await settingsStore.saveOemSettings(settings) if (result && result.success) {