diff --git a/src/routes/admin.js b/src/routes/admin.js index 88fbff97..12c19769 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -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分组的使用趋势 router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { try { diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 0f3f3628..1615d7e7 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -497,6 +497,9 @@ class ClaudeAccountService { schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据 // 添加自动停止调度设置 autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false + // 添加5小时自动停止状态 + fiveHourAutoStopped: account.fiveHourAutoStopped === 'true', + fiveHourStoppedAt: account.fiveHourStoppedAt || null, // 添加统一User-Agent设置 useUnifiedUserAgent: account.useUnifiedUserAgent === 'true', // 默认为false // 添加统一客户端标识设置 @@ -2333,7 +2336,7 @@ class ClaudeAccountService { for (const account of accounts) { // 只检查因5小时限制被自动停止的账号 // 重要:不恢复手动停止的账号(没有fiveHourAutoStopped标记的) - if (account.fiveHourAutoStopped === 'true' && account.schedulable === 'false') { + if (account.fiveHourAutoStopped === true && account.schedulable === false) { result.checked++ // 使用分布式锁防止并发修改 diff --git a/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue b/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue new file mode 100644 index 00000000..58c2e75a --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue @@ -0,0 +1,630 @@ + + + + + + + + + + + + + + + {{ account?.name || account?.email || '账号使用详情' }} + + + {{ platformLabel }} + + + {{ accountTypeLabel }} + + + + 近 {{ summary?.days || 30 }} 天内的费用与请求趋势 + + + + + + + + + + + + + + + + + + + + + {{ metric.label }} + + + {{ metric.value }} + + + {{ metric.subtitle }} + + + + + + + + + + + + + + + 今日概览 + + + + 费用 + {{ + summary?.today?.costFormatted || '$0.000000' + }} + + + 请求 + {{ + formatNumber(summary?.today?.requests || 0) + }} + + + Tokens + {{ formatNumber(summary?.today?.tokens || 0) }} + + + + + + + + 最高费用日 + + + + 日期 + {{ + formatDate(summary?.highestCostDay?.date) + }} + + + 费用 + {{ + summary?.highestCostDay?.formattedCost || '$0.000000' + }} + + + 请求 + {{ + formatNumber(findHistoryValue(summary?.highestCostDay?.date, 'requests')) + }} + + + + + + + + 最高请求日 + + + + 日期 + {{ + formatDate(summary?.highestRequestDay?.date) + }} + + + 请求 + {{ + formatNumber(summary?.highestRequestDay?.requests || 0) + }} + + + 费用 + {{ + formatCost(findHistoryValue(summary?.highestRequestDay?.date, 'cost')) + }} + + + + + + + + + + 累计 Token + + + + 30天总计 + {{ + formatNumber(totalTokens) + }} + + + 日均 Token + {{ + formatNumber(Math.round(summary?.avgDailyTokens || 0)) + }} + + + 输入 / 输出 + {{ formatNumber(overviewInputTokens) }} / + {{ formatNumber(overviewOutputTokens) }} + + + + + + 平均速率 + + + + RPM + {{ + overview?.averages?.rpm ?? 0 + }} + + + TPM + {{ + overview?.averages?.tpm ?? 0 + }} + + + 日均请求 / Token + {{ + formatNumber( + Math.round((overview?.averages?.dailyRequests || 0) * 100) / 100 + ) + }} + / + {{ + formatNumber(Math.round((overview?.averages?.dailyTokens || 0) * 100) / 100) + }} + + + + + + 最近统计 + + + + 今日请求 + {{ + formatNumber(overview?.daily?.requests || 0) + }} + + + 今日 Token + {{ + formatNumber(overview?.daily?.allTokens || 0) + }} + + + 今日费用 + {{ formatCost(overview?.daily?.cost || 0) }} + + + + + + + + + + 30天费用与请求趋势 + + + 最新更新时间:{{ formatDateTime(generatedAtDisplay) }} + + + + + + + + + + + + + + + + diff --git a/web/admin-spa/src/stores/dashboard.js b/web/admin-spa/src/stores/dashboard.js index 20cc40b6..4e4599ba 100644 --- a/web/admin-spa/src/stores/dashboard.js +++ b/web/admin-spa/src/stores/dashboard.js @@ -59,6 +59,13 @@ export const useDashboardStore = defineStore('dashboard', () => { topApiKeys: [], totalApiKeys: 0 }) + const accountUsageTrendData = ref({ + data: [], + topAccounts: [], + totalAccounts: 0, + group: 'claude', + groupLabel: 'Claude账户' + }) // 日期筛选 const dateFilter = ref({ @@ -77,6 +84,7 @@ export const useDashboardStore = defineStore('dashboard', () => { // 趋势图粒度 const trendGranularity = ref('day') // 'day' 或 'hour' 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)]) @@ -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) { dateFilter.value.type = 'preset' @@ -748,10 +847,16 @@ export const useDashboardStore = defineStore('dashboard', () => { await Promise.all([ loadUsageTrend(days, trendGranularity.value), loadModelStats(modelPeriod), - loadApiKeysTrend(apiKeysTrendMetric.value) + loadApiKeysTrend(apiKeysTrendMetric.value), + loadAccountUsageTrend(accountUsageGroup.value) ]) } + function setAccountUsageGroup(group) { + accountUsageGroup.value = group + return loadAccountUsageTrend(group) + } + function calculateDaysBetween(start, end) { if (!start || !end) return 7 const startDate = new Date(start) @@ -774,9 +879,11 @@ export const useDashboardStore = defineStore('dashboard', () => { trendData, dashboardModelStats, apiKeysTrendData, + accountUsageTrendData, dateFilter, trendGranularity, apiKeysTrendMetric, + accountUsageGroup, defaultTime, // 计算属性 @@ -787,10 +894,12 @@ export const useDashboardStore = defineStore('dashboard', () => { loadUsageTrend, loadModelStats, loadApiKeysTrend, + loadAccountUsageTrend, setDateFilterPreset, onCustomDateRangeChange, setTrendGranularity, refreshChartsData, + setAccountUsageGroup, disabledDate } }) diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 5ee3bafe..b2a0a081 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -849,6 +849,15 @@ {{ account.schedulable ? '调度' : '停用' }} + + + 详情 + + + + 详情 + + + + @@ -1308,6 +1338,7 @@ import { apiClient } from '@/config/api' import { useConfirm } from '@/composables/useConfirm' import AccountForm from '@/components/accounts/AccountForm.vue' import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue' +import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue' import ConfirmModal from '@/components/common/ConfirmModal.vue' import CustomDropdown from '@/components/common/CustomDropdown.vue' @@ -1340,6 +1371,17 @@ const pageSizeOptions = [10, 20, 50, 100] const pageSize = ref(getInitialPageSize()) 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 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(() => { let sourceAccounts = accounts.value diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index bdcc8bec..152595dd 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -408,7 +408,7 @@ 操作 @@ -703,7 +703,10 @@ - + 操作 @@ -1657,7 +1660,7 @@ 从未使用 - + { border-radius: 12px; border: 1px solid rgba(0, 0, 0, 0.05); width: 100%; + position: relative; } .table-container { - overflow-x: hidden; + overflow-x: auto; overflow-y: hidden; margin: 0; padding: 0; max-width: 100%; + position: relative; } -/* 防止表格内容溢出 */ +/* 防止表格内容溢出,保证横向滚动 */ .table-container table { - min-width: 100%; + min-width: 1200px; border-collapse: collapse; } @@ -3811,6 +3816,27 @@ onMounted(async () => { 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 { width: 24px; height: 24px; diff --git a/web/admin-spa/src/views/DashboardView.vue b/web/admin-spa/src/views/DashboardView.vue index 4ac9c587..7791d77c 100644 --- a/web/admin-spa/src/views/DashboardView.vue +++ b/web/admin-spa/src/views/DashboardView.vue @@ -621,6 +621,58 @@ + + + + + + + + 账号使用趋势 + + + 当前分组:{{ accountUsageTrendData.groupLabel || '未选择' }} + + + + + + {{ option.label }} + + + + + + 共 {{ accountUsageTrendData.totalAccounts || 0 }} 个账号 + + 显示成本前 {{ accountUsageTrendData.topAccounts.length }} 个账号 + + + + 暂无账号使用数据 + + + + + + @@ -641,6 +693,8 @@ const { dashboardModelStats, trendData, apiKeysTrendData, + accountUsageTrendData, + accountUsageGroup, formattedUptime, dateFilter, trendGranularity, @@ -655,6 +709,7 @@ const { onCustomDateRangeChange, setTrendGranularity, refreshChartsData, + setAccountUsageGroup, disabledDate } = dashboardStore @@ -662,9 +717,19 @@ const { const modelUsageChart = ref(null) const usageTrendChart = ref(null) const apiKeysUsageTrendChart = ref(null) +const accountUsageTrendChart = ref(null) let modelUsageChartInstance = null let usageTrendChartInstance = 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) @@ -697,6 +762,19 @@ function formatNumber(num) { 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) { if (!stats || stats.length === 0) return 0 @@ -1201,6 +1279,186 @@ async function updateApiKeysUsageTrendChart() { 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, () => { nextTick(() => createModelUsageChart()) @@ -1214,6 +1472,10 @@ watch(apiKeysTrendData, () => { nextTick(() => createApiKeysUsageTrendChart()) }) +watch(accountUsageTrendData, () => { + nextTick(() => createAccountUsageTrendChart()) +}) + // 刷新所有数据 async function refreshAllData() { if (isRefreshing.value) return @@ -1297,6 +1559,7 @@ watch(isDarkMode, () => { createModelUsageChart() createUsageTrendChart() createApiKeysUsageTrendChart() + createAccountUsageTrendChart() }) }) @@ -1310,6 +1573,7 @@ onMounted(async () => { createModelUsageChart() createUsageTrendChart() createApiKeysUsageTrendChart() + createAccountUsageTrendChart() }) // 清理 @@ -1325,6 +1589,9 @@ onUnmounted(() => { if (apiKeysUsageTrendChartInstance) { apiKeysUsageTrendChartInstance.destroy() } + if (accountUsageTrendChartInstance) { + accountUsageTrendChartInstance.destroy() + } })
+ 近 {{ summary?.days || 30 }} 天内的费用与请求趋势 +
+ {{ metric.label }} +
+ {{ metric.value }} +
+ {{ metric.subtitle }} +