diff --git a/package-lock.json b/package-lock.json index 551062b7..c6dccd11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "jest": "^29.7.0", "nodemon": "^3.0.1", "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.7.2", "supertest": "^6.3.3" }, "engines": { @@ -7605,6 +7606,85 @@ "node": ">=6.0.0" } }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz", diff --git a/package.json b/package.json index 72ea4720..2b7ffa25 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "jest": "^29.7.0", "nodemon": "^3.0.1", "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.7.2", "supertest": "^6.3.3" }, "engines": { diff --git a/src/routes/admin/usageStats.js b/src/routes/admin/usageStats.js index 18d6b436..bf35f8a0 100644 --- a/src/routes/admin/usageStats.js +++ b/src/routes/admin/usageStats.js @@ -16,6 +16,65 @@ const pricingService = require('../../services/pricingService') const router = express.Router() +const accountTypeNames = { + claude: 'Claude官方', + 'claude-console': 'Claude Console', + ccr: 'Claude Console Relay', + openai: 'OpenAI', + 'openai-responses': 'OpenAI Responses', + gemini: 'Gemini', + 'gemini-api': 'Gemini API', + droid: 'Droid', + unknown: '未知渠道' +} + +const resolveAccountByPlatform = async (accountId, platform) => { + const serviceMap = { + claude: claudeAccountService, + 'claude-console': claudeConsoleAccountService, + gemini: geminiAccountService, + 'gemini-api': geminiApiAccountService, + openai: openaiAccountService, + 'openai-responses': openaiResponsesAccountService, + droid: droidAccountService, + ccr: ccrAccountService + } + + if (platform && serviceMap[platform]) { + try { + const account = await serviceMap[platform].getAccount(accountId) + if (account) { + return { ...account, platform } + } + } catch (error) { + logger.debug(`⚠️ Failed to get account ${accountId} from ${platform}: ${error.message}`) + } + } + + for (const [platformName, service] of Object.entries(serviceMap)) { + try { + const account = await service.getAccount(accountId) + if (account) { + return { ...account, platform: platformName } + } + } catch (error) { + logger.debug(`⚠️ Failed to get account ${accountId} from ${platformName}: ${error.message}`) + } + } + + return null +} + +const getApiKeyName = async (keyId) => { + try { + const keyData = await redis.getApiKey(keyId) + return keyData?.name || keyData?.label || keyId + } catch (error) { + logger.debug(`⚠️ Failed to get API key name for ${keyId}: ${error.message}`) + return keyId + } +} + // 📊 账户使用统计 // 获取所有账户的使用统计 @@ -1861,18 +1920,6 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) const rawRecords = await redis.getUsageRecords(keyId, 5000) - const accountTypeNames = { - claude: 'Claude官方', - 'claude-console': 'Claude Console', - ccr: 'Claude Console Relay', - openai: 'OpenAI', - 'openai-responses': 'OpenAI Responses', - gemini: 'Gemini', - 'gemini-api': 'Gemini API', - droid: 'Droid', - unknown: '未知渠道' - } - const accountServices = [ { type: 'claude', getter: (id) => claudeAccountService.getAccount(id) }, { type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) }, @@ -1895,10 +1942,15 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) return accountCache.get(cacheKey) } - const servicesToTry = type + let servicesToTry = type ? accountServices.filter((svc) => svc.type === type) : accountServices + // 若渠道改名或传入未知类型,回退尝试全量服务,避免漏解析历史账号 + if (!servicesToTry.length) { + servicesToTry = accountServices + } + for (const service of servicesToTry) { try { const account = await service.getter(id) @@ -2008,12 +2060,14 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) } if (record.accountId) { - const key = `${record.accountId}:${record.accountType || 'unknown'}` - if (!accountOptionMap.has(key)) { - accountOptionMap.set(key, { + const normalizedType = record.accountType || 'unknown' + if (!accountOptionMap.has(record.accountId)) { + accountOptionMap.set(record.accountId, { id: record.accountId, - accountType: record.accountType || 'unknown' + accountTypes: new Set([normalizedType]) }) + } else { + accountOptionMap.get(record.accountId).accountTypes.add(normalizedType) } } @@ -2087,13 +2141,33 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) const accountOptions = [] for (const option of accountOptionMap.values()) { - const info = await resolveAccountInfo(option.id, option.accountType) - const resolvedType = info?.type || option.accountType || 'unknown' + const types = Array.from(option.accountTypes || []) + + // 优先按历史出现的 accountType 解析,若失败则回退全量解析 + let resolvedInfo = null + for (const type of types) { + resolvedInfo = await resolveAccountInfo(option.id, type) + if (resolvedInfo && resolvedInfo.name) { + break + } + } + if (!resolvedInfo) { + resolvedInfo = await resolveAccountInfo(option.id) + } + + const chosenType = resolvedInfo?.type || types[0] || 'unknown' + const chosenTypeName = accountTypeNames[chosenType] || '未知渠道' + + if (!resolvedInfo) { + logger.warn(`⚠️ 保留无法解析的账户筛选项: ${option.id}, types=${types.join(',') || 'none'}`) + } + accountOptions.push({ id: option.id, - name: info?.name || option.id, - accountType: resolvedType, - accountTypeName: accountTypeNames[resolvedType] || '未知渠道' + name: resolvedInfo?.name || option.id, + accountType: chosenType, + accountTypeName: chosenTypeName, + rawTypes: types }) } @@ -2146,4 +2220,287 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) } }) +// 获取账户的请求记录时间线 +router.get('/accounts/:accountId/usage-records', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { + platform, + page = 1, + pageSize = 50, + startDate, + endDate, + model, + apiKeyId, + sortOrder = 'desc' + } = req.query + + const pageNumber = Math.max(parseInt(page, 10) || 1, 1) + const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200) + const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc' + + const startTime = startDate ? new Date(startDate) : null + const endTime = endDate ? new Date(endDate) : null + + if ( + (startDate && Number.isNaN(startTime?.getTime())) || + (endDate && Number.isNaN(endTime?.getTime())) + ) { + return res.status(400).json({ success: false, error: 'Invalid date range' }) + } + + if (startTime && endTime && startTime > endTime) { + return res + .status(400) + .json({ success: false, error: 'Start date must be before or equal to end date' }) + } + + const accountInfo = await resolveAccountByPlatform(accountId, platform) + if (!accountInfo) { + return res.status(404).json({ success: false, error: 'Account not found' }) + } + + const allApiKeys = await apiKeyService.getAllApiKeys(true) + const apiKeyNameCache = new Map( + allApiKeys.map((key) => [key.id, key.name || key.label || key.id]) + ) + + let keysToUse = apiKeyId ? allApiKeys.filter((key) => key.id === apiKeyId) : allApiKeys + if (apiKeyId && keysToUse.length === 0) { + keysToUse = [{ id: apiKeyId }] + } + + const toUsageObject = (record) => ({ + input_tokens: record.inputTokens || 0, + output_tokens: record.outputTokens || 0, + cache_creation_input_tokens: record.cacheCreateTokens || 0, + cache_read_input_tokens: record.cacheReadTokens || 0, + cache_creation: record.cacheCreation || record.cache_creation || null + }) + + const withinRange = (record) => { + if (!record.timestamp) { + return false + } + const ts = new Date(record.timestamp) + if (Number.isNaN(ts.getTime())) { + return false + } + if (startTime && ts < startTime) { + return false + } + if (endTime && ts > endTime) { + return false + } + return true + } + + const filteredRecords = [] + const modelSet = new Set() + const apiKeyOptionMap = new Map() + let earliestTimestamp = null + let latestTimestamp = null + + const batchSize = 10 + for (let i = 0; i < keysToUse.length; i += batchSize) { + const batch = keysToUse.slice(i, i + batchSize) + const batchResults = await Promise.all( + batch.map(async (key) => { + try { + const records = await redis.getUsageRecords(key.id, 5000) + return { keyId: key.id, records: records || [] } + } catch (error) { + logger.debug(`⚠️ Failed to get usage records for key ${key.id}: ${error.message}`) + return { keyId: key.id, records: [] } + } + }) + ) + + for (const { keyId, records } of batchResults) { + const apiKeyName = apiKeyNameCache.get(keyId) || (await getApiKeyName(keyId)) + for (const record of records) { + if (record.accountId !== accountId) { + continue + } + if (!withinRange(record)) { + continue + } + if (model && record.model !== model) { + continue + } + + const accountType = record.accountType || accountInfo.platform || 'unknown' + const normalizedModel = record.model || 'unknown' + + modelSet.add(normalizedModel) + apiKeyOptionMap.set(keyId, { id: keyId, name: apiKeyName }) + + if (record.timestamp) { + const ts = new Date(record.timestamp) + if (!Number.isNaN(ts.getTime())) { + if (!earliestTimestamp || ts < earliestTimestamp) { + earliestTimestamp = ts + } + if (!latestTimestamp || ts > latestTimestamp) { + latestTimestamp = ts + } + } + } + + filteredRecords.push({ + ...record, + model: normalizedModel, + accountType, + apiKeyId: keyId, + apiKeyName + }) + } + } + } + + filteredRecords.sort((a, b) => { + const aTime = new Date(a.timestamp).getTime() + const bTime = new Date(b.timestamp).getTime() + if (Number.isNaN(aTime) || Number.isNaN(bTime)) { + return 0 + } + return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime + }) + + const summary = { + totalRequests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0 + } + + for (const record of filteredRecords) { + const usage = toUsageObject(record) + const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') + const computedCost = + typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const totalTokens = + record.totalTokens || + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + + summary.totalRequests += 1 + summary.inputTokens += usage.input_tokens + summary.outputTokens += usage.output_tokens + summary.cacheCreateTokens += usage.cache_creation_input_tokens + summary.cacheReadTokens += usage.cache_read_input_tokens + summary.totalTokens += totalTokens + summary.totalCost += computedCost + } + + const totalRecords = filteredRecords.length + const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0 + const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1 + const startIndex = (safePage - 1) * pageSizeNumber + const pageRecords = + totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber) + + const enrichedRecords = [] + for (const record of pageRecords) { + const usage = toUsageObject(record) + const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') + const computedCost = + typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const totalTokens = + record.totalTokens || + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + + enrichedRecords.push({ + timestamp: record.timestamp, + model: record.model || 'unknown', + apiKeyId: record.apiKeyId, + apiKeyName: record.apiKeyName, + accountId, + accountName: accountInfo.name || accountInfo.email || accountId, + accountType: record.accountType, + accountTypeName: accountTypeNames[record.accountType] || '未知渠道', + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + ephemeral5mTokens: record.ephemeral5mTokens || 0, + ephemeral1hTokens: record.ephemeral1hTokens || 0, + totalTokens, + isLongContextRequest: record.isLongContext || record.isLongContextRequest || false, + cost: Number(computedCost.toFixed(6)), + costFormatted: + record.costFormatted || + costData?.formatted?.total || + CostCalculator.formatCost(computedCost), + costBreakdown: record.costBreakdown || { + input: costData?.costs?.input || 0, + output: costData?.costs?.output || 0, + cacheCreate: costData?.costs?.cacheWrite || 0, + cacheRead: costData?.costs?.cacheRead || 0, + total: costData?.costs?.total || computedCost + }, + responseTime: record.responseTime || null + }) + } + + return res.json({ + success: true, + data: { + records: enrichedRecords, + pagination: { + currentPage: safePage, + pageSize: pageSizeNumber, + totalRecords, + totalPages, + hasNextPage: totalPages > 0 && safePage < totalPages, + hasPreviousPage: totalPages > 0 && safePage > 1 + }, + filters: { + startDate: startTime ? startTime.toISOString() : null, + endDate: endTime ? endTime.toISOString() : null, + model: model || null, + apiKeyId: apiKeyId || null, + platform: accountInfo.platform, + sortOrder: normalizedSortOrder + }, + accountInfo: { + id: accountId, + name: accountInfo.name || accountInfo.email || accountId, + platform: accountInfo.platform || platform || 'unknown', + status: accountInfo.status ?? accountInfo.isActive ?? null + }, + summary: { + ...summary, + totalCost: Number(summary.totalCost.toFixed(6)), + avgCost: + summary.totalRequests > 0 + ? Number((summary.totalCost / summary.totalRequests).toFixed(6)) + : 0 + }, + availableFilters: { + models: Array.from(modelSet), + apiKeys: Array.from(apiKeyOptionMap.values()), + dateRange: { + earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null, + latest: latestTimestamp ? latestTimestamp.toISOString() : null + } + } + } + }) + } catch (error) { + logger.error('❌ Failed to get account usage records:', error) + return res + .status(500) + .json({ error: 'Failed to get account usage records', message: error.message }) + } +}) + module.exports = router diff --git a/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue b/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue index f9b6083c..09da703b 100644 --- a/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue +++ b/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue @@ -44,12 +44,20 @@

- +
+ + +
@@ -325,6 +333,7 @@ diff --git a/web/admin-spa/src/router/index.js b/web/admin-spa/src/router/index.js index 3375c906..feb67aa1 100644 --- a/web/admin-spa/src/router/index.js +++ b/web/admin-spa/src/router/index.js @@ -13,6 +13,7 @@ const DashboardView = () => import('@/views/DashboardView.vue') const ApiKeysView = () => import('@/views/ApiKeysView.vue') const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue') const AccountsView = () => import('@/views/AccountsView.vue') +const AccountUsageRecordsView = () => import('@/views/AccountUsageRecordsView.vue') const TutorialView = () => import('@/views/TutorialView.vue') const SettingsView = () => import('@/views/SettingsView.vue') const ApiStatsView = () => import('@/views/ApiStatsView.vue') @@ -110,6 +111,18 @@ const routes = [ } ] }, + { + path: '/accounts/:accountId/usage-records', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'AccountUsageRecords', + component: AccountUsageRecordsView + } + ] + }, { path: '/tutorial', component: MainLayout, diff --git a/web/admin-spa/src/views/AccountUsageRecordsView.vue b/web/admin-spa/src/views/AccountUsageRecordsView.vue new file mode 100644 index 00000000..c813e143 --- /dev/null +++ b/web/admin-spa/src/views/AccountUsageRecordsView.vue @@ -0,0 +1,574 @@ + + + diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index c2a31013..88aa858a 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -1668,7 +1668,6 @@ 详情 -