Compare commits

...

17 Commits

Author SHA1 Message Date
github-actions[bot]
bbaa850809 chore: sync VERSION file with release v1.1.245 [skip ci] 2025-12-26 06:57:35 +00:00
shaw
0731ac0449 fix: 修复无权访问 Claude 服务的问题2 2025-12-26 14:57:14 +08:00
github-actions[bot]
2c5a74eb5d chore: sync VERSION file with release v1.1.244 [skip ci] 2025-12-26 06:39:48 +00:00
shaw
09c9b88c27 fix: 修复无权访问 Claude 服务的问题 2025-12-26 14:39:29 +08:00
github-actions[bot]
dd417e780c chore: sync VERSION file with release v1.1.243 [skip ci] 2025-12-26 06:35:54 +00:00
Wesley Liddick
822466fc6e Merge pull request #837 from Chapoly1305/guestPreview
Feat:登陆前服务状态概览
2025-12-26 01:35:38 -05:00
github-actions[bot]
b892ac30a0 chore: sync VERSION file with release v1.1.242 [skip ci] 2025-12-26 05:59:55 +00:00
Wesley Liddick
b8f34b4630 Merge pull request #844 from dadongwo/antigravity
feat: 实现 Antigravity OAuth 账户支持与路径分流
2025-12-26 00:59:42 -05:00
Wesley Liddick
c9621e9efb Merge pull request #846 from bgColorGray/feat/passthrough-system-prompt [skip ci]
feat: allow passing system prompt to Claude
2025-12-26 00:59:29 -05:00
pengyujie
e57a7bd614 feat: allow passing system prompt to Claude
Add CRS_PASSTHROUGH_SYSTEM_PROMPT to optionally forward OpenAI-format system messages to Claude, improving compatibility with clients that rely on strict system instructions (e.g. MineContext).
2025-12-25 20:02:26 +08:00
Chapoly1305
b16968c3e5 feat: 模型使用分布改用环形图展示
- 将条形图改为 Doughnut 环形图,更直观展示占比
- 右侧图例显示模型名称和百分比
- 支持8种渐变配色,明暗主题自适应
- 移除旧的条形图相关样式

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:59:02 -05:00
Chapoly1305
e754589ad5 style: 优化公开统计概览的宽屏布局
- 移除状态概览的最大宽度限制,与其他标签页保持一致
- 重构 PublicStatsOverview 组件布局为响应式两列设计
- 顶部状态栏:服务状态左侧,平台可用性右侧
- 主内容区:今日统计(4项)与模型分布并排显示
- 图表区域独立占满宽度
- 各区块添加独立圆角背景,视觉层次更清晰

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:48:59 -05:00
Chapoly1305
cfeb4658ad feat: 模型使用分布支持自定义时间范围
- 后端:getPublicModelStats 支持 today/24h/7d/30d/all 五种时间范围
- 后端:新增 publicStatsModelDistributionPeriod 设置项
- 前端:设置页面添加横向选项卡式时间范围选择器
- 前端:公开统计组件显示当前数据时间范围标签

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:43:26 -05:00
Chapoly1305
0d94d3b449 feat: 公开统计功能增强 - 独立设置栏目和双Y轴折线图
- 将公开统计设置从品牌设置移至独立栏目
- 用三合一双Y轴折线图替代条形图(Chart.js + vue-chartjs)
- 左Y轴显示Tokens,右Y轴显示活跃数量
- 添加暂无数据状态的友好提示
- 修复Y轴可能显示负数的问题(设置min:0)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 18:58:20 +00:00
Chapoly1305
0c1bdf53d6 feat: 丰富公开统计概览数据并添加可选显示项
- 后端:添加 OEM 设置选项控制公开统计显示内容
  - publicStatsShowModelDistribution: 模型使用分布
  - publicStatsShowTokenTrends: Token 使用趋势(近7天)
  - publicStatsShowApiKeysTrends: API Keys 活跃趋势(近7天)
  - publicStatsShowAccountTrends: 账号活跃趋势(近7天)
- 后端:扩展 /admin/public-stats API 返回趋势数据
- 前端:PublicStatsOverview 组件支持显示趋势柱状图
- 前端:设置页面添加公开统计选项复选框
- 前端:从登录页移除公开统计概览(已移至首页)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 17:42:14 +00:00
Chapoly1305
ab474c3322 feat: 将公开统计概览移至首页状态概览标签
- 在首页添加"状态概览"标签,作为默认显示页面
- 修复 PublicStatsOverview.vue 属性顺序 lint 错误
- 修复 LoginView.vue prettier 格式问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 17:30:00 +00:00
Claude
82d1489a55 feat: 添加公开统计概览功能
- 新增 GET /admin/public-stats 公开端点,返回脱敏的服务统计数据
- 在 OEM 设置中添加 publicStatsEnabled 开关
- 创建 PublicStatsOverview 组件,展示服务状态、平台可用性、今日统计和模型使用分布
- 在登录页集成公开统计展示(当 publicStatsEnabled 开启时)
- 在设置页品牌设置中添加公开统计开关
2025-12-23 01:48:55 +00:00
15 changed files with 1527 additions and 65 deletions

View File

@@ -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

View File

@@ -1 +1 @@
1.1.241
1.1.245

View File

@@ -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

View File

@@ -122,16 +122,6 @@ async function handleMessagesRequest(req, res) {
try {
const startTime = Date.now()
// Claude 服务权限校验,阻止未授权的 Key
if (!apiKeyService.hasPermission(req.apiKey.permissions, 'claude')) {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Claude 服务'
}
})
}
// 🔄 并发满额重试标志最多重试一次使用req对象存储状态
if (req._concurrencyRetryAttempted === undefined) {
req._concurrencyRetryAttempted = false
@@ -192,8 +182,7 @@ async function handleMessagesRequest(req, res) {
// /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
if (!apiKeyService.hasPermission(req.apiKey?.permissions, 'gemini')) {
return res.status(403).json({
error: {
type: 'permission_error',
@@ -207,11 +196,7 @@ async function handleMessagesRequest(req, res) {
}
// Claude 服务权限校验,阻止未授权的 Key默认路径保持不变
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
if (!apiKeyService.hasPermission(req.apiKey.permissions, 'claude')) {
return res.status(403).json({
error: {
type: 'permission_error',
@@ -1250,8 +1235,7 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
//(通过 v1internal:fetchAvailableModels避免依赖静态 modelService 列表。
const forcedVendor = req._anthropicVendor || null
if (forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
if (!apiKeyService.hasPermission(req.apiKey?.permissions, 'gemini')) {
return res.status(403).json({
error: {
type: 'permission_error',
@@ -1445,8 +1429,7 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
const forcedVendor = req._anthropicVendor || null
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
if (!apiKeyService.hasPermission(req.apiKey?.permissions, 'gemini')) {
return res.status(403).json({
error: {
type: 'permission_error',
@@ -1459,11 +1442,7 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
}
// 检查权限
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
if (!apiKeyService.hasPermission(req.apiKey.permissions, 'claude')) {
return res.status(403).json({
error: {
type: 'permission_error',

View File

@@ -19,8 +19,7 @@ const { getEffectiveModel } = require('../utils/modelHelper')
// 🔧 辅助函数:检查 API Key 权限
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
const permissions = apiKeyData.permissions || 'all'
return permissions === 'all' || permissions === requiredPermission
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
}
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {

View File

@@ -46,11 +46,11 @@ async function routeToBackend(req, res, requestedModel) {
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
// 检查权限
const permissions = req.apiKey.permissions || 'all'
const { permissions } = req.apiKey
if (backend === 'claude') {
// Claude 后端:通过 OpenAI 兼容层
if (permissions !== 'all' && permissions !== 'claude') {
if (!apiKeyService.hasPermission(permissions, 'claude')) {
return res.status(403).json({
error: {
message: 'This API key does not have permission to access Claude',
@@ -62,7 +62,7 @@ async function routeToBackend(req, res, requestedModel) {
await handleChatCompletion(req, res, req.apiKey)
} else if (backend === 'openai') {
// OpenAI 后端
if (permissions !== 'all' && permissions !== 'openai') {
if (!apiKeyService.hasPermission(permissions, 'openai')) {
return res.status(403).json({
error: {
message: 'This API key does not have permission to access OpenAI',

View File

@@ -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)' : ''}`

View File

@@ -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",

View File

@@ -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"

View 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>

View File

@@ -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
}
})

View File

@@ -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
}

View File

@@ -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

View File

@@ -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"
>

View File

@@ -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) {