mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 支持账号维度的数据统计
This commit is contained in:
@@ -4052,6 +4052,198 @@ router.get('/accounts/:accountId/usage-stats', authenticateAdmin, async (req, re
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取账号近30天使用历史
|
||||||
|
router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { platform = 'claude', days = 30 } = req.query
|
||||||
|
|
||||||
|
const allowedPlatforms = ['claude', 'claude-console', 'openai', 'openai-responses', 'gemini']
|
||||||
|
if (!allowedPlatforms.includes(platform)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Unsupported account platform'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountTypeMap = {
|
||||||
|
openai: 'openai',
|
||||||
|
'openai-responses': 'openai-responses'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackModelMap = {
|
||||||
|
claude: 'claude-3-5-sonnet-20241022',
|
||||||
|
'claude-console': 'claude-3-5-sonnet-20241022',
|
||||||
|
openai: 'gpt-4o-mini-2024-07-18',
|
||||||
|
'openai-responses': 'gpt-4o-mini-2024-07-18',
|
||||||
|
gemini: 'gemini-1.5-flash'
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const fallbackModel = fallbackModelMap[platform] || 'unknown'
|
||||||
|
const daysCount = Math.min(Math.max(parseInt(days, 10) || 30, 1), 60)
|
||||||
|
|
||||||
|
// 获取概览统计数据
|
||||||
|
const accountUsageStats = await redis.getAccountUsageStats(
|
||||||
|
accountId,
|
||||||
|
accountTypeMap[platform] || null
|
||||||
|
)
|
||||||
|
|
||||||
|
const history = []
|
||||||
|
let totalCost = 0
|
||||||
|
let totalRequests = 0
|
||||||
|
let totalTokens = 0
|
||||||
|
|
||||||
|
let highestCostDay = null
|
||||||
|
let highestRequestDay = null
|
||||||
|
|
||||||
|
const sumModelCostsForDay = async (dateKey) => {
|
||||||
|
const modelPattern = `account_usage:model:daily:${accountId}:*:${dateKey}`
|
||||||
|
const modelKeys = await client.keys(modelPattern)
|
||||||
|
let summedCost = 0
|
||||||
|
|
||||||
|
if (modelKeys.length === 0) {
|
||||||
|
return summedCost
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const modelKey of modelKeys) {
|
||||||
|
const modelParts = modelKey.split(':')
|
||||||
|
const modelName = modelParts[4] || 'unknown'
|
||||||
|
const modelData = await client.hgetall(modelKey)
|
||||||
|
if (!modelData || Object.keys(modelData).length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = {
|
||||||
|
input_tokens: parseInt(modelData.inputTokens) || 0,
|
||||||
|
output_tokens: parseInt(modelData.outputTokens) || 0,
|
||||||
|
cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0,
|
||||||
|
cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const costResult = CostCalculator.calculateCost(usage, modelName)
|
||||||
|
summedCost += costResult.costs.total
|
||||||
|
}
|
||||||
|
|
||||||
|
return summedCost
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
|
||||||
|
for (let offset = daysCount - 1; offset >= 0; offset--) {
|
||||||
|
const date = new Date(today)
|
||||||
|
date.setDate(date.getDate() - offset)
|
||||||
|
|
||||||
|
const tzDate = redis.getDateInTimezone(date)
|
||||||
|
const dateKey = redis.getDateStringInTimezone(date)
|
||||||
|
const monthLabel = String(tzDate.getUTCMonth() + 1).padStart(2, '0')
|
||||||
|
const dayLabel = String(tzDate.getUTCDate()).padStart(2, '0')
|
||||||
|
const label = `${monthLabel}/${dayLabel}`
|
||||||
|
|
||||||
|
const dailyKey = `account_usage:daily:${accountId}:${dateKey}`
|
||||||
|
const dailyData = await client.hgetall(dailyKey)
|
||||||
|
|
||||||
|
const inputTokens = parseInt(dailyData?.inputTokens) || 0
|
||||||
|
const outputTokens = parseInt(dailyData?.outputTokens) || 0
|
||||||
|
const cacheCreateTokens = parseInt(dailyData?.cacheCreateTokens) || 0
|
||||||
|
const cacheReadTokens = parseInt(dailyData?.cacheReadTokens) || 0
|
||||||
|
const allTokens =
|
||||||
|
parseInt(dailyData?.allTokens) ||
|
||||||
|
inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||||
|
const requests = parseInt(dailyData?.requests) || 0
|
||||||
|
|
||||||
|
let cost = await sumModelCostsForDay(dateKey)
|
||||||
|
|
||||||
|
if (cost === 0 && allTokens > 0) {
|
||||||
|
const fallbackUsage = {
|
||||||
|
input_tokens: inputTokens,
|
||||||
|
output_tokens: outputTokens,
|
||||||
|
cache_creation_input_tokens: cacheCreateTokens,
|
||||||
|
cache_read_input_tokens: cacheReadTokens
|
||||||
|
}
|
||||||
|
const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel)
|
||||||
|
cost = fallbackResult.costs.total
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCost = Math.round(cost * 1_000_000) / 1_000_000
|
||||||
|
|
||||||
|
totalCost += normalizedCost
|
||||||
|
totalRequests += requests
|
||||||
|
totalTokens += allTokens
|
||||||
|
|
||||||
|
if (!highestCostDay || normalizedCost > highestCostDay.cost) {
|
||||||
|
highestCostDay = {
|
||||||
|
date: dateKey,
|
||||||
|
label,
|
||||||
|
cost: normalizedCost,
|
||||||
|
formattedCost: CostCalculator.formatCost(normalizedCost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!highestRequestDay || requests > highestRequestDay.requests) {
|
||||||
|
highestRequestDay = {
|
||||||
|
date: dateKey,
|
||||||
|
label,
|
||||||
|
requests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
history.push({
|
||||||
|
date: dateKey,
|
||||||
|
label,
|
||||||
|
cost: normalizedCost,
|
||||||
|
formattedCost: CostCalculator.formatCost(normalizedCost),
|
||||||
|
requests,
|
||||||
|
tokens: allTokens
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgDailyCost = daysCount > 0 ? totalCost / daysCount : 0
|
||||||
|
const avgDailyRequests = daysCount > 0 ? totalRequests / daysCount : 0
|
||||||
|
const avgDailyTokens = daysCount > 0 ? totalTokens / daysCount : 0
|
||||||
|
|
||||||
|
const todayData = history.length > 0 ? history[history.length - 1] : null
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
history,
|
||||||
|
summary: {
|
||||||
|
days: daysCount,
|
||||||
|
totalCost,
|
||||||
|
totalCostFormatted: CostCalculator.formatCost(totalCost),
|
||||||
|
totalRequests,
|
||||||
|
totalTokens,
|
||||||
|
avgDailyCost,
|
||||||
|
avgDailyCostFormatted: CostCalculator.formatCost(avgDailyCost),
|
||||||
|
avgDailyRequests,
|
||||||
|
avgDailyTokens,
|
||||||
|
today: todayData
|
||||||
|
? {
|
||||||
|
date: todayData.date,
|
||||||
|
cost: todayData.cost,
|
||||||
|
costFormatted: todayData.formattedCost,
|
||||||
|
requests: todayData.requests,
|
||||||
|
tokens: todayData.tokens
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
highestCostDay,
|
||||||
|
highestRequestDay
|
||||||
|
},
|
||||||
|
overview: accountUsageStats,
|
||||||
|
generatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get account usage history:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get account usage history',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 📊 系统统计
|
// 📊 系统统计
|
||||||
|
|
||||||
// 获取系统概览
|
// 获取系统概览
|
||||||
@@ -5158,6 +5350,345 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) =
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取按账号分组的使用趋势
|
||||||
|
router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query
|
||||||
|
|
||||||
|
const allowedGroups = ['claude', 'openai', 'gemini']
|
||||||
|
if (!allowedGroups.includes(group)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid account group'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupLabels = {
|
||||||
|
claude: 'Claude账户',
|
||||||
|
openai: 'OpenAI账户',
|
||||||
|
gemini: 'Gemini账户'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取各平台账号列表
|
||||||
|
let accounts = []
|
||||||
|
if (group === 'claude') {
|
||||||
|
const [claudeAccounts, claudeConsoleAccounts] = await Promise.all([
|
||||||
|
claudeAccountService.getAllAccounts(),
|
||||||
|
claudeConsoleAccountService.getAllAccounts()
|
||||||
|
])
|
||||||
|
|
||||||
|
accounts = [
|
||||||
|
...claudeAccounts.map((account) => {
|
||||||
|
const id = String(account.id || '')
|
||||||
|
const shortId = id ? id.slice(0, 8) : '未知'
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: account.name || account.email || `Claude账号 ${shortId}`,
|
||||||
|
platform: 'claude'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
...claudeConsoleAccounts.map((account) => {
|
||||||
|
const id = String(account.id || '')
|
||||||
|
const shortId = id ? id.slice(0, 8) : '未知'
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: account.name || `Console账号 ${shortId}`,
|
||||||
|
platform: 'claude-console'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
} else if (group === 'openai') {
|
||||||
|
const [openaiAccounts, openaiResponsesAccounts] = await Promise.all([
|
||||||
|
openaiAccountService.getAllAccounts(),
|
||||||
|
openaiResponsesAccountService.getAllAccounts(true)
|
||||||
|
])
|
||||||
|
|
||||||
|
accounts = [
|
||||||
|
...openaiAccounts.map((account) => {
|
||||||
|
const id = String(account.id || '')
|
||||||
|
const shortId = id ? id.slice(0, 8) : '未知'
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: account.name || account.email || `OpenAI账号 ${shortId}`,
|
||||||
|
platform: 'openai'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
...openaiResponsesAccounts.map((account) => {
|
||||||
|
const id = String(account.id || '')
|
||||||
|
const shortId = id ? id.slice(0, 8) : '未知'
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: account.name || `Responses账号 ${shortId}`,
|
||||||
|
platform: 'openai-responses'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
} else if (group === 'gemini') {
|
||||||
|
const geminiAccounts = await geminiAccountService.getAllAccounts()
|
||||||
|
accounts = geminiAccounts.map((account) => {
|
||||||
|
const id = String(account.id || '')
|
||||||
|
const shortId = id ? id.slice(0, 8) : '未知'
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: account.name || account.email || `Gemini账号 ${shortId}`,
|
||||||
|
platform: 'gemini'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accounts || accounts.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: [],
|
||||||
|
granularity,
|
||||||
|
group,
|
||||||
|
groupLabel: groupLabels[group],
|
||||||
|
topAccounts: [],
|
||||||
|
totalAccounts: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountMap = new Map()
|
||||||
|
const accountIdSet = new Set()
|
||||||
|
for (const account of accounts) {
|
||||||
|
accountMap.set(account.id, {
|
||||||
|
name: account.name,
|
||||||
|
platform: account.platform
|
||||||
|
})
|
||||||
|
accountIdSet.add(account.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackModelByGroup = {
|
||||||
|
claude: 'claude-3-5-sonnet-20241022',
|
||||||
|
openai: 'gpt-4o-mini-2024-07-18',
|
||||||
|
gemini: 'gemini-1.5-flash'
|
||||||
|
}
|
||||||
|
const fallbackModel = fallbackModelByGroup[group] || 'unknown'
|
||||||
|
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const trendData = []
|
||||||
|
const accountCostTotals = new Map()
|
||||||
|
|
||||||
|
const sumModelCosts = async (accountId, period, timeKey) => {
|
||||||
|
const modelPattern = `account_usage:model:${period}:${accountId}:*:${timeKey}`
|
||||||
|
const modelKeys = await client.keys(modelPattern)
|
||||||
|
let totalCost = 0
|
||||||
|
|
||||||
|
for (const modelKey of modelKeys) {
|
||||||
|
const modelData = await client.hgetall(modelKey)
|
||||||
|
if (!modelData) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = modelKey.split(':')
|
||||||
|
if (parts.length < 5) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelName = parts[4]
|
||||||
|
const usage = {
|
||||||
|
input_tokens: parseInt(modelData.inputTokens) || 0,
|
||||||
|
output_tokens: parseInt(modelData.outputTokens) || 0,
|
||||||
|
cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0,
|
||||||
|
cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const costResult = CostCalculator.calculateCost(usage, modelName)
|
||||||
|
totalCost += costResult.costs.total
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalCost
|
||||||
|
}
|
||||||
|
|
||||||
|
if (granularity === 'hour') {
|
||||||
|
let startTime
|
||||||
|
let endTime
|
||||||
|
|
||||||
|
if (startDate && endDate) {
|
||||||
|
startTime = new Date(startDate)
|
||||||
|
endTime = new Date(endDate)
|
||||||
|
} else {
|
||||||
|
endTime = new Date()
|
||||||
|
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentHour = new Date(startTime)
|
||||||
|
currentHour.setMinutes(0, 0, 0)
|
||||||
|
|
||||||
|
while (currentHour <= endTime) {
|
||||||
|
const tzCurrentHour = redis.getDateInTimezone(currentHour)
|
||||||
|
const dateStr = redis.getDateStringInTimezone(currentHour)
|
||||||
|
const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0')
|
||||||
|
const hourKey = `${dateStr}:${hour}`
|
||||||
|
|
||||||
|
const tzDateForLabel = redis.getDateInTimezone(currentHour)
|
||||||
|
const monthLabel = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0')
|
||||||
|
const dayLabel = String(tzDateForLabel.getUTCDate()).padStart(2, '0')
|
||||||
|
const hourLabel = String(tzDateForLabel.getUTCHours()).padStart(2, '0')
|
||||||
|
|
||||||
|
const hourData = {
|
||||||
|
hour: currentHour.toISOString(),
|
||||||
|
label: `${monthLabel}/${dayLabel} ${hourLabel}:00`,
|
||||||
|
accounts: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = `account_usage:hourly:*:${hourKey}`
|
||||||
|
const keys = await client.keys(pattern)
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const match = key.match(/account_usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/)
|
||||||
|
if (!match) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountId = match[1]
|
||||||
|
if (!accountIdSet.has(accountId)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await client.hgetall(key)
|
||||||
|
if (!data) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputTokens = parseInt(data.inputTokens) || 0
|
||||||
|
const outputTokens = parseInt(data.outputTokens) || 0
|
||||||
|
const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
|
||||||
|
const cacheReadTokens = parseInt(data.cacheReadTokens) || 0
|
||||||
|
const allTokens =
|
||||||
|
parseInt(data.allTokens) ||
|
||||||
|
inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||||
|
const requests = parseInt(data.requests) || 0
|
||||||
|
|
||||||
|
let cost = await sumModelCosts(accountId, 'hourly', hourKey)
|
||||||
|
|
||||||
|
if (cost === 0 && allTokens > 0) {
|
||||||
|
const fallbackUsage = {
|
||||||
|
input_tokens: inputTokens,
|
||||||
|
output_tokens: outputTokens,
|
||||||
|
cache_creation_input_tokens: cacheCreateTokens,
|
||||||
|
cache_read_input_tokens: cacheReadTokens
|
||||||
|
}
|
||||||
|
const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel)
|
||||||
|
cost = fallbackResult.costs.total
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedCost = CostCalculator.formatCost(cost)
|
||||||
|
const accountInfo = accountMap.get(accountId)
|
||||||
|
|
||||||
|
hourData.accounts[accountId] = {
|
||||||
|
name: accountInfo ? accountInfo.name : `账号 ${accountId.slice(0, 8)}`,
|
||||||
|
cost,
|
||||||
|
formattedCost,
|
||||||
|
requests
|
||||||
|
}
|
||||||
|
|
||||||
|
accountCostTotals.set(accountId, (accountCostTotals.get(accountId) || 0) + cost)
|
||||||
|
}
|
||||||
|
|
||||||
|
trendData.push(hourData)
|
||||||
|
currentHour.setHours(currentHour.getHours() + 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const daysCount = parseInt(days) || 7
|
||||||
|
const today = new Date()
|
||||||
|
|
||||||
|
for (let i = 0; i < daysCount; i++) {
|
||||||
|
const date = new Date(today)
|
||||||
|
date.setDate(date.getDate() - i)
|
||||||
|
const dateStr = redis.getDateStringInTimezone(date)
|
||||||
|
|
||||||
|
const dayData = {
|
||||||
|
date: dateStr,
|
||||||
|
accounts: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = `account_usage:daily:*:${dateStr}`
|
||||||
|
const keys = await client.keys(pattern)
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const match = key.match(/account_usage:daily:(.+?):\d{4}-\d{2}-\d{2}/)
|
||||||
|
if (!match) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountId = match[1]
|
||||||
|
if (!accountIdSet.has(accountId)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await client.hgetall(key)
|
||||||
|
if (!data) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputTokens = parseInt(data.inputTokens) || 0
|
||||||
|
const outputTokens = parseInt(data.outputTokens) || 0
|
||||||
|
const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
|
||||||
|
const cacheReadTokens = parseInt(data.cacheReadTokens) || 0
|
||||||
|
const allTokens =
|
||||||
|
parseInt(data.allTokens) ||
|
||||||
|
inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||||
|
const requests = parseInt(data.requests) || 0
|
||||||
|
|
||||||
|
let cost = await sumModelCosts(accountId, 'daily', dateStr)
|
||||||
|
|
||||||
|
if (cost === 0 && allTokens > 0) {
|
||||||
|
const fallbackUsage = {
|
||||||
|
input_tokens: inputTokens,
|
||||||
|
output_tokens: outputTokens,
|
||||||
|
cache_creation_input_tokens: cacheCreateTokens,
|
||||||
|
cache_read_input_tokens: cacheReadTokens
|
||||||
|
}
|
||||||
|
const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel)
|
||||||
|
cost = fallbackResult.costs.total
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedCost = CostCalculator.formatCost(cost)
|
||||||
|
const accountInfo = accountMap.get(accountId)
|
||||||
|
|
||||||
|
dayData.accounts[accountId] = {
|
||||||
|
name: accountInfo ? accountInfo.name : `账号 ${accountId.slice(0, 8)}`,
|
||||||
|
cost,
|
||||||
|
formattedCost,
|
||||||
|
requests
|
||||||
|
}
|
||||||
|
|
||||||
|
accountCostTotals.set(accountId, (accountCostTotals.get(accountId) || 0) + cost)
|
||||||
|
}
|
||||||
|
|
||||||
|
trendData.push(dayData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (granularity === 'hour') {
|
||||||
|
trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour))
|
||||||
|
} else {
|
||||||
|
trendData.sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||||
|
}
|
||||||
|
|
||||||
|
const topAccounts = Array.from(accountCostTotals.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 30)
|
||||||
|
.map(([accountId]) => accountId)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: trendData,
|
||||||
|
granularity,
|
||||||
|
group,
|
||||||
|
groupLabel: groupLabels[group],
|
||||||
|
topAccounts,
|
||||||
|
totalAccounts: accountCostTotals.size
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get account usage trend:', error)
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Failed to get account usage trend', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 获取按API Key分组的使用趋势
|
// 获取按API Key分组的使用趋势
|
||||||
router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
|
router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -497,6 +497,9 @@ class ClaudeAccountService {
|
|||||||
schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据
|
schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据
|
||||||
// 添加自动停止调度设置
|
// 添加自动停止调度设置
|
||||||
autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false
|
autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false
|
||||||
|
// 添加5小时自动停止状态
|
||||||
|
fiveHourAutoStopped: account.fiveHourAutoStopped === 'true',
|
||||||
|
fiveHourStoppedAt: account.fiveHourStoppedAt || null,
|
||||||
// 添加统一User-Agent设置
|
// 添加统一User-Agent设置
|
||||||
useUnifiedUserAgent: account.useUnifiedUserAgent === 'true', // 默认为false
|
useUnifiedUserAgent: account.useUnifiedUserAgent === 'true', // 默认为false
|
||||||
// 添加统一客户端标识设置
|
// 添加统一客户端标识设置
|
||||||
@@ -2333,7 +2336,7 @@ class ClaudeAccountService {
|
|||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
// 只检查因5小时限制被自动停止的账号
|
// 只检查因5小时限制被自动停止的账号
|
||||||
// 重要:不恢复手动停止的账号(没有fiveHourAutoStopped标记的)
|
// 重要:不恢复手动停止的账号(没有fiveHourAutoStopped标记的)
|
||||||
if (account.fiveHourAutoStopped === 'true' && account.schedulable === 'false') {
|
if (account.fiveHourAutoStopped === true && account.schedulable === false) {
|
||||||
result.checked++
|
result.checked++
|
||||||
|
|
||||||
// 使用分布式锁防止并发修改
|
// 使用分布式锁防止并发修改
|
||||||
|
|||||||
@@ -0,0 +1,630 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0" @click="handleClose" />
|
||||||
|
<div
|
||||||
|
class="relative z-10 mx-3 flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4 sm:p-1"
|
||||||
|
>
|
||||||
|
<!-- 顶部栏 -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-indigo-500 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chart-area text-lg" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 sm:text-xl">
|
||||||
|
{{ account?.name || account?.email || '账号使用详情' }}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
v-if="account?.platform"
|
||||||
|
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-500/10 dark:text-blue-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-layer-group mr-1" />{{ platformLabel }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="account?.accountType"
|
||||||
|
class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-500/10 dark:text-purple-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user-tag mr-1" />{{ accountTypeLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 sm:text-sm">
|
||||||
|
近 {{ summary?.days || 30 }} 天内的费用与请求趋势
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-5 py-4 sm:px-6">
|
||||||
|
<div v-if="loading" class="flex h-[50vh] items-center justify-center">
|
||||||
|
<div class="loading-spinner h-12 w-12 border-4 border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<!-- 关键指标 -->
|
||||||
|
<div class="mb-5 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div
|
||||||
|
v-for="metric in primaryMetrics"
|
||||||
|
:key="metric.key"
|
||||||
|
class="rounded-2xl border border-gray-100 bg-white/80 p-4 shadow-sm transition dark:border-gray-700 dark:bg-gray-900/70"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ metric.label }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ metric.value }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{{ metric.subtitle }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-50 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<i :class="['fas', metric.icon, metric.iconClass]"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 今日与峰值 -->
|
||||||
|
<div class="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<div
|
||||||
|
class="space-y-3 rounded-2xl border border-blue-100 bg-blue-50/60 p-4 dark:border-blue-500/20 dark:bg-blue-900/20"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-sm font-semibold text-blue-700 dark:text-blue-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sun" />
|
||||||
|
今日概览
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-xl bg-white/80 p-3 text-sm text-gray-600 shadow-sm ring-1 ring-blue-100 dark:bg-gray-900/80 dark:text-gray-300 dark:ring-blue-500/20"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>费用</span>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">{{
|
||||||
|
summary?.today?.costFormatted || '$0.000000'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center justify-between">
|
||||||
|
<span>请求</span>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">{{
|
||||||
|
formatNumber(summary?.today?.requests || 0)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<span>Tokens</span>
|
||||||
|
<span>{{ formatNumber(summary?.today?.tokens || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="space-y-3 rounded-2xl border border-amber-100 bg-amber-50/70 p-4 dark:border-amber-500/20 dark:bg-amber-900/20"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-sm font-semibold text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-crown" />
|
||||||
|
最高费用日
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-xl bg-white/80 p-3 text-sm text-gray-600 shadow-sm ring-1 ring-amber-100 dark:bg-gray-900/80 dark:text-gray-300 dark:ring-amber-500/20"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>日期</span>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">{{
|
||||||
|
formatDate(summary?.highestCostDay?.date)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center justify-between">
|
||||||
|
<span>费用</span>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">{{
|
||||||
|
summary?.highestCostDay?.formattedCost || '$0.000000'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<span>请求</span>
|
||||||
|
<span>{{
|
||||||
|
formatNumber(findHistoryValue(summary?.highestCostDay?.date, 'requests'))
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="space-y-3 rounded-2xl border border-emerald-100 bg-emerald-50/60 p-4 dark:border-emerald-500/20 dark:bg-emerald-900/20"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-sm font-semibold text-emerald-700 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chart-bar" />
|
||||||
|
最高请求日
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-xl bg-white/80 p-3 text-sm text-gray-600 shadow-sm ring-1 ring-emerald-100 dark:bg-gray-900/80 dark:text-gray-300 dark:ring-emerald-500/20"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>日期</span>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">{{
|
||||||
|
formatDate(summary?.highestRequestDay?.date)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center justify-between">
|
||||||
|
<span>请求</span>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">{{
|
||||||
|
formatNumber(summary?.highestRequestDay?.requests || 0)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<span>费用</span>
|
||||||
|
<span>{{
|
||||||
|
formatCost(findHistoryValue(summary?.highestRequestDay?.date, 'cost'))
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 综合统计 -->
|
||||||
|
<div class="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-gray-100 bg-white/80 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/70"
|
||||||
|
>
|
||||||
|
<h4
|
||||||
|
class="mb-3 flex items-center text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-database mr-2 text-indigo-500" /> 累计 Token
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>30天总计</span>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">{{
|
||||||
|
formatNumber(totalTokens)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>日均 Token</span>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">{{
|
||||||
|
formatNumber(Math.round(summary?.avgDailyTokens || 0))
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<span>输入 / 输出</span>
|
||||||
|
<span
|
||||||
|
>{{ formatNumber(overviewInputTokens) }} /
|
||||||
|
{{ formatNumber(overviewOutputTokens) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-gray-100 bg-white/80 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/70"
|
||||||
|
>
|
||||||
|
<h4
|
||||||
|
class="mb-3 flex items-center text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-tachometer-alt mr-2 text-purple-500" /> 平均速率
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>RPM</span>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">{{
|
||||||
|
overview?.averages?.rpm ?? 0
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>TPM</span>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">{{
|
||||||
|
overview?.averages?.tpm ?? 0
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<span>日均请求 / Token</span>
|
||||||
|
<span
|
||||||
|
>{{
|
||||||
|
formatNumber(
|
||||||
|
Math.round((overview?.averages?.dailyRequests || 0) * 100) / 100
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/
|
||||||
|
{{
|
||||||
|
formatNumber(Math.round((overview?.averages?.dailyTokens || 0) * 100) / 100)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-gray-100 bg-white/80 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/70"
|
||||||
|
>
|
||||||
|
<h4
|
||||||
|
class="mb-3 flex items-center text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-layer-group mr-2 text-teal-500" /> 最近统计
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>今日请求</span>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">{{
|
||||||
|
formatNumber(overview?.daily?.requests || 0)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>今日 Token</span>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">{{
|
||||||
|
formatNumber(overview?.daily?.allTokens || 0)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<span>今日费用</span>
|
||||||
|
<span>{{ formatCost(overview?.daily?.cost || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 折线图 -->
|
||||||
|
<div
|
||||||
|
class="mb-6 rounded-2xl border border-gray-100 bg-white/80 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/70"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h4
|
||||||
|
class="flex items-center text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chart-line mr-2 text-blue-500" /> 30天费用与请求趋势
|
||||||
|
</h4>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
最新更新时间:{{ formatDateTime(generatedAtDisplay) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-[260px] sm:h-[300px]">
|
||||||
|
<canvas ref="chartCanvas" class="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import Chart from 'chart.js/auto'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: { type: Boolean, default: false },
|
||||||
|
account: { type: Object, default: () => ({}) },
|
||||||
|
history: { type: Array, default: () => [] },
|
||||||
|
summary: { type: Object, default: () => ({}) },
|
||||||
|
overview: { type: Object, default: () => ({}) },
|
||||||
|
generatedAt: { type: String, default: '' },
|
||||||
|
loading: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
const { isDarkMode } = storeToRefs(themeStore)
|
||||||
|
|
||||||
|
const chartCanvas = ref(null)
|
||||||
|
let chartInstance = null
|
||||||
|
|
||||||
|
const platformLabelMap = {
|
||||||
|
claude: 'Claude',
|
||||||
|
'claude-console': 'Claude Console',
|
||||||
|
openai: 'OpenAI',
|
||||||
|
'openai-responses': 'OpenAI Responses',
|
||||||
|
gemini: 'Gemini'
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformLabel = computed(() => platformLabelMap[props.account?.platform] || '未知平台')
|
||||||
|
|
||||||
|
const accountTypeLabel = computed(() => {
|
||||||
|
if (!props.account?.accountType) return '共享'
|
||||||
|
if (props.account.accountType === 'dedicated') return '专属'
|
||||||
|
if (props.account.accountType === 'group') return '分组'
|
||||||
|
return '共享'
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartColors = computed(() => ({
|
||||||
|
text: isDarkMode.value ? '#e5e7eb' : '#374151',
|
||||||
|
grid: isDarkMode.value ? 'rgba(75, 85, 99, 0.25)' : 'rgba(209, 213, 219, 0.4)',
|
||||||
|
cost: '#3b82f6',
|
||||||
|
costFill: 'rgba(59, 130, 246, 0.15)',
|
||||||
|
requests: '#f97316'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const totalTokens = computed(() => props.summary?.totalTokens || 0)
|
||||||
|
const overviewInputTokens = computed(() => props.overview?.total?.inputTokens || 0)
|
||||||
|
const overviewOutputTokens = computed(() => props.overview?.total?.outputTokens || 0)
|
||||||
|
|
||||||
|
const formatNumber = (value) => {
|
||||||
|
const num = Number(value || 0)
|
||||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(2)}M`
|
||||||
|
if (num >= 1_000) return `${(num / 1_000).toFixed(2)}K`
|
||||||
|
return num.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCost = (value) => {
|
||||||
|
const num = Number(value || 0)
|
||||||
|
if (Number.isNaN(num)) return '$0.000000'
|
||||||
|
if (num >= 1) return `$${num.toFixed(2)}`
|
||||||
|
if (num >= 0.01) return `$${num.toFixed(3)}`
|
||||||
|
return `$${num.toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundToTwo = (value) => Math.round((Number(value) || 0) * 100) / 100
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '-'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
const parts = value.split('-')
|
||||||
|
if (parts.length === 3) {
|
||||||
|
return `${parts[1]}-${parts[2]}`
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) return '暂无'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const findHistoryValue = (date, field) => {
|
||||||
|
if (!date) return 0
|
||||||
|
const target = props.history.find((item) => item.date === date)
|
||||||
|
return target ? target[field] || 0 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedAtDisplay = computed(
|
||||||
|
() => props.generatedAt || props.summary?.generatedAt || props.summary?.generated_at || ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const primaryMetrics = computed(() => [
|
||||||
|
{
|
||||||
|
key: 'totalCost',
|
||||||
|
label: '30天总费用',
|
||||||
|
value: props.summary?.totalCostFormatted || '$0.000000',
|
||||||
|
subtitle: '累计成本',
|
||||||
|
icon: 'fa-file-invoice-dollar',
|
||||||
|
iconClass: 'text-emerald-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'totalRequests',
|
||||||
|
label: '30天总请求',
|
||||||
|
value: formatNumber(props.summary?.totalRequests || 0),
|
||||||
|
subtitle: '调用次数',
|
||||||
|
icon: 'fa-paper-plane',
|
||||||
|
iconClass: 'text-blue-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avgCost',
|
||||||
|
label: '日均费用',
|
||||||
|
value: props.summary?.avgDailyCostFormatted || formatCost(props.summary?.avgDailyCost || 0),
|
||||||
|
subtitle: '平均每日成本',
|
||||||
|
icon: 'fa-wave-square',
|
||||||
|
iconClass: 'text-purple-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avgRequests',
|
||||||
|
label: '日均请求',
|
||||||
|
value: formatNumber(roundToTwo(props.summary?.avgDailyRequests || 0)),
|
||||||
|
subtitle: '平均每日调用',
|
||||||
|
icon: 'fa-chart-line',
|
||||||
|
iconClass: 'text-orange-500'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const renderChart = async () => {
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
if (!props.show || !chartCanvas.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartInstance) {
|
||||||
|
chartInstance.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.history || props.history.length === 0) {
|
||||||
|
chartInstance = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = props.history.map((item) => item.label)
|
||||||
|
const costs = props.history.map((item) => item.cost || 0)
|
||||||
|
const requests = props.history.map((item) => item.requests || 0)
|
||||||
|
|
||||||
|
chartInstance = new Chart(chartCanvas.value, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: '费用 (USD)',
|
||||||
|
data: costs,
|
||||||
|
borderColor: chartColors.value.cost,
|
||||||
|
backgroundColor: chartColors.value.costFill,
|
||||||
|
tension: 0.35,
|
||||||
|
fill: true,
|
||||||
|
yAxisID: 'y'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '请求次数',
|
||||||
|
data: requests,
|
||||||
|
borderColor: chartColors.value.requests,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
tension: 0.35,
|
||||||
|
yAxisID: 'y1'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
color: chartColors.value.text
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label(context) {
|
||||||
|
if (context.dataset.label === '费用 (USD)') {
|
||||||
|
return `${context.dataset.label}: ${formatCost(context.parsed.y)}`
|
||||||
|
}
|
||||||
|
return `${context.dataset.label}: ${formatNumber(context.parsed.y)} 次`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
color: chartColors.value.text
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: chartColors.value.grid
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
position: 'left',
|
||||||
|
ticks: {
|
||||||
|
color: chartColors.value.text,
|
||||||
|
callback: (value) => formatCost(value)
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: chartColors.value.grid
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
position: 'right',
|
||||||
|
ticks: {
|
||||||
|
color: chartColors.value.text,
|
||||||
|
callback: (value) => formatNumber(value)
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
drawOnChartArea: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupChart = () => {
|
||||||
|
if (chartInstance) {
|
||||||
|
chartInstance.destroy()
|
||||||
|
chartInstance = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
cleanupChart()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(visible) => {
|
||||||
|
if (visible && !props.loading) {
|
||||||
|
renderChart()
|
||||||
|
} else if (!visible) {
|
||||||
|
cleanupChart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.loading,
|
||||||
|
(loading) => {
|
||||||
|
if (!loading && props.show) {
|
||||||
|
renderChart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.history,
|
||||||
|
() => {
|
||||||
|
if (props.show && !props.loading) {
|
||||||
|
renderChart()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(isDarkMode, () => {
|
||||||
|
if (props.show && !props.loading) {
|
||||||
|
renderChart()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cleanupChart()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading-spinner {
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 9999px;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -59,6 +59,13 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
topApiKeys: [],
|
topApiKeys: [],
|
||||||
totalApiKeys: 0
|
totalApiKeys: 0
|
||||||
})
|
})
|
||||||
|
const accountUsageTrendData = ref({
|
||||||
|
data: [],
|
||||||
|
topAccounts: [],
|
||||||
|
totalAccounts: 0,
|
||||||
|
group: 'claude',
|
||||||
|
groupLabel: 'Claude账户'
|
||||||
|
})
|
||||||
|
|
||||||
// 日期筛选
|
// 日期筛选
|
||||||
const dateFilter = ref({
|
const dateFilter = ref({
|
||||||
@@ -77,6 +84,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
// 趋势图粒度
|
// 趋势图粒度
|
||||||
const trendGranularity = ref('day') // 'day' 或 'hour'
|
const trendGranularity = ref('day') // 'day' 或 'hour'
|
||||||
const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens'
|
const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens'
|
||||||
|
const accountUsageGroup = ref('claude') // claude | openai | gemini
|
||||||
|
|
||||||
// 默认时间
|
// 默认时间
|
||||||
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
|
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
|
||||||
@@ -503,6 +511,97 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAccountUsageTrend(group = accountUsageGroup.value) {
|
||||||
|
try {
|
||||||
|
let url = '/admin/account-usage-trend?'
|
||||||
|
let days = 7
|
||||||
|
|
||||||
|
if (trendGranularity.value === 'hour') {
|
||||||
|
url += `granularity=hour`
|
||||||
|
|
||||||
|
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||||
|
const convertToUTC = (systemTzTimeStr) => {
|
||||||
|
const systemTz = 8
|
||||||
|
const [datePart, timePart] = systemTzTimeStr.split(' ')
|
||||||
|
const [year, month, day] = datePart.split('-').map(Number)
|
||||||
|
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const now = new Date()
|
||||||
|
let startTime
|
||||||
|
let endTime
|
||||||
|
|
||||||
|
if (dateFilter.value.type === 'preset') {
|
||||||
|
switch (dateFilter.value.preset) {
|
||||||
|
case 'last24h': {
|
||||||
|
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: {
|
||||||
|
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||||
|
endTime = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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 += `&group=${group}`
|
||||||
|
|
||||||
|
const response = await apiClient.get(url)
|
||||||
|
if (response.success) {
|
||||||
|
accountUsageTrendData.value = {
|
||||||
|
data: response.data || [],
|
||||||
|
topAccounts: response.topAccounts || [],
|
||||||
|
totalAccounts: response.totalAccounts || 0,
|
||||||
|
group: response.group || group,
|
||||||
|
groupLabel: response.groupLabel || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载账号使用趋势失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 日期筛选相关方法
|
// 日期筛选相关方法
|
||||||
function setDateFilterPreset(preset) {
|
function setDateFilterPreset(preset) {
|
||||||
dateFilter.value.type = 'preset'
|
dateFilter.value.type = 'preset'
|
||||||
@@ -748,10 +847,16 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadUsageTrend(days, trendGranularity.value),
|
loadUsageTrend(days, trendGranularity.value),
|
||||||
loadModelStats(modelPeriod),
|
loadModelStats(modelPeriod),
|
||||||
loadApiKeysTrend(apiKeysTrendMetric.value)
|
loadApiKeysTrend(apiKeysTrendMetric.value),
|
||||||
|
loadAccountUsageTrend(accountUsageGroup.value)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAccountUsageGroup(group) {
|
||||||
|
accountUsageGroup.value = group
|
||||||
|
return loadAccountUsageTrend(group)
|
||||||
|
}
|
||||||
|
|
||||||
function calculateDaysBetween(start, end) {
|
function calculateDaysBetween(start, end) {
|
||||||
if (!start || !end) return 7
|
if (!start || !end) return 7
|
||||||
const startDate = new Date(start)
|
const startDate = new Date(start)
|
||||||
@@ -774,9 +879,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
trendData,
|
trendData,
|
||||||
dashboardModelStats,
|
dashboardModelStats,
|
||||||
apiKeysTrendData,
|
apiKeysTrendData,
|
||||||
|
accountUsageTrendData,
|
||||||
dateFilter,
|
dateFilter,
|
||||||
trendGranularity,
|
trendGranularity,
|
||||||
apiKeysTrendMetric,
|
apiKeysTrendMetric,
|
||||||
|
accountUsageGroup,
|
||||||
defaultTime,
|
defaultTime,
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
@@ -787,10 +894,12 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
loadUsageTrend,
|
loadUsageTrend,
|
||||||
loadModelStats,
|
loadModelStats,
|
||||||
loadApiKeysTrend,
|
loadApiKeysTrend,
|
||||||
|
loadAccountUsageTrend,
|
||||||
setDateFilterPreset,
|
setDateFilterPreset,
|
||||||
onCustomDateRangeChange,
|
onCustomDateRangeChange,
|
||||||
setTrendGranularity,
|
setTrendGranularity,
|
||||||
refreshChartsData,
|
refreshChartsData,
|
||||||
|
setAccountUsageGroup,
|
||||||
disabledDate
|
disabledDate
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -849,6 +849,15 @@
|
|||||||
<i :class="['fas', account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off']" />
|
<i :class="['fas', account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off']" />
|
||||||
<span class="ml-1">{{ account.schedulable ? '调度' : '停用' }}</span>
|
<span class="ml-1">{{ account.schedulable ? '调度' : '停用' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canViewUsage(account)"
|
||||||
|
class="rounded bg-indigo-100 px-2.5 py-1 text-xs font-medium text-indigo-700 transition-colors hover:bg-indigo-200"
|
||||||
|
:title="'查看使用详情'"
|
||||||
|
@click="openAccountUsageModal(account)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chart-line" />
|
||||||
|
<span class="ml-1">详情</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
|
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
|
||||||
:title="'编辑账户'"
|
:title="'编辑账户'"
|
||||||
@@ -1154,6 +1163,15 @@
|
|||||||
{{ account.schedulable ? '暂停' : '启用' }}
|
{{ account.schedulable ? '暂停' : '启用' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="canViewUsage(account)"
|
||||||
|
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-indigo-50 px-3 py-2 text-xs text-indigo-600 transition-colors hover:bg-indigo-100"
|
||||||
|
@click="openAccountUsageModal(account)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chart-line" />
|
||||||
|
详情
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100"
|
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100"
|
||||||
@click="editAccount(account)"
|
@click="editAccount(account)"
|
||||||
@@ -1298,6 +1316,18 @@
|
|||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
@confirm="handleConfirm"
|
@confirm="handleConfirm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AccountUsageDetailModal
|
||||||
|
v-if="showAccountUsageModal"
|
||||||
|
:account="selectedAccountForUsage || {}"
|
||||||
|
:generated-at="accountUsageGeneratedAt"
|
||||||
|
:history="accountUsageHistory"
|
||||||
|
:loading="accountUsageLoading"
|
||||||
|
:overview="accountUsageOverview"
|
||||||
|
:show="showAccountUsageModal"
|
||||||
|
:summary="accountUsageSummary"
|
||||||
|
@close="closeAccountUsageModal"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1308,6 +1338,7 @@ import { apiClient } from '@/config/api'
|
|||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import AccountForm from '@/components/accounts/AccountForm.vue'
|
import AccountForm from '@/components/accounts/AccountForm.vue'
|
||||||
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
||||||
|
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
|
||||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||||
|
|
||||||
@@ -1340,6 +1371,17 @@ const pageSizeOptions = [10, 20, 50, 100]
|
|||||||
const pageSize = ref(getInitialPageSize())
|
const pageSize = ref(getInitialPageSize())
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
|
||||||
|
// 账号使用详情弹窗状态
|
||||||
|
const showAccountUsageModal = ref(false)
|
||||||
|
const accountUsageLoading = ref(false)
|
||||||
|
const selectedAccountForUsage = ref(null)
|
||||||
|
const accountUsageHistory = ref([])
|
||||||
|
const accountUsageSummary = ref({})
|
||||||
|
const accountUsageOverview = ref({})
|
||||||
|
const accountUsageGeneratedAt = ref('')
|
||||||
|
|
||||||
|
const supportedUsagePlatforms = ['claude', 'claude-console', 'openai', 'openai-responses', 'gemini']
|
||||||
|
|
||||||
// 缓存状态标志
|
// 缓存状态标志
|
||||||
const apiKeysLoaded = ref(false)
|
const apiKeysLoaded = ref(false)
|
||||||
const groupsLoaded = ref(false)
|
const groupsLoaded = ref(false)
|
||||||
@@ -1453,6 +1495,50 @@ const accountMatchesKeyword = (account, normalizedKeyword) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canViewUsage = (account) => !!account && supportedUsagePlatforms.includes(account.platform)
|
||||||
|
|
||||||
|
const openAccountUsageModal = async (account) => {
|
||||||
|
if (!canViewUsage(account)) {
|
||||||
|
showToast('该账户类型暂不支持查看详情', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedAccountForUsage.value = account
|
||||||
|
showAccountUsageModal.value = true
|
||||||
|
accountUsageLoading.value = true
|
||||||
|
accountUsageHistory.value = []
|
||||||
|
accountUsageSummary.value = {}
|
||||||
|
accountUsageOverview.value = {}
|
||||||
|
accountUsageGeneratedAt.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/admin/accounts/${account.id}/usage-history?platform=${account.platform}&days=30`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
const data = response.data || {}
|
||||||
|
accountUsageHistory.value = data.history || []
|
||||||
|
accountUsageSummary.value = data.summary || {}
|
||||||
|
accountUsageOverview.value = data.overview || {}
|
||||||
|
accountUsageGeneratedAt.value = data.generatedAt || ''
|
||||||
|
} else {
|
||||||
|
showToast(response.error || '加载账号使用详情失败', 'error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载账号使用详情失败:', error)
|
||||||
|
showToast('加载账号使用详情失败', 'error')
|
||||||
|
} finally {
|
||||||
|
accountUsageLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAccountUsageModal = () => {
|
||||||
|
showAccountUsageModal.value = false
|
||||||
|
accountUsageLoading.value = false
|
||||||
|
selectedAccountForUsage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// 计算排序后的账户列表
|
// 计算排序后的账户列表
|
||||||
const sortedAccounts = computed(() => {
|
const sortedAccounts = computed(() => {
|
||||||
let sourceAccounts = accounts.value
|
let sourceAccounts = accounts.value
|
||||||
|
|||||||
@@ -408,7 +408,7 @@
|
|||||||
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="w-[23%] min-w-[170px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
class="operations-column sticky right-0 w-[23%] min-w-[200px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
操作
|
操作
|
||||||
</th>
|
</th>
|
||||||
@@ -703,7 +703,10 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-3" style="font-size: 13px">
|
<td
|
||||||
|
class="operations-column operations-cell whitespace-nowrap px-3 py-3"
|
||||||
|
style="font-size: 13px"
|
||||||
|
>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<button
|
<button
|
||||||
class="rounded px-2 py-1 text-xs font-medium text-purple-600 transition-colors hover:bg-purple-50 hover:text-purple-900 dark:hover:bg-purple-900/20"
|
class="rounded px-2 py-1 text-xs font-medium text-purple-600 transition-colors hover:bg-purple-50 hover:text-purple-900 dark:hover:bg-purple-900/20"
|
||||||
@@ -1501,7 +1504,7 @@
|
|||||||
最后使用
|
最后使用
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
class="operations-column sticky right-0 w-[15%] min-w-[160px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
操作
|
操作
|
||||||
</th>
|
</th>
|
||||||
@@ -1657,7 +1660,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
|
<span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-3">
|
<td class="operations-column operations-cell px-3 py-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
v-if="key.canRestore"
|
v-if="key.canRestore"
|
||||||
@@ -3765,19 +3768,21 @@ onMounted(async () => {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-container {
|
.table-container {
|
||||||
overflow-x: hidden;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 防止表格内容溢出 */
|
/* 防止表格内容溢出,保证横向滚动 */
|
||||||
.table-container table {
|
.table-container table {
|
||||||
min-width: 100%;
|
min-width: 1200px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3811,6 +3816,27 @@ onMounted(async () => {
|
|||||||
background-color: rgba(255, 255, 255, 0.02);
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 固定操作列在右侧,兼容浅色和深色模式 */
|
||||||
|
.operations-column {
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
background: inherit;
|
||||||
|
background-color: inherit;
|
||||||
|
z-index: 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container thead .operations-column {
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container tbody .operations-column {
|
||||||
|
box-shadow: -8px 0 12px -8px rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .table-container tbody .operations-column {
|
||||||
|
box-shadow: -8px 0 12px -8px rgba(30, 41, 59, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
|||||||
@@ -621,6 +621,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 账号使用趋势图 -->
|
||||||
|
<div class="mb-4 sm:mb-6 md:mb-8">
|
||||||
|
<div class="card p-4 sm:p-6">
|
||||||
|
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 sm:text-lg">
|
||||||
|
账号使用趋势
|
||||||
|
</h3>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 sm:text-sm">
|
||||||
|
当前分组:{{ accountUsageTrendData.groupLabel || '未选择' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<div class="flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-700">
|
||||||
|
<button
|
||||||
|
v-for="option in accountGroupOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:class="[
|
||||||
|
'rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-3 sm:text-sm',
|
||||||
|
accountUsageGroup === option.value
|
||||||
|
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
|
||||||
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100'
|
||||||
|
]"
|
||||||
|
@click="handleAccountUsageGroupChange(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mb-4 flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-400 sm:text-sm"
|
||||||
|
>
|
||||||
|
<span>共 {{ accountUsageTrendData.totalAccounts || 0 }} 个账号</span>
|
||||||
|
<span
|
||||||
|
v-if="accountUsageTrendData.topAccounts && accountUsageTrendData.topAccounts.length"
|
||||||
|
>
|
||||||
|
显示成本前 {{ accountUsageTrendData.topAccounts.length }} 个账号
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!accountUsageTrendData.data || accountUsageTrendData.data.length === 0"
|
||||||
|
class="py-12 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
暂无账号使用数据
|
||||||
|
</div>
|
||||||
|
<div v-else class="sm:h-[350px]" style="height: 300px">
|
||||||
|
<canvas ref="accountUsageTrendChart" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -641,6 +693,8 @@ const {
|
|||||||
dashboardModelStats,
|
dashboardModelStats,
|
||||||
trendData,
|
trendData,
|
||||||
apiKeysTrendData,
|
apiKeysTrendData,
|
||||||
|
accountUsageTrendData,
|
||||||
|
accountUsageGroup,
|
||||||
formattedUptime,
|
formattedUptime,
|
||||||
dateFilter,
|
dateFilter,
|
||||||
trendGranularity,
|
trendGranularity,
|
||||||
@@ -655,6 +709,7 @@ const {
|
|||||||
onCustomDateRangeChange,
|
onCustomDateRangeChange,
|
||||||
setTrendGranularity,
|
setTrendGranularity,
|
||||||
refreshChartsData,
|
refreshChartsData,
|
||||||
|
setAccountUsageGroup,
|
||||||
disabledDate
|
disabledDate
|
||||||
} = dashboardStore
|
} = dashboardStore
|
||||||
|
|
||||||
@@ -662,9 +717,19 @@ const {
|
|||||||
const modelUsageChart = ref(null)
|
const modelUsageChart = ref(null)
|
||||||
const usageTrendChart = ref(null)
|
const usageTrendChart = ref(null)
|
||||||
const apiKeysUsageTrendChart = ref(null)
|
const apiKeysUsageTrendChart = ref(null)
|
||||||
|
const accountUsageTrendChart = ref(null)
|
||||||
let modelUsageChartInstance = null
|
let modelUsageChartInstance = null
|
||||||
let usageTrendChartInstance = null
|
let usageTrendChartInstance = null
|
||||||
let apiKeysUsageTrendChartInstance = null
|
let apiKeysUsageTrendChartInstance = null
|
||||||
|
let accountUsageTrendChartInstance = null
|
||||||
|
|
||||||
|
const accountGroupOptions = [
|
||||||
|
{ value: 'claude', label: 'Claude' },
|
||||||
|
{ value: 'openai', label: 'OpenAI' },
|
||||||
|
{ value: 'gemini', label: 'Gemini' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const accountTrendUpdating = ref(false)
|
||||||
|
|
||||||
// 自动刷新相关
|
// 自动刷新相关
|
||||||
const autoRefreshEnabled = ref(false)
|
const autoRefreshEnabled = ref(false)
|
||||||
@@ -697,6 +762,19 @@ function formatNumber(num) {
|
|||||||
return num.toString()
|
return num.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCostValue(cost) {
|
||||||
|
if (!Number.isFinite(cost)) {
|
||||||
|
return '$0.000000'
|
||||||
|
}
|
||||||
|
if (cost >= 1) {
|
||||||
|
return `$${cost.toFixed(2)}`
|
||||||
|
}
|
||||||
|
if (cost >= 0.01) {
|
||||||
|
return `$${cost.toFixed(3)}`
|
||||||
|
}
|
||||||
|
return `$${cost.toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
// 计算百分比
|
// 计算百分比
|
||||||
function calculatePercentage(value, stats) {
|
function calculatePercentage(value, stats) {
|
||||||
if (!stats || stats.length === 0) return 0
|
if (!stats || stats.length === 0) return 0
|
||||||
@@ -1201,6 +1279,186 @@ async function updateApiKeysUsageTrendChart() {
|
|||||||
createApiKeysUsageTrendChart()
|
createApiKeysUsageTrendChart()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createAccountUsageTrendChart() {
|
||||||
|
if (!accountUsageTrendChart.value) return
|
||||||
|
|
||||||
|
if (accountUsageTrendChartInstance) {
|
||||||
|
accountUsageTrendChartInstance.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
const trend = accountUsageTrendData.value?.data || []
|
||||||
|
const topAccounts = accountUsageTrendData.value?.topAccounts || []
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#2563EB',
|
||||||
|
'#059669',
|
||||||
|
'#D97706',
|
||||||
|
'#DC2626',
|
||||||
|
'#7C3AED',
|
||||||
|
'#F472B6',
|
||||||
|
'#0EA5E9',
|
||||||
|
'#F97316',
|
||||||
|
'#6366F1',
|
||||||
|
'#22C55E'
|
||||||
|
]
|
||||||
|
|
||||||
|
const datasets = topAccounts.map((accountId, index) => {
|
||||||
|
const dataPoints = trend.map((item) => {
|
||||||
|
if (!item.accounts || !item.accounts[accountId]) return 0
|
||||||
|
return item.accounts[accountId].cost || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const accountName =
|
||||||
|
trend.find((item) => item.accounts && item.accounts[accountId])?.accounts[accountId]?.name ||
|
||||||
|
`账号 ${String(accountId).slice(0, 6)}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: accountName,
|
||||||
|
data: dataPoints,
|
||||||
|
borderColor: colors[index % colors.length],
|
||||||
|
backgroundColor: colors[index % colors.length] + '20',
|
||||||
|
tension: 0.4,
|
||||||
|
fill: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const labelField = trend[0]?.date ? 'date' : 'hour'
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels: trend.map((item) => {
|
||||||
|
if (item.label) {
|
||||||
|
return item.label
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labelField === 'hour') {
|
||||||
|
const date = new Date(item.hour)
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hour = String(date.getHours()).padStart(2, '0')
|
||||||
|
return `${month}/${day} ${hour}:00`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.date && item.date.includes('-')) {
|
||||||
|
const parts = item.date.split('-')
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
return `${parts[1]}/${parts[2]}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.date
|
||||||
|
}),
|
||||||
|
datasets
|
||||||
|
}
|
||||||
|
|
||||||
|
const topAccountIds = topAccounts
|
||||||
|
|
||||||
|
accountUsageTrendChartInstance = new Chart(accountUsageTrendChart.value, {
|
||||||
|
type: 'line',
|
||||||
|
data: chartData,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
padding: 20,
|
||||||
|
usePointStyle: true,
|
||||||
|
font: {
|
||||||
|
size: 12
|
||||||
|
},
|
||||||
|
color: chartColors.value.legend
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
itemSort: (a, b) => b.parsed.y - a.parsed.y,
|
||||||
|
callbacks: {
|
||||||
|
label: function (context) {
|
||||||
|
const label = context.dataset.label || ''
|
||||||
|
const value = context.parsed.y || 0
|
||||||
|
const dataIndex = context.dataIndex
|
||||||
|
const datasetIndex = context.datasetIndex
|
||||||
|
const accountId = topAccountIds[datasetIndex]
|
||||||
|
const dataPoint = accountUsageTrendData.value.data[dataIndex]
|
||||||
|
const accountDetail = dataPoint?.accounts?.[accountId]
|
||||||
|
|
||||||
|
const allValues = context.chart.data.datasets
|
||||||
|
.map((dataset, idx) => ({
|
||||||
|
value: dataset.data[dataIndex] || 0,
|
||||||
|
index: idx
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
|
||||||
|
const rank = allValues.findIndex((item) => item.index === datasetIndex) + 1
|
||||||
|
let rankIcon = ''
|
||||||
|
if (rank === 1) rankIcon = '🥇 '
|
||||||
|
else if (rank === 2) rankIcon = '🥈 '
|
||||||
|
else if (rank === 3) rankIcon = '🥉 '
|
||||||
|
|
||||||
|
const formattedCost = accountDetail?.formattedCost || formatCostValue(value)
|
||||||
|
const requests = accountDetail?.requests || 0
|
||||||
|
|
||||||
|
return `${rankIcon}${label}: ${formattedCost} / ${requests.toLocaleString()} 次`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'category',
|
||||||
|
display: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: trendGranularity.value === 'hour' ? '时间' : '日期',
|
||||||
|
color: chartColors.value.text
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: chartColors.value.text
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: chartColors.value.grid
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '消耗金额 (USD)',
|
||||||
|
color: chartColors.value.text
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: (value) => formatCostValue(Number(value)),
|
||||||
|
color: chartColors.value.text
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: chartColors.value.grid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAccountUsageGroupChange(group) {
|
||||||
|
if (accountUsageGroup.value === group || accountTrendUpdating.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountTrendUpdating.value = true
|
||||||
|
try {
|
||||||
|
await setAccountUsageGroup(group)
|
||||||
|
await nextTick()
|
||||||
|
createAccountUsageTrendChart()
|
||||||
|
} finally {
|
||||||
|
accountTrendUpdating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 监听数据变化更新图表
|
// 监听数据变化更新图表
|
||||||
watch(dashboardModelStats, () => {
|
watch(dashboardModelStats, () => {
|
||||||
nextTick(() => createModelUsageChart())
|
nextTick(() => createModelUsageChart())
|
||||||
@@ -1214,6 +1472,10 @@ watch(apiKeysTrendData, () => {
|
|||||||
nextTick(() => createApiKeysUsageTrendChart())
|
nextTick(() => createApiKeysUsageTrendChart())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(accountUsageTrendData, () => {
|
||||||
|
nextTick(() => createAccountUsageTrendChart())
|
||||||
|
})
|
||||||
|
|
||||||
// 刷新所有数据
|
// 刷新所有数据
|
||||||
async function refreshAllData() {
|
async function refreshAllData() {
|
||||||
if (isRefreshing.value) return
|
if (isRefreshing.value) return
|
||||||
@@ -1297,6 +1559,7 @@ watch(isDarkMode, () => {
|
|||||||
createModelUsageChart()
|
createModelUsageChart()
|
||||||
createUsageTrendChart()
|
createUsageTrendChart()
|
||||||
createApiKeysUsageTrendChart()
|
createApiKeysUsageTrendChart()
|
||||||
|
createAccountUsageTrendChart()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1310,6 +1573,7 @@ onMounted(async () => {
|
|||||||
createModelUsageChart()
|
createModelUsageChart()
|
||||||
createUsageTrendChart()
|
createUsageTrendChart()
|
||||||
createApiKeysUsageTrendChart()
|
createApiKeysUsageTrendChart()
|
||||||
|
createAccountUsageTrendChart()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 清理
|
// 清理
|
||||||
@@ -1325,6 +1589,9 @@ onUnmounted(() => {
|
|||||||
if (apiKeysUsageTrendChartInstance) {
|
if (apiKeysUsageTrendChartInstance) {
|
||||||
apiKeysUsageTrendChartInstance.destroy()
|
apiKeysUsageTrendChartInstance.destroy()
|
||||||
}
|
}
|
||||||
|
if (accountUsageTrendChartInstance) {
|
||||||
|
accountUsageTrendChartInstance.destroy()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user