mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd417e780c | ||
|
|
822466fc6e | ||
|
|
b892ac30a0 | ||
|
|
b8f34b4630 | ||
|
|
c9621e9efb | ||
|
|
e57a7bd614 | ||
|
|
b16968c3e5 | ||
|
|
e754589ad5 | ||
|
|
cfeb4658ad | ||
|
|
0d94d3b449 | ||
|
|
0c1bdf53d6 | ||
|
|
ab474c3322 | ||
|
|
82d1489a55 |
@@ -166,3 +166,7 @@ DEFAULT_USER_ROLE=user
|
||||
USER_SESSION_TIMEOUT=86400000
|
||||
MAX_API_KEYS_PER_USER=1
|
||||
ALLOW_USER_DELETE_API_KEYS=false
|
||||
|
||||
# Pass through incoming OpenAI-format system prompts to Claude.
|
||||
# Enable this when using generic OpenAI-compatible clients (e.g. MineContext) that rely on system prompts.
|
||||
# CRS_PASSTHROUGH_SYSTEM_PROMPT=true
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,15 +36,28 @@ class OpenAIToClaudeConverter {
|
||||
|
||||
// 如果 OpenAI 请求中包含系统消息,提取并检查
|
||||
const systemMessage = this._extractSystemMessage(openaiRequest.messages)
|
||||
if (systemMessage && systemMessage.includes('You are currently in Xcode')) {
|
||||
// Xcode 系统提示词
|
||||
|
||||
const passThroughSystemPrompt =
|
||||
String(process.env.CRS_PASSTHROUGH_SYSTEM_PROMPT || '').toLowerCase() === 'true'
|
||||
|
||||
if (
|
||||
systemMessage &&
|
||||
(passThroughSystemPrompt || systemMessage.includes('You are currently in Xcode'))
|
||||
) {
|
||||
claudeRequest.system = systemMessage
|
||||
logger.info(
|
||||
`🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)`
|
||||
)
|
||||
|
||||
if (systemMessage.includes('You are currently in Xcode')) {
|
||||
logger.info(
|
||||
`🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)`
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
`🧩 Using caller-provided system prompt (${systemMessage.length} chars) because CRS_PASSTHROUGH_SYSTEM_PROMPT=true`
|
||||
)
|
||||
}
|
||||
logger.debug(`📋 System prompt preview: ${systemMessage.substring(0, 150)}...`)
|
||||
} else {
|
||||
// 使用 Claude Code 默认系统提示词
|
||||
// 默认行为:兼容 Claude Code(忽略外部 system)
|
||||
claudeRequest.system = claudeCodeSystemMessage
|
||||
logger.debug(
|
||||
`📋 Using Claude Code default system prompt${systemMessage ? ' (ignored custom prompt)' : ''}`
|
||||
|
||||
11
web/admin-spa/package-lock.json
generated
11
web/admin-spa/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
750
web/admin-spa/src/components/common/PublicStatsOverview.vue
Normal file
750
web/admin-spa/src/components/common/PublicStatsOverview.vue
Normal file
@@ -0,0 +1,750 @@
|
||||
<template>
|
||||
<div v-if="authStore.publicStats" class="public-stats-overview">
|
||||
<!-- 顶部状态栏:服务状态 + 平台可用性 -->
|
||||
<div class="header-section">
|
||||
<div class="flex items-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="flex flex-wrap justify-center gap-2 md:justify-end">
|
||||
<div
|
||||
v-for="(available, platform) in authStore.publicStats.platforms"
|
||||
:key="platform"
|
||||
class="platform-badge"
|
||||
:class="{ available: available, unavailable: !available }"
|
||||
>
|
||||
<i class="mr-1" :class="getPlatformIcon(platform)"></i>
|
||||
<span>{{ getPlatformName(platform) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区:今日统计 + 模型分布 -->
|
||||
<div class="main-content">
|
||||
<!-- 左侧:今日统计 -->
|
||||
<div class="stats-section">
|
||||
<div class="section-title-left">今日统计</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 class="stat-item">
|
||||
<div class="stat-value">
|
||||
{{ formatTokens(authStore.publicStats.todayStats.inputTokens) }}
|
||||
</div>
|
||||
<div class="stat-label">输入</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">
|
||||
{{ formatTokens(authStore.publicStats.todayStats.outputTokens) }}
|
||||
</div>
|
||||
<div class="stat-label">输出</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:模型使用分布 -->
|
||||
<div
|
||||
v-if="
|
||||
authStore.publicStats.showOptions?.modelDistribution &&
|
||||
authStore.publicStats.modelDistribution?.length > 0
|
||||
"
|
||||
class="model-section"
|
||||
>
|
||||
<div class="section-title-left">
|
||||
模型使用分布
|
||||
<span class="period-label">{{
|
||||
formatPeriodLabel(authStore.publicStats.modelDistributionPeriod)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="model-chart-container">
|
||||
<Doughnut :data="modelChartData" :options="modelChartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 趋势图表(三合一双Y轴折线图) -->
|
||||
<div v-if="hasAnyTrendData" class="chart-section">
|
||||
<div class="section-title-left">使用趋势(近7天)</div>
|
||||
<div class="chart-container">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
<!-- 图例 -->
|
||||
<div class="chart-legend">
|
||||
<div v-if="authStore.publicStats.showOptions?.tokenTrends" class="legend-item">
|
||||
<span class="legend-dot legend-tokens"></span>
|
||||
<span class="legend-text">Tokens</span>
|
||||
</div>
|
||||
<div v-if="authStore.publicStats.showOptions?.apiKeysTrends" class="legend-item">
|
||||
<span class="legend-dot legend-keys"></span>
|
||||
<span class="legend-text">活跃 Keys</span>
|
||||
</div>
|
||||
<div v-if="authStore.publicStats.showOptions?.accountTrends" class="legend-item">
|
||||
<span class="legend-dot legend-accounts"></span>
|
||||
<span class="legend-text">活跃账号</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 暂无趋势数据 -->
|
||||
<div v-else-if="hasTrendOptionsEnabled" class="empty-state">
|
||||
<i class="fas fa-chart-line empty-icon"></i>
|
||||
<p class="empty-text">暂无趋势数据</p>
|
||||
<p class="empty-hint">数据将在有请求后自动更新</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-else-if="authStore.publicStatsLoading" class="public-stats-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
|
||||
<!-- 无数据状态 -->
|
||||
<div v-else class="public-stats-empty">
|
||||
<i class="fas fa-chart-pie empty-icon"></i>
|
||||
<p class="empty-text">暂无统计数据</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Line, Doughnut } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
|
||||
// 注册 Chart.js 组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 检查是否有任何趋势选项启用
|
||||
const hasTrendOptionsEnabled = computed(() => {
|
||||
const opts = authStore.publicStats?.showOptions
|
||||
return opts?.tokenTrends || opts?.apiKeysTrends || opts?.accountTrends
|
||||
})
|
||||
|
||||
// 检查是否有实际趋势数据
|
||||
const hasAnyTrendData = computed(() => {
|
||||
const stats = authStore.publicStats
|
||||
if (!stats) return false
|
||||
|
||||
const opts = stats.showOptions || {}
|
||||
const hasTokens = opts.tokenTrends && stats.tokenTrends?.length > 0
|
||||
const hasKeys = opts.apiKeysTrends && stats.apiKeysTrends?.length > 0
|
||||
const hasAccounts = opts.accountTrends && stats.accountTrends?.length > 0
|
||||
|
||||
return hasTokens || hasKeys || hasAccounts
|
||||
})
|
||||
|
||||
// 模型分布颜色
|
||||
const modelColors = [
|
||||
'rgb(99, 102, 241)', // indigo
|
||||
'rgb(59, 130, 246)', // blue
|
||||
'rgb(16, 185, 129)', // emerald
|
||||
'rgb(245, 158, 11)', // amber
|
||||
'rgb(239, 68, 68)', // red
|
||||
'rgb(139, 92, 246)', // violet
|
||||
'rgb(236, 72, 153)', // pink
|
||||
'rgb(20, 184, 166)' // teal
|
||||
]
|
||||
|
||||
// 模型分布环形图数据
|
||||
const modelChartData = computed(() => {
|
||||
const stats = authStore.publicStats
|
||||
if (!stats?.modelDistribution?.length) {
|
||||
return { labels: [], datasets: [] }
|
||||
}
|
||||
|
||||
const models = stats.modelDistribution
|
||||
return {
|
||||
labels: models.map((m) => formatModelName(m.model)),
|
||||
datasets: [
|
||||
{
|
||||
data: models.map((m) => m.percentage),
|
||||
backgroundColor: models.map((_, i) => modelColors[i % modelColors.length]),
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
hoverOffset: 4
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 模型分布环形图选项
|
||||
const modelChartOptions = computed(() => {
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
const textColor = isDark ? 'rgb(156, 163, 175)' : 'rgb(107, 114, 128)'
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '60%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: {
|
||||
color: textColor,
|
||||
padding: 12,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
generateLabels: (chart) => {
|
||||
const data = chart.data
|
||||
if (data.labels.length && data.datasets.length) {
|
||||
return data.labels.map((label, i) => ({
|
||||
text: `${label} ${data.datasets[0].data[i]}%`,
|
||||
fillStyle: data.datasets[0].backgroundColor[i],
|
||||
strokeStyle: 'transparent',
|
||||
lineWidth: 0,
|
||||
pointStyle: 'circle',
|
||||
hidden: false,
|
||||
index: i
|
||||
}))
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? 'rgba(31, 41, 55, 0.95)' : 'rgba(255, 255, 255, 0.95)',
|
||||
titleColor: isDark ? 'rgb(243, 244, 246)' : 'rgb(17, 24, 39)',
|
||||
bodyColor: isDark ? 'rgb(209, 213, 219)' : 'rgb(75, 85, 99)',
|
||||
borderColor: isDark ? 'rgba(75, 85, 99, 0.3)' : 'rgba(209, 213, 219, 0.5)',
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
cornerRadius: 8,
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
return ` ${context.label}: ${context.parsed}%`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 趋势图表数据
|
||||
const chartData = computed(() => {
|
||||
const stats = authStore.publicStats
|
||||
if (!stats) return { labels: [], datasets: [] }
|
||||
|
||||
const opts = stats.showOptions || {}
|
||||
|
||||
// 获取日期标签(优先使用 tokenTrends)
|
||||
const labels =
|
||||
stats.tokenTrends?.map((t) => formatDateShort(t.date)) ||
|
||||
stats.apiKeysTrends?.map((t) => formatDateShort(t.date)) ||
|
||||
stats.accountTrends?.map((t) => formatDateShort(t.date)) ||
|
||||
[]
|
||||
|
||||
const datasets = []
|
||||
|
||||
// Token 趋势(左Y轴)
|
||||
if (opts.tokenTrends && stats.tokenTrends?.length > 0) {
|
||||
datasets.push({
|
||||
label: 'Tokens',
|
||||
data: stats.tokenTrends.map((t) => t.tokens),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
yAxisID: 'y',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5
|
||||
})
|
||||
}
|
||||
|
||||
// API Keys 趋势(右Y轴)
|
||||
if (opts.apiKeysTrends && stats.apiKeysTrends?.length > 0) {
|
||||
datasets.push({
|
||||
label: '活跃 Keys',
|
||||
data: stats.apiKeysTrends.map((t) => t.activeKeys),
|
||||
borderColor: 'rgb(34, 197, 94)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
yAxisID: 'y1',
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5
|
||||
})
|
||||
}
|
||||
|
||||
// 账号趋势(右Y轴)
|
||||
if (opts.accountTrends && stats.accountTrends?.length > 0) {
|
||||
datasets.push({
|
||||
label: '活跃账号',
|
||||
data: stats.accountTrends.map((t) => t.activeAccounts),
|
||||
borderColor: 'rgb(168, 85, 247)',
|
||||
backgroundColor: 'rgba(168, 85, 247, 0.1)',
|
||||
yAxisID: 'y1',
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5
|
||||
})
|
||||
}
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = computed(() => {
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
const textColor = isDark ? 'rgba(156, 163, 175, 1)' : 'rgba(107, 114, 128, 1)'
|
||||
const gridColor = isDark ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)'
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? 'rgba(31, 41, 55, 0.9)' : 'rgba(255, 255, 255, 0.9)',
|
||||
titleColor: isDark ? '#e5e7eb' : '#1f2937',
|
||||
bodyColor: isDark ? '#d1d5db' : '#4b5563',
|
||||
borderColor: isDark ? 'rgba(75, 85, 99, 0.5)' : 'rgba(229, 231, 235, 1)',
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
let label = context.dataset.label || ''
|
||||
if (label) {
|
||||
label += ': '
|
||||
}
|
||||
if (context.dataset.yAxisID === 'y') {
|
||||
label += formatTokens(context.parsed.y)
|
||||
} else {
|
||||
label += context.parsed.y
|
||||
}
|
||||
return label
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: gridColor,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: {
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
min: 0,
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Tokens',
|
||||
color: 'rgb(59, 130, 246)',
|
||||
font: {
|
||||
size: 10
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: gridColor,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
callback: function (value) {
|
||||
return formatTokensShort(value)
|
||||
}
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
min: 0,
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '数量',
|
||||
color: 'rgb(34, 197, 94)',
|
||||
font: {
|
||||
size: 10
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
stepSize: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化运行时间
|
||||
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()
|
||||
}
|
||||
|
||||
// 格式化 tokens(简短版,用于Y轴)
|
||||
function formatTokensShort(tokens) {
|
||||
if (tokens >= 1000000000) {
|
||||
return (tokens / 1000000000).toFixed(0) + 'B'
|
||||
} else if (tokens >= 1000000) {
|
||||
return (tokens / 1000000).toFixed(0) + 'M'
|
||||
} else if (tokens >= 1000) {
|
||||
return (tokens / 1000).toFixed(0) + 'K'
|
||||
}
|
||||
return tokens.toString()
|
||||
}
|
||||
|
||||
// 格式化时间范围标签
|
||||
function formatPeriodLabel(period) {
|
||||
const labels = {
|
||||
today: '今天',
|
||||
'24h': '过去24小时',
|
||||
'7d': '过去7天',
|
||||
'30d': '过去30天',
|
||||
all: '全部'
|
||||
}
|
||||
return labels[period] || labels['today']
|
||||
}
|
||||
|
||||
// 获取平台图标
|
||||
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
|
||||
}
|
||||
|
||||
// 格式化日期(短格式)
|
||||
function formatDateShort(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const parts = dateStr.split('-')
|
||||
if (parts.length === 3) {
|
||||
return `${parts[1]}/${parts[2]}`
|
||||
}
|
||||
return dateStr
|
||||
}
|
||||
</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 md:p-6;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 顶部状态栏 */
|
||||
.header-section {
|
||||
@apply mb-4 flex flex-col items-center justify-between gap-3 border-b border-gray-200 pb-4 dark:border-gray-700 md:mb-6 md:flex-row md:pb-6;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
@apply grid gap-4 md:grid-cols-2 md:gap-6;
|
||||
}
|
||||
|
||||
/* 统计区块 */
|
||||
.stats-section {
|
||||
@apply rounded-lg bg-gray-50/50 p-4 dark:bg-gray-700/30;
|
||||
}
|
||||
|
||||
/* 模型区块 */
|
||||
.model-section {
|
||||
@apply rounded-lg bg-gray-50/50 p-4 dark:bg-gray-700/30;
|
||||
}
|
||||
|
||||
/* 图表区块 */
|
||||
.chart-section {
|
||||
@apply mt-4 rounded-lg bg-gray-50/50 p-4 dark:bg-gray-700/30 md:mt-6;
|
||||
}
|
||||
|
||||
/* 章节标题(居中) */
|
||||
.section-title {
|
||||
@apply mb-2 text-center text-xs text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
|
||||
/* 章节标题(左对齐) */
|
||||
.section-title-left {
|
||||
@apply mb-3 text-sm font-medium text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
/* 时间范围标签 */
|
||||
.period-label {
|
||||
@apply ml-1 rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-normal text-gray-500 dark:bg-gray-600 dark:text-gray-400;
|
||||
}
|
||||
|
||||
/* 状态徽章 */
|
||||
.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-white p-3 text-center shadow-sm dark:bg-gray-800/50;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@apply text-lg font-bold text-gray-900 dark:text-gray-100 md:text-xl;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@apply text-xs text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
/* 模型分布环形图容器 */
|
||||
.model-chart-container {
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.model-chart-container {
|
||||
height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 趋势图表容器 */
|
||||
.chart-container {
|
||||
@apply rounded-lg bg-gray-50 p-3 dark:bg-gray-700/50;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
/* 图例 */
|
||||
.chart-legend {
|
||||
@apply mt-2 flex flex-wrap items-center justify-center gap-4;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
@apply flex items-center gap-1.5;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
@apply inline-block h-2.5 w-2.5 rounded-full;
|
||||
}
|
||||
|
||||
.legend-tokens {
|
||||
@apply bg-blue-500;
|
||||
}
|
||||
|
||||
.legend-keys {
|
||||
@apply bg-green-500;
|
||||
}
|
||||
|
||||
.legend-accounts {
|
||||
@apply bg-purple-500;
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
@apply text-xs text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
@apply flex flex-col items-center justify-center rounded-lg bg-gray-50 py-6 dark:bg-gray-700/50;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
@apply mb-2 text-2xl text-gray-400 dark:text-gray-500;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
@apply text-sm text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
@apply mt-1 text-xs text-gray-400 dark:text-gray-500;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.public-stats-loading {
|
||||
@apply flex items-center justify-center py-8;
|
||||
}
|
||||
|
||||
.public-stats-empty {
|
||||
@apply flex flex-col items-center justify-center rounded-xl border border-gray-200/50 bg-white/80 py-8 backdrop-blur-sm dark:border-gray-700/50 dark:bg-gray-800/80;
|
||||
}
|
||||
|
||||
.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,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
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
<LogoTitle
|
||||
:loading="oemLoading"
|
||||
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
:subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'"
|
||||
:subtitle="
|
||||
currentTab === 'stats'
|
||||
? 'API Key 使用统计'
|
||||
: currentTab === 'overview'
|
||||
? '服务状态概览'
|
||||
: '使用教程'
|
||||
"
|
||||
:title="oemSettings.siteName"
|
||||
/>
|
||||
<div class="flex items-center gap-2 md:gap-4">
|
||||
@@ -49,6 +55,13 @@
|
||||
<div
|
||||
class="inline-flex w-full max-w-md rounded-full border border-white/20 bg-white/10 p-1 shadow-lg backdrop-blur-xl md:w-auto"
|
||||
>
|
||||
<button
|
||||
:class="['tab-pill-button', currentTab === 'overview' ? 'active' : '']"
|
||||
@click="switchToOverview"
|
||||
>
|
||||
<i class="fas fa-tachometer-alt mr-1 md:mr-2" />
|
||||
<span class="text-sm md:text-base">状态概览</span>
|
||||
</button>
|
||||
<button
|
||||
:class="['tab-pill-button', currentTab === 'stats' ? 'active' : '']"
|
||||
@click="currentTab = 'stats'"
|
||||
@@ -67,6 +80,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态概览内容 -->
|
||||
<div v-if="currentTab === 'overview'" class="tab-content">
|
||||
<PublicStatsOverview />
|
||||
</div>
|
||||
|
||||
<!-- 统计内容 -->
|
||||
<div v-if="currentTab === 'stats'" class="tab-content">
|
||||
<!-- API Key 输入区域 -->
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<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"
|
||||
>
|
||||
|
||||
@@ -48,6 +48,18 @@
|
||||
<i class="fas fa-robot mr-2"></i>
|
||||
Claude 转发
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'border-b-2 pb-2 text-sm font-medium transition-colors',
|
||||
activeSection === 'publicStats'
|
||||
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
]"
|
||||
@click="activeSection = 'publicStats'"
|
||||
>
|
||||
<i class="fas fa-chart-line mr-2"></i>
|
||||
公开统计
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1025,6 +1037,158 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 公开统计设置部分 -->
|
||||
<div v-show="activeSection === 'publicStats'">
|
||||
<div class="rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 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-line"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
公开统计概览
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
配置未登录用户可见的统计数据
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- 数据显示选项 -->
|
||||
<div
|
||||
v-if="oemSettings.publicStatsEnabled"
|
||||
class="space-y-4 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-600 dark:bg-gray-700/50"
|
||||
>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-eye mr-2 text-gray-400"></i>
|
||||
选择要公开显示的数据:
|
||||
</p>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-3 transition-colors dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<label class="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
v-model="oemSettings.publicStatsShowModelDistribution"
|
||||
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>模型使用分布</span
|
||||
>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">显示各模型的使用占比</p>
|
||||
</div>
|
||||
</label>
|
||||
<div v-if="oemSettings.publicStatsShowModelDistribution" class="mt-3 pl-7">
|
||||
<div class="mb-1.5 text-xs text-gray-500 dark:text-gray-400">时间范围</div>
|
||||
<div class="inline-flex rounded-lg bg-gray-100 p-0.5 dark:bg-gray-700/50">
|
||||
<button
|
||||
v-for="option in modelDistributionPeriodOptions"
|
||||
:key="option.value"
|
||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all"
|
||||
:class="
|
||||
oemSettings.publicStatsModelDistributionPeriod === option.value
|
||||
? 'bg-white text-green-600 shadow-sm dark:bg-gray-600 dark:text-green-400'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
"
|
||||
type="button"
|
||||
@click="oemSettings.publicStatsModelDistributionPeriod = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
>
|
||||
<input
|
||||
v-model="oemSettings.publicStatsShowTokenTrends"
|
||||
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>Token 使用趋势</span
|
||||
>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">显示近7天的Token使用量</p>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
>
|
||||
<input
|
||||
v-model="oemSettings.publicStatsShowApiKeysTrends"
|
||||
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>API Keys 活跃趋势</span
|
||||
>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
显示近7天的活跃API Key数量
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
>
|
||||
<input
|
||||
v-model="oemSettings.publicStatsShowAccountTrends"
|
||||
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>账号活跃趋势</span
|
||||
>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">显示近7天的活跃账号数量</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="btn btn-primary px-6 py-3"
|
||||
:class="{ 'cursor-not-allowed opacity-50': saving }"
|
||||
:disabled="saving"
|
||||
@click="saveOemSettings"
|
||||
>
|
||||
<div v-if="saving" class="loading-spinner mr-2"></div>
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="oemSettings.updatedAt" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-clock mr-1" />
|
||||
最后更新:{{ formatDateTime(oemSettings.updatedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user