feat: 在仪表盘添加使用记录展示功能

- 新增后端API端点 /admin/dashboard/usage-records
  - 支持分页查询所有API Key的使用记录
  - 自动关联API Key名称和账户名称
  - 按时间倒序排列(最新的在前)

- 新增仪表盘使用记录表格
  - 显示时间、API Key、账户、模型、输入/输出/缓存创建/缓存读取tokens、成本
  - 智能时间格式化(今天显示时分秒,昨天显示时间)
  - 支持加载更多记录,分页展示
  - 响应式设计,支持暗黑模式

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
IanShaw027
2025-12-03 19:41:37 -08:00
committed by IanShaw027
parent 69a1006f4c
commit 81971436e6
2 changed files with 346 additions and 1 deletions

View File

@@ -704,4 +704,106 @@ router.post('/cleanup', authenticateAdmin, async (req, res) => {
}
})
// 📊 获取最近的使用记录
router.get('/usage-records', authenticateAdmin, async (req, res) => {
try {
const { limit = 100, offset = 0 } = req.query
const limitNum = Math.min(parseInt(limit) || 100, 500) // 最多500条
const offsetNum = Math.max(parseInt(offset) || 0, 0)
// 获取所有API Keys
const apiKeys = await apiKeyService.getAllApiKeys()
if (!apiKeys || apiKeys.length === 0) {
return res.json({ success: true, data: { records: [], total: 0 } })
}
// 收集所有API Key的使用记录
const allRecords = []
for (const key of apiKeys) {
try {
const records = await redis.getUsageRecords(key.id, 100) // 每个key最多取100条
if (records && records.length > 0) {
// 为每条记录添加API Key信息
const enrichedRecords = records.map((record) => ({
...record,
apiKeyId: key.id,
apiKeyName: key.name || 'Unnamed Key'
}))
allRecords.push(...enrichedRecords)
}
} catch (error) {
logger.error(`Failed to get usage records for key ${key.id}:`, error)
continue
}
}
// 按时间戳倒序排序(最新的在前)
allRecords.sort((a, b) => {
const timeA = new Date(a.timestamp).getTime()
const timeB = new Date(b.timestamp).getTime()
return timeB - timeA
})
// 分页
const paginatedRecords = allRecords.slice(offsetNum, offsetNum + limitNum)
// 获取账户名称映射
const accountIds = [...new Set(paginatedRecords.map((r) => r.accountId).filter(Boolean))]
const accountNameMap = {}
// 并发获取所有账户名称
await Promise.all(
accountIds.map(async (accountId) => {
try {
// 尝试从不同类型的账户中获取
const claudeAcc = await redis.getAccount(accountId)
if (claudeAcc && claudeAcc.name) {
accountNameMap[accountId] = claudeAcc.name
return
}
const consoleAcc = await redis.getClaudeConsoleAccount(accountId)
if (consoleAcc && consoleAcc.name) {
accountNameMap[accountId] = consoleAcc.name
return
}
const geminiAcc = await redis.getGeminiAccount(accountId)
if (geminiAcc && geminiAcc.name) {
accountNameMap[accountId] = geminiAcc.name
return
}
// 其他平台账户...
accountNameMap[accountId] = accountId // 降级显示ID
} catch (error) {
accountNameMap[accountId] = accountId
}
})
)
// 为记录添加账户名称
const enrichedRecords = paginatedRecords.map((record) => ({
...record,
accountName: record.accountId ? accountNameMap[record.accountId] || record.accountId : '-'
}))
return res.json({
success: true,
data: {
records: enrichedRecords,
total: allRecords.length,
limit: limitNum,
offset: offsetNum
}
})
} catch (error) {
logger.error('❌ Failed to get usage records:', error)
return res.status(500).json({
error: 'Failed to get usage records',
message: error.message
})
}
})
module.exports = router

View File

@@ -673,6 +673,158 @@
</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 items-center justify-between">
<h4 class="text-base font-semibold text-gray-800 dark:text-gray-200 sm:text-lg">
最近使用记录
</h4>
<button
class="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
:disabled="usageRecordsLoading"
@click="loadUsageRecords"
>
<i :class="['fas', usageRecordsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt']"></i>
<span class="ml-1">刷新</span>
</button>
</div>
<div v-if="usageRecordsLoading" class="py-12 text-center">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-500 dark:text-gray-400">正在加载使用记录...</p>
</div>
<div v-else-if="usageRecords.length === 0" class="py-12 text-center">
<p class="text-gray-500 dark:text-gray-400">暂无使用记录</p>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full min-w-[800px] text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th
class="border-b border-gray-200 px-3 py-2 text-left font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
>
时间
</th>
<th
class="border-b border-gray-200 px-3 py-2 text-left font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
>
API Key
</th>
<th
class="border-b border-gray-200 px-3 py-2 text-left font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
>
账户
</th>
<th
class="border-b border-gray-200 px-3 py-2 text-left font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
>
模型
</th>
<th
class="border-b border-gray-200 px-3 py-2 text-right font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
>
输入
</th>
<th
class="border-b border-gray-200 px-3 py-2 text-right font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
>
输出
</th>
<th
class="border-b border-gray-200 px-3 py-2 text-right font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
>
缓存创建
</th>
<th
class="border-b border-gray-200 px-3 py-2 text-right font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
>
缓存读取
</th>
<th
class="border-b border-gray-200 px-3 py-2 text-right font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
>
成本
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(record, index) in usageRecords"
:key="index"
class="hover:bg-gray-50 dark:hover:bg-gray-700/30"
>
<td
class="border-b border-gray-100 px-3 py-2 text-gray-700 dark:border-gray-700 dark:text-gray-300"
>
{{ formatRecordTime(record.timestamp) }}
</td>
<td
class="border-b border-gray-100 px-3 py-2 text-gray-700 dark:border-gray-700 dark:text-gray-300"
>
<div class="max-w-[120px] truncate" :title="record.apiKeyName">
{{ record.apiKeyName }}
</div>
</td>
<td
class="border-b border-gray-100 px-3 py-2 text-gray-700 dark:border-gray-700 dark:text-gray-300"
>
<div class="max-w-[120px] truncate" :title="record.accountName">
{{ record.accountName }}
</div>
</td>
<td
class="border-b border-gray-100 px-3 py-2 text-gray-700 dark:border-gray-700 dark:text-gray-300"
>
<div class="max-w-[150px] truncate" :title="record.model">
{{ record.model }}
</div>
</td>
<td
class="border-b border-gray-100 px-3 py-2 text-right text-gray-700 dark:border-gray-700 dark:text-gray-300"
>
{{ formatNumber(record.inputTokens) }}
</td>
<td
class="border-b border-gray-100 px-3 py-2 text-right text-gray-700 dark:border-gray-700 dark:text-gray-300"
>
{{ formatNumber(record.outputTokens) }}
</td>
<td
class="border-b border-gray-100 px-3 py-2 text-right text-gray-700 dark:border-gray-700 dark:text-gray-300"
>
{{ formatNumber(record.cacheCreateTokens) }}
</td>
<td
class="border-b border-gray-100 px-3 py-2 text-right text-gray-700 dark:border-gray-700 dark:text-gray-300"
>
{{ formatNumber(record.cacheReadTokens) }}
</td>
<td
class="border-b border-gray-100 px-3 py-2 text-right font-medium text-gray-700 dark:border-gray-700 dark:text-gray-300"
>
${{ formatCost(record.cost) }}
</td>
</tr>
</tbody>
</table>
<!-- 分页 -->
<div v-if="usageRecordsTotal > usageRecords.length" class="mt-4 flex justify-center">
<button
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
:disabled="usageRecordsLoading"
@click="loadMoreUsageRecords"
>
加载更多 (剩余 {{ usageRecordsTotal - usageRecords.length }} )
</button>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -681,12 +833,21 @@ import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useDashboardStore } from '@/stores/dashboard'
import { useThemeStore } from '@/stores/theme'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
import Chart from 'chart.js/auto'
const dashboardStore = useDashboardStore()
const themeStore = useThemeStore()
const { isDarkMode } = storeToRefs(themeStore)
// 使用记录相关
const usageRecords = ref([])
const usageRecordsLoading = ref(false)
const usageRecordsTotal = ref(0)
const usageRecordsOffset = ref(0)
const usageRecordsLimit = ref(50)
const {
dashboardData,
costsData,
@@ -1477,13 +1638,95 @@ watch(accountUsageTrendData, () => {
nextTick(() => createAccountUsageTrendChart())
})
// 加载使用记录
async function loadUsageRecords(reset = true) {
if (usageRecordsLoading.value) return
try {
usageRecordsLoading.value = true
if (reset) {
usageRecordsOffset.value = 0
usageRecords.value = []
}
const response = await apiClient.get('/admin/dashboard/usage-records', {
params: {
limit: usageRecordsLimit.value,
offset: usageRecordsOffset.value
}
})
if (response.success && response.data) {
if (reset) {
usageRecords.value = response.data.records || []
} else {
usageRecords.value = [...usageRecords.value, ...(response.data.records || [])]
}
usageRecordsTotal.value = response.data.total || 0
}
} catch (error) {
console.error('Failed to load usage records:', error)
showToast('加载使用记录失败', 'error')
} finally {
usageRecordsLoading.value = false
}
}
// 加载更多使用记录
async function loadMoreUsageRecords() {
usageRecordsOffset.value += usageRecordsLimit.value
await loadUsageRecords(false)
}
// 格式化记录时间
function formatRecordTime(timestamp) {
if (!timestamp) return '-'
const date = new Date(timestamp)
const now = new Date()
const diff = now - date
// 如果是今天
if (diff < 86400000 && date.getDate() === now.getDate()) {
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 如果是昨天
if (diff < 172800000 && date.getDate() === now.getDate() - 1) {
return '昨天 ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
// 其他日期
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 格式化数字(添加千分位)
function formatNumber(num) {
if (!num || num === 0) return '0'
return num.toLocaleString('en-US')
}
// 格式化成本
function formatCost(cost) {
if (!cost || cost === 0) return '0.000000'
return cost.toFixed(6)
}
// 刷新所有数据
async function refreshAllData() {
if (isRefreshing.value) return
isRefreshing.value = true
try {
await Promise.all([loadDashboardData(), refreshChartsData()])
await Promise.all([loadDashboardData(), refreshChartsData(), loadUsageRecords()])
} finally {
isRefreshing.value = false
}