Files
claude-relay-service/web/admin-spa/src/stores/dashboard.js
shaw a6ab6b7abe feat: 实现基于滑动窗口的实时RPM/TPM统计
- 添加系统级分钟统计,支持1-60分钟可配置时间窗口
- 新增 getRealtimeSystemMetrics 方法计算滑动窗口内的平均值
- 前端显示实时RPM/TPM,标注时间窗口和数据来源
- 修复 EditApiKeyModal 中模型限制和客户端限制复选框状态错误
- 优化性能:使用Pipeline批量操作替代Promise.all
- TPM包含所有token类型:input、output、cache_creation、cache_read
- 添加降级方案:实时数据不可用时返回历史平均值

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-30 15:37:57 +08:00

647 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
export const useDashboardStore = defineStore('dashboard', () => {
// 状态
const loading = ref(false)
const dashboardData = ref({
totalApiKeys: 0,
activeApiKeys: 0,
totalAccounts: 0,
activeAccounts: 0,
rateLimitedAccounts: 0,
todayRequests: 0,
totalRequests: 0,
todayTokens: 0,
todayInputTokens: 0,
todayOutputTokens: 0,
totalTokens: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheCreateTokens: 0,
totalCacheReadTokens: 0,
todayCacheCreateTokens: 0,
todayCacheReadTokens: 0,
systemRPM: 0,
systemTPM: 0,
realtimeRPM: 0,
realtimeTPM: 0,
metricsWindow: 5,
isHistoricalMetrics: false,
systemStatus: '正常',
uptime: 0,
systemTimezone: 8 // 默认 UTC+8
})
const costsData = ref({
todayCosts: { totalCost: 0, formatted: { totalCost: '$0.000000' } },
totalCosts: { totalCost: 0, formatted: { totalCost: '$0.000000' } }
})
const modelStats = ref([])
const trendData = ref([])
const dashboardModelStats = ref([])
const apiKeysTrendData = ref({
data: [],
topApiKeys: [],
totalApiKeys: 0
})
// 日期筛选
const dateFilter = ref({
type: 'preset', // preset 或 custom
preset: '7days', // today, 7days, 30days
customStart: '',
customEnd: '',
customRange: null,
presetOptions: [
{ value: 'today', label: '今日', days: 1 },
{ value: '7days', label: '7天', days: 7 },
{ value: '30days', label: '30天', days: 30 }
]
})
// 趋势图粒度
const trendGranularity = ref('day') // 'day' 或 'hour'
const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens'
// 默认时间
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
// 计算属性
const formattedUptime = computed(() => {
const seconds = dashboardData.value.uptime
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 getDateInSystemTimezone(date = new Date()) {
const offset = dashboardData.value.systemTimezone || 8
// 将本地时间转换为UTC时间然后加上系统时区偏移
const utcTime = date.getTime() + (date.getTimezoneOffset() * 60000)
return new Date(utcTime + (offset * 3600000))
}
// 辅助函数获取系统时区某一天的起止UTC时间
// 输入:一个本地时间的日期对象(如用户选择的日期)
// 输出该日期在系统时区的0点/23:59对应的UTC时间
function getSystemTimezoneDay(localDate, startOfDay = true) {
// 固定使用UTC+8因为后端系统时区是UTC+8
const systemTz = 8
// 获取本地日期的年月日(这是用户想要查看的日期)
const year = localDate.getFullYear()
const month = localDate.getMonth()
const day = localDate.getDate()
if (startOfDay) {
// 系统时区UTC+8的 YYYY-MM-DD 00:00:00
// 对应的UTC时间是前一天的16:00
// 例如UTC+8的2025-07-29 00:00:00 = UTC的2025-07-28 16:00:00
return new Date(Date.UTC(year, month, day - 1, 16, 0, 0, 0))
} else {
// 系统时区UTC+8的 YYYY-MM-DD 23:59:59
// 对应的UTC时间是当天的15:59:59
// 例如UTC+8的2025-07-29 23:59:59 = UTC的2025-07-29 15:59:59
return new Date(Date.UTC(year, month, day, 15, 59, 59, 999))
}
}
// 方法
async function loadDashboardData() {
loading.value = true
try {
const [dashboardResponse, todayCostsResponse, totalCostsResponse] = await Promise.all([
apiClient.get('/admin/dashboard'),
apiClient.get('/admin/usage-costs?period=today'),
apiClient.get('/admin/usage-costs?period=all')
])
if (dashboardResponse.success) {
const overview = dashboardResponse.data.overview || {}
const recentActivity = dashboardResponse.data.recentActivity || {}
const systemAverages = dashboardResponse.data.systemAverages || {}
const realtimeMetrics = dashboardResponse.data.realtimeMetrics || {}
const systemHealth = dashboardResponse.data.systemHealth || {}
dashboardData.value = {
totalApiKeys: overview.totalApiKeys || 0,
activeApiKeys: overview.activeApiKeys || 0,
totalAccounts: overview.totalClaudeAccounts || 0,
activeAccounts: overview.activeClaudeAccounts || 0,
rateLimitedAccounts: overview.rateLimitedClaudeAccounts || 0,
todayRequests: recentActivity.requestsToday || 0,
totalRequests: overview.totalRequestsUsed || 0,
todayTokens: recentActivity.tokensToday || 0,
todayInputTokens: recentActivity.inputTokensToday || 0,
todayOutputTokens: recentActivity.outputTokensToday || 0,
totalTokens: overview.totalTokensUsed || 0,
totalInputTokens: overview.totalInputTokensUsed || 0,
totalOutputTokens: overview.totalOutputTokensUsed || 0,
totalCacheCreateTokens: overview.totalCacheCreateTokensUsed || 0,
totalCacheReadTokens: overview.totalCacheReadTokensUsed || 0,
todayCacheCreateTokens: recentActivity.cacheCreateTokensToday || 0,
todayCacheReadTokens: recentActivity.cacheReadTokensToday || 0,
systemRPM: systemAverages.rpm || 0,
systemTPM: systemAverages.tpm || 0,
realtimeRPM: realtimeMetrics.rpm || 0,
realtimeTPM: realtimeMetrics.tpm || 0,
metricsWindow: realtimeMetrics.windowMinutes || 5,
isHistoricalMetrics: realtimeMetrics.isHistorical || false,
systemStatus: systemHealth.redisConnected ? '正常' : '异常',
uptime: systemHealth.uptime || 0,
systemTimezone: dashboardResponse.data.systemTimezone || 8
}
}
// 更新费用数据
if (todayCostsResponse.success && totalCostsResponse.success) {
costsData.value = {
todayCosts: todayCostsResponse.data.totalCosts || { totalCost: 0, formatted: { totalCost: '$0.000000' } },
totalCosts: totalCostsResponse.data.totalCosts || { totalCost: 0, formatted: { totalCost: '$0.000000' } }
}
}
} catch (error) {
console.error('加载仪表板数据失败:', error)
} finally {
loading.value = false
}
}
async function loadUsageTrend(days = 7, granularity = 'day') {
try {
let url = '/admin/usage-trend?'
if (granularity === 'hour') {
// 小时粒度,计算时间范围
url += `granularity=hour`
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
// 使用自定义时间范围 - 需要将系统时区时间转换为UTC
const convertToUTC = (systemTzTimeStr) => {
// 固定使用UTC+8因为后端系统时区是UTC+8
const systemTz = 8
// 解析系统时区时间字符串
const [datePart, timePart] = systemTzTimeStr.split(' ')
const [year, month, day] = datePart.split('-').map(Number)
const [hours, minutes, seconds] = timePart.split(':').map(Number)
// 创建UTC时间使其在系统时区显示为用户选择的时间
// 例如:用户选择 UTC+8 的 2025-07-25 00:00:00
// 对应的UTC时间是 2025-07-24 16:00:00
const utcDate = new Date(Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds))
return utcDate.toISOString()
}
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
} else {
// 使用预设计算时间范围与loadApiKeysTrend保持一致
const now = new Date()
let startTime, endTime
if (dateFilter.value.type === 'preset') {
switch (dateFilter.value.preset) {
case 'last24h':
// 近24小时从当前时间往前推24小时
endTime = new Date(now)
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
case 'yesterday':
// 昨天:基于系统时区的昨天
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
startTime = getSystemTimezoneDay(yesterday, true)
endTime = getSystemTimezoneDay(yesterday, false)
break
case 'dayBefore':
// 前天:基于系统时区的前天
const dayBefore = new Date()
dayBefore.setDate(dayBefore.getDate() - 2)
startTime = getSystemTimezoneDay(dayBefore, true)
endTime = getSystemTimezoneDay(dayBefore, false)
break
default:
// 默认近24小时
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
endTime = now
}
} else {
// 默认使用days参数计算
startTime = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
endTime = now
}
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
}
} else {
// 天粒度,传递天数
url += `granularity=day&days=${days}`
}
const response = await apiClient.get(url)
if (response.success) {
trendData.value = response.data
}
} catch (error) {
console.error('加载使用趋势失败:', error)
}
}
async function loadModelStats(period = 'daily') {
try {
const response = await apiClient.get(`/admin/model-stats?period=${period}`)
if (response.success) {
dashboardModelStats.value = response.data
}
} catch (error) {
console.error('加载模型统计失败:', error)
}
}
async function loadApiKeysTrend(metric = 'requests') {
try {
let url = '/admin/api-keys-usage-trend?'
let days = 7
if (trendGranularity.value === 'hour') {
// 小时粒度,计算时间范围
url += `granularity=hour`
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
// 使用自定义时间范围 - 需要将系统时区时间转换为UTC
const convertToUTC = (systemTzTimeStr) => {
// 固定使用UTC+8因为后端系统时区是UTC+8
const systemTz = 8
// 解析系统时区时间字符串
const [datePart, timePart] = systemTzTimeStr.split(' ')
const [year, month, day] = datePart.split('-').map(Number)
const [hours, minutes, seconds] = timePart.split(':').map(Number)
// 创建UTC时间使其在系统时区显示为用户选择的时间
// 例如:用户选择 UTC+8 的 2025-07-25 00:00:00
// 对应的UTC时间是 2025-07-24 16:00:00
const utcDate = new Date(Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds))
return utcDate.toISOString()
}
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
} else {
// 使用预设计算时间范围与setDateFilterPreset保持一致
const now = new Date()
let startTime, endTime
if (dateFilter.value.type === 'preset') {
switch (dateFilter.value.preset) {
case 'last24h':
// 近24小时从当前时间往前推24小时
endTime = new Date(now)
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
case 'yesterday':
// 昨天:基于系统时区的昨天
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
startTime = getSystemTimezoneDay(yesterday, true)
endTime = getSystemTimezoneDay(yesterday, false)
break
case 'dayBefore':
// 前天:基于系统时区的前天
const dayBefore = new Date()
dayBefore.setDate(dayBefore.getDate() - 2)
startTime = getSystemTimezoneDay(dayBefore, true)
endTime = getSystemTimezoneDay(dayBefore, false)
break
default:
// 默认近24小时
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
endTime = now
}
} else {
// 默认近24小时
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
endTime = now
}
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
}
} else {
// 天粒度,传递天数
days = dateFilter.value.type === 'preset'
? (dateFilter.value.preset === 'today' ? 1 : dateFilter.value.preset === '7days' ? 7 : 30)
: calculateDaysBetween(dateFilter.value.customStart, dateFilter.value.customEnd)
url += `granularity=day&days=${days}`
}
url += `&metric=${metric}`
const response = await apiClient.get(url)
if (response.success) {
apiKeysTrendData.value = {
data: response.data || [],
topApiKeys: response.topApiKeys || [],
totalApiKeys: response.totalApiKeys || 0
}
}
} catch (error) {
console.error('加载API Keys趋势失败:', error)
}
}
// 日期筛选相关方法
function setDateFilterPreset(preset) {
dateFilter.value.type = 'preset'
dateFilter.value.preset = preset
// 根据预设计算并设置具体的日期范围
const option = dateFilter.value.presetOptions.find(opt => opt.value === preset)
if (option) {
const now = new Date()
let startDate, endDate
if (trendGranularity.value === 'hour') {
// 小时粒度的预设
switch (preset) {
case 'last24h':
// 近24小时从当前时间往前推24小时
endDate = new Date(now)
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
case 'yesterday':
// 昨天:获取本地时间的昨天
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
// 转换为系统时区的昨天0点和23:59
startDate = getSystemTimezoneDay(yesterday, true)
endDate = getSystemTimezoneDay(yesterday, false)
break
case 'dayBefore':
// 前天:获取本地时间的前天
const dayBefore = new Date()
dayBefore.setDate(dayBefore.getDate() - 2)
// 转换为系统时区的前天0点和23:59
startDate = getSystemTimezoneDay(dayBefore, true)
endDate = getSystemTimezoneDay(dayBefore, false)
break
}
} else {
// 天粒度的预设
startDate = new Date(now)
endDate = new Date(now)
if (preset === 'today') {
// 今日:从凌晨开始
startDate.setHours(0, 0, 0, 0)
endDate.setHours(23, 59, 59, 999)
} else {
// 其他预设:按天数计算
startDate.setDate(now.getDate() - (option.days - 1))
startDate.setHours(0, 0, 0, 0)
endDate.setHours(23, 59, 59, 999)
}
}
dateFilter.value.customStart = startDate.toISOString().split('T')[0]
dateFilter.value.customEnd = endDate.toISOString().split('T')[0]
// 设置 customRange 为 Element Plus 需要的格式
// 对于小时粒度的昨天/前天,需要特殊处理显示
if (trendGranularity.value === 'hour' && (preset === 'yesterday' || preset === 'dayBefore')) {
// 获取本地日期
const targetDate = new Date()
if (preset === 'yesterday') {
targetDate.setDate(targetDate.getDate() - 1)
} else {
targetDate.setDate(targetDate.getDate() - 2)
}
// 显示系统时区的完整一天
const year = targetDate.getFullYear()
const month = String(targetDate.getMonth() + 1).padStart(2, '0')
const day = String(targetDate.getDate()).padStart(2, '0')
dateFilter.value.customRange = [
`${year}-${month}-${day} 00:00:00`,
`${year}-${month}-${day} 23:59:59`
]
} else {
// 其他情况近24小时或天粒度
const formatDateForDisplay = (date) => {
// 固定使用UTC+8来显示时间
const systemTz = 8
const tzOffset = systemTz * 60 * 60 * 1000
const localTime = new Date(date.getTime() + tzOffset)
const year = localTime.getUTCFullYear()
const month = String(localTime.getUTCMonth() + 1).padStart(2, '0')
const day = String(localTime.getUTCDate()).padStart(2, '0')
const hours = String(localTime.getUTCHours()).padStart(2, '0')
const minutes = String(localTime.getUTCMinutes()).padStart(2, '0')
const seconds = String(localTime.getUTCSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
dateFilter.value.customRange = [
formatDateForDisplay(startDate),
formatDateForDisplay(endDate)
]
}
}
// 触发数据刷新
refreshChartsData()
}
function onCustomDateRangeChange(value) {
if (value && value.length === 2) {
dateFilter.value.type = 'custom'
dateFilter.value.preset = '' // 清除预设选择
dateFilter.value.customRange = value
dateFilter.value.customStart = value[0].split(' ')[0]
dateFilter.value.customEnd = value[1].split(' ')[0]
// 检查日期范围限制 - value中的时间已经是系统时区时间
const systemTz = dashboardData.value.systemTimezone || 8
// 解析系统时区时间
const parseSystemTime = (timeStr) => {
const [datePart, timePart] = timeStr.split(' ')
const [year, month, day] = datePart.split('-').map(Number)
const [hours, minutes, seconds] = timePart.split(':').map(Number)
return new Date(year, month - 1, day, hours, minutes, seconds)
}
const start = parseSystemTime(value[0])
const end = parseSystemTime(value[1])
if (trendGranularity.value === 'hour') {
// 小时粒度:限制 24 小时
const hoursDiff = (end - start) / (1000 * 60 * 60)
if (hoursDiff > 24) {
showToast('小时粒度下日期范围不能超过24小时', 'warning')
return
}
} else {
// 天粒度:限制 31 天
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
if (daysDiff > 31) {
showToast('日期范围不能超过 31 天', 'warning')
return
}
}
// 触发数据刷新
refreshChartsData()
} else if (value === null) {
// 清空时恢复默认
setDateFilterPreset(trendGranularity.value === 'hour' ? 'last24h' : '7days')
}
}
function setTrendGranularity(granularity) {
trendGranularity.value = granularity
// 根据粒度更新预设选项
if (granularity === 'hour') {
dateFilter.value.presetOptions = [
{ value: 'last24h', label: '近24小时', hours: 24 },
{ value: 'yesterday', label: '昨天', hours: 24 },
{ value: 'dayBefore', label: '前天', hours: 24 }
]
// 检查当前自定义日期范围是否超过24小时
if (dateFilter.value.type === 'custom' && dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
const start = new Date(dateFilter.value.customRange[0])
const end = new Date(dateFilter.value.customRange[1])
const hoursDiff = (end - start) / (1000 * 60 * 60)
if (hoursDiff > 24) {
showToast('小时粒度下日期范围不能超过24小时已切换到近24小时', 'warning')
setDateFilterPreset('last24h')
return
}
}
// 如果当前是天粒度的预设,切换到小时粒度的默认预设
if (['today', '7days', '30days'].includes(dateFilter.value.preset)) {
setDateFilterPreset('last24h')
return
}
} else {
// 天粒度
dateFilter.value.presetOptions = [
{ value: 'today', label: '今日', days: 1 },
{ value: '7days', label: '7天', days: 7 },
{ value: '30days', label: '30天', days: 30 }
]
// 如果当前是小时粒度的预设,切换到天粒度的默认预设
if (['last24h', 'yesterday', 'dayBefore'].includes(dateFilter.value.preset)) {
setDateFilterPreset('7days')
return
}
}
// 触发数据刷新
refreshChartsData()
}
async function refreshChartsData() {
// 根据当前筛选条件刷新数据
let days
let modelPeriod = 'monthly'
if (dateFilter.value.type === 'preset') {
const option = dateFilter.value.presetOptions.find(opt => opt.value === dateFilter.value.preset)
if (trendGranularity.value === 'hour') {
// 小时粒度
days = 1 // 小时粒度默认查看1天的数据
modelPeriod = 'daily' // 小时粒度使用日统计
} else {
// 天粒度
days = option ? option.days : 7
// 设置模型统计期间
if (dateFilter.value.preset === 'today') {
modelPeriod = 'daily'
} else {
modelPeriod = 'monthly'
}
}
} else {
// 自定义日期范围
if (trendGranularity.value === 'hour') {
// 小时粒度下的自定义范围,计算小时数
const start = new Date(dateFilter.value.customRange[0])
const end = new Date(dateFilter.value.customRange[1])
const hoursDiff = Math.ceil((end - start) / (1000 * 60 * 60))
days = Math.ceil(hoursDiff / 24) || 1
} else {
days = calculateDaysBetween(dateFilter.value.customStart, dateFilter.value.customEnd)
}
modelPeriod = 'daily' // 自定义范围使用日统计
}
await Promise.all([
loadUsageTrend(days, trendGranularity.value),
loadModelStats(modelPeriod),
loadApiKeysTrend(apiKeysTrendMetric.value)
])
}
function calculateDaysBetween(start, end) {
if (!start || !end) return 7
const startDate = new Date(start)
const endDate = new Date(end)
const diffTime = Math.abs(endDate - startDate)
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return diffDays || 7
}
function disabledDate(date) {
return date > new Date()
}
return {
// 状态
loading,
dashboardData,
costsData,
modelStats,
trendData,
dashboardModelStats,
apiKeysTrendData,
dateFilter,
trendGranularity,
apiKeysTrendMetric,
defaultTime,
// 计算属性
formattedUptime,
// 方法
loadDashboardData,
loadUsageTrend,
loadModelStats,
loadApiKeysTrend,
setDateFilterPreset,
onCustomDateRangeChange,
setTrendGranularity,
refreshChartsData,
disabledDate
}
})