mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
211
web/admin-spa/src/components/apikeys/RecordDetailModal.vue
Normal file
211
web/admin-spa/src/components/apikeys/RecordDetailModal.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:append-to-body="true"
|
||||
class="record-detail-modal"
|
||||
:close-on-click-modal="false"
|
||||
:destroy-on-close="true"
|
||||
:model-value="show"
|
||||
:show-close="false"
|
||||
top="10vh"
|
||||
width="720px"
|
||||
@close="emitClose"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
请求详情
|
||||
</p>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ record?.model || '未知模型' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label="关闭"
|
||||
class="rounded-full p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||||
@click="emitClose"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">基本信息</h4>
|
||||
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">时间</span>
|
||||
<span class="font-medium">{{ formattedTime }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">模型</span>
|
||||
<span class="font-medium">{{ record?.model || '未知模型' }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">账户</span>
|
||||
<span class="font-medium">{{ record?.accountName || '未知账户' }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">渠道</span>
|
||||
<span class="font-medium">{{ record?.accountTypeName || '未知渠道' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">Token 使用</h4>
|
||||
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">输入 Token</span>
|
||||
<span class="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{{ formatNumber(record?.inputTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">输出 Token</span>
|
||||
<span class="font-semibold text-green-600 dark:text-green-400">
|
||||
{{ formatNumber(record?.outputTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">缓存创建</span>
|
||||
<span class="font-semibold text-purple-600 dark:text-purple-400">
|
||||
{{ formatNumber(record?.cacheCreateTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">缓存读取</span>
|
||||
<span class="font-semibold text-orange-600 dark:text-orange-400">
|
||||
{{ formatNumber(record?.cacheReadTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">总计</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(record?.totalTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">费用详情</h4>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">输入费用</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formattedCosts.input }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">输出费用</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formattedCosts.output }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">缓存创建</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formattedCosts.cacheCreate }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">缓存读取</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formattedCosts.cacheRead }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 flex items-center justify-between rounded-md border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">总费用</span>
|
||||
<div class="text-base font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{{ record?.costFormatted || formattedCosts.total }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<el-button type="primary" @click="emitClose">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
record: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const emitClose = () => emit('close')
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
if (!props.record?.timestamp) return '未知时间'
|
||||
return dayjs(props.record.timestamp).format('YYYY-MM-DD HH:mm:ss')
|
||||
})
|
||||
|
||||
const formattedCosts = computed(() => {
|
||||
const breakdown = props.record?.costBreakdown || {}
|
||||
const formatValue = (value) => {
|
||||
const num = typeof value === 'number' ? value : 0
|
||||
if (num >= 1) return `$${num.toFixed(2)}`
|
||||
if (num >= 0.001) return `$${num.toFixed(4)}`
|
||||
return `$${num.toFixed(6)}`
|
||||
}
|
||||
|
||||
return {
|
||||
input: formatValue(breakdown.input),
|
||||
output: formatValue(breakdown.output),
|
||||
cacheCreate: formatValue(breakdown.cacheCreate),
|
||||
cacheRead: formatValue(breakdown.cacheRead),
|
||||
total: formatValue(breakdown.total)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.record-detail-modal :deep(.el-dialog__header) {
|
||||
margin: 0;
|
||||
padding: 16px 16px 0;
|
||||
}
|
||||
|
||||
.record-detail-modal :deep(.el-dialog__body) {
|
||||
padding: 12px 16px 4px;
|
||||
}
|
||||
|
||||
.record-detail-modal :deep(.el-dialog__footer) {
|
||||
padding: 8px 16px 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -231,6 +231,9 @@
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="mt-4 flex justify-end gap-2 sm:mt-6 sm:gap-3">
|
||||
<button class="btn btn-primary px-4 py-2 text-sm" type="button" @click="openTimeline">
|
||||
查看请求时间线
|
||||
</button>
|
||||
<button class="btn btn-secondary px-4 py-2 text-sm" type="button" @click="close">
|
||||
关闭
|
||||
</button>
|
||||
@@ -256,7 +259,7 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const emit = defineEmits(['close', 'open-timeline'])
|
||||
|
||||
// 计算属性
|
||||
const totalRequests = computed(() => props.apiKey.usage?.total?.requests || 0)
|
||||
@@ -320,6 +323,10 @@ const formatTokenCount = (count) => {
|
||||
const close = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const openTimeline = () => {
|
||||
emit('open-timeline', props.apiKey?.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,6 +11,7 @@ const UserManagementView = () => import('@/views/UserManagementView.vue')
|
||||
const MainLayout = () => import('@/components/layout/MainLayout.vue')
|
||||
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 TutorialView = () => import('@/views/TutorialView.vue')
|
||||
const SettingsView = () => import('@/views/SettingsView.vue')
|
||||
@@ -85,6 +86,18 @@ const routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/api-keys/:keyId/usage-records',
|
||||
component: MainLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'ApiKeyUsageRecords',
|
||||
component: ApiKeyUsageRecordsView
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
component: MainLayout,
|
||||
|
||||
@@ -67,22 +67,72 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
groupLabel: 'Claude账户'
|
||||
})
|
||||
|
||||
// 本地偏好
|
||||
const STORAGE_KEYS = {
|
||||
preset: 'dashboard:date:preset',
|
||||
granularity: 'dashboard:trend:granularity'
|
||||
}
|
||||
const defaultPreset = 'today'
|
||||
const defaultGranularity = 'day'
|
||||
|
||||
const getPresetOptions = (granularity) =>
|
||||
granularity === 'hour'
|
||||
? [
|
||||
{ value: 'last24h', label: '近24小时', hours: 24 },
|
||||
{ value: 'yesterday', label: '昨天', hours: 24 },
|
||||
{ value: 'dayBefore', label: '前天', hours: 24 }
|
||||
]
|
||||
: [
|
||||
{ value: 'today', label: '今日', days: 1 },
|
||||
{ value: '7days', label: '7天', days: 7 },
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
|
||||
const readFromStorage = (key, fallback) => {
|
||||
try {
|
||||
const value = localStorage.getItem(key)
|
||||
return value || fallback
|
||||
} catch (error) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const saveToStorage = (key, value) => {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch (error) {
|
||||
// 忽略存储错误,避免影响渲染
|
||||
}
|
||||
}
|
||||
|
||||
const normalizePresetForGranularity = (preset, granularity) => {
|
||||
const options = getPresetOptions(granularity)
|
||||
const hasPreset = options.some((opt) => opt.value === preset)
|
||||
if (hasPreset) return preset
|
||||
return granularity === 'hour' ? 'last24h' : defaultPreset
|
||||
}
|
||||
|
||||
const storedGranularity = readFromStorage(STORAGE_KEYS.granularity, defaultGranularity)
|
||||
const initialGranularity = ['day', 'hour'].includes(storedGranularity)
|
||||
? storedGranularity
|
||||
: defaultGranularity
|
||||
const initialPreset = normalizePresetForGranularity(
|
||||
readFromStorage(STORAGE_KEYS.preset, defaultPreset),
|
||||
initialGranularity
|
||||
)
|
||||
|
||||
// 日期筛选
|
||||
const dateFilter = ref({
|
||||
type: 'preset', // preset 或 custom
|
||||
preset: '7days', // today, 7days, 30days
|
||||
preset: initialPreset, // today, 7days, 30days
|
||||
customStart: '',
|
||||
customEnd: '',
|
||||
customRange: null,
|
||||
presetOptions: [
|
||||
{ value: 'today', label: '今日', days: 1 },
|
||||
{ value: '7days', label: '7天', days: 7 },
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
presetOptions: getPresetOptions(initialGranularity)
|
||||
})
|
||||
|
||||
// 趋势图粒度
|
||||
const trendGranularity = ref('day') // 'day' 或 'hour'
|
||||
const trendGranularity = ref(initialGranularity) // 'day' 或 'hour'
|
||||
const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens'
|
||||
const accountUsageGroup = ref('claude') // claude | openai | gemini
|
||||
|
||||
@@ -138,6 +188,21 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const persistDatePreferences = (
|
||||
preset = dateFilter.value.preset,
|
||||
granularity = trendGranularity.value
|
||||
) => {
|
||||
saveToStorage(STORAGE_KEYS.preset, preset)
|
||||
saveToStorage(STORAGE_KEYS.granularity, granularity)
|
||||
}
|
||||
|
||||
const getEffectiveGranularity = () =>
|
||||
dateFilter.value.type === 'preset' &&
|
||||
dateFilter.value.preset === 'today' &&
|
||||
trendGranularity.value === 'day'
|
||||
? 'hour'
|
||||
: trendGranularity.value
|
||||
|
||||
// 方法
|
||||
async function loadDashboardData(timeRange = null) {
|
||||
loading.value = true
|
||||
@@ -232,7 +297,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsageTrend(days = 7, granularity = 'day') {
|
||||
async function loadUsageTrend(days = 7, granularity = getEffectiveGranularity()) {
|
||||
try {
|
||||
let url = '/admin/usage-trend?'
|
||||
|
||||
@@ -241,26 +306,9 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
url += `granularity=hour`
|
||||
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
// 使用自定义时间范围 - 需要将系统时区时间转换为UTC
|
||||
const convertToUTC = (systemTzTimeStr) => {
|
||||
// 固定使用UTC+8,因为后端系统时区是UTC+8
|
||||
const systemTz = 8
|
||||
// 解析系统时区时间字符串
|
||||
const [datePart, timePart] = systemTzTimeStr.split(' ')
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
||||
|
||||
// 创建UTC时间,使其在系统时区显示为用户选择的时间
|
||||
// 例如:用户选择 UTC+8 的 2025-07-25 00:00:00
|
||||
// 对应的UTC时间是 2025-07-24 16:00:00
|
||||
const utcDate = new Date(
|
||||
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
|
||||
)
|
||||
return utcDate.toISOString()
|
||||
}
|
||||
|
||||
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
|
||||
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
|
||||
// 使用自定义时间范围 - 直接按系统时区字符串传递,避免额外时区偏移导致窗口错位
|
||||
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||
} else {
|
||||
// 使用预设计算时间范围,与loadApiKeysTrend保持一致
|
||||
const now = new Date()
|
||||
@@ -268,6 +316,12 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'today': {
|
||||
// 今日:使用系统时区的当日0点-23:59
|
||||
startTime = getSystemTimezoneDay(now, true)
|
||||
endTime = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
case 'last24h': {
|
||||
// 近24小时:从当前时间往前推24小时
|
||||
endTime = new Date(now)
|
||||
@@ -318,34 +372,28 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModelStats(period = 'daily') {
|
||||
async function loadModelStats(period = 'daily', granularity = null) {
|
||||
const currentGranularity = granularity || getEffectiveGranularity()
|
||||
try {
|
||||
let url = `/admin/model-stats?period=${period}`
|
||||
|
||||
// 如果是自定义时间范围或小时粒度,传递具体的时间参数
|
||||
if (dateFilter.value.type === 'custom' || trendGranularity.value === 'hour') {
|
||||
if (dateFilter.value.type === 'custom' || currentGranularity === 'hour') {
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
// 将系统时区时间转换为UTC
|
||||
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 if (trendGranularity.value === 'hour' && dateFilter.value.type === 'preset') {
|
||||
// 按系统时区字符串直传,避免额外时区转换
|
||||
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||
} else if (currentGranularity === 'hour' && dateFilter.value.type === 'preset') {
|
||||
// 小时粒度的预设时间范围
|
||||
const now = new Date()
|
||||
let startTime, endTime
|
||||
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'today': {
|
||||
startTime = getSystemTimezoneDay(now, true)
|
||||
endTime = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
case 'last24h': {
|
||||
endTime = new Date(now)
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
@@ -374,7 +422,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
|
||||
}
|
||||
} else if (dateFilter.value.type === 'preset' && trendGranularity.value === 'day') {
|
||||
} else if (dateFilter.value.type === 'preset' && currentGranularity === 'day') {
|
||||
// 天粒度的预设时间范围,需要传递startDate和endDate参数
|
||||
const now = new Date()
|
||||
let startDate, endDate
|
||||
@@ -409,36 +457,20 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiKeysTrend(metric = 'requests') {
|
||||
async function loadApiKeysTrend(metric = 'requests', granularity = null) {
|
||||
const currentGranularity = granularity || getEffectiveGranularity()
|
||||
try {
|
||||
let url = '/admin/api-keys-usage-trend?'
|
||||
let days = 7
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
if (currentGranularity === 'hour') {
|
||||
// 小时粒度,计算时间范围
|
||||
url += `granularity=hour`
|
||||
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
// 使用自定义时间范围 - 需要将系统时区时间转换为UTC
|
||||
const convertToUTC = (systemTzTimeStr) => {
|
||||
// 固定使用UTC+8,因为后端系统时区是UTC+8
|
||||
const systemTz = 8
|
||||
// 解析系统时区时间字符串
|
||||
const [datePart, timePart] = systemTzTimeStr.split(' ')
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
||||
|
||||
// 创建UTC时间,使其在系统时区显示为用户选择的时间
|
||||
// 例如:用户选择 UTC+8 的 2025-07-25 00:00:00
|
||||
// 对应的UTC时间是 2025-07-24 16:00:00
|
||||
const utcDate = new Date(
|
||||
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
|
||||
)
|
||||
return utcDate.toISOString()
|
||||
}
|
||||
|
||||
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
|
||||
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
|
||||
// 使用自定义时间范围 - 按系统时区字符串直传
|
||||
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||
} else {
|
||||
// 使用预设计算时间范围,与setDateFilterPreset保持一致
|
||||
const now = new Date()
|
||||
@@ -446,6 +478,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'today': {
|
||||
startTime = getSystemTimezoneDay(now, true)
|
||||
endTime = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
case 'last24h': {
|
||||
// 近24小时:从当前时间往前推24小时
|
||||
endTime = new Date(now)
|
||||
@@ -511,29 +548,18 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAccountUsageTrend(group = accountUsageGroup.value) {
|
||||
async function loadAccountUsageTrend(group = accountUsageGroup.value, granularity = null) {
|
||||
const currentGranularity = granularity || getEffectiveGranularity()
|
||||
try {
|
||||
let url = '/admin/account-usage-trend?'
|
||||
let days = 7
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
if (currentGranularity === '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]))}`
|
||||
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||
} else {
|
||||
const now = new Date()
|
||||
let startTime
|
||||
@@ -541,6 +567,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'today': {
|
||||
startTime = getSystemTimezoneDay(now, true)
|
||||
endTime = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
case 'last24h': {
|
||||
endTime = new Date(now)
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
@@ -603,110 +634,87 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
|
||||
// 日期筛选相关方法
|
||||
function setDateFilterPreset(preset) {
|
||||
function setDateFilterPreset(preset, options = {}) {
|
||||
const { silent = false, skipSave = false } = options
|
||||
const normalizedPreset = normalizePresetForGranularity(preset, trendGranularity.value)
|
||||
|
||||
dateFilter.value.type = 'preset'
|
||||
dateFilter.value.preset = preset
|
||||
dateFilter.value.preset = normalizedPreset
|
||||
|
||||
// 根据预设计算并设置具体的日期范围
|
||||
const option = dateFilter.value.presetOptions.find((opt) => opt.value === preset)
|
||||
if (option) {
|
||||
const now = new Date()
|
||||
let startDate, endDate
|
||||
const option = dateFilter.value.presetOptions.find((opt) => opt.value === normalizedPreset)
|
||||
const now = new Date()
|
||||
let startDate
|
||||
let endDate
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
// 小时粒度的预设
|
||||
switch (preset) {
|
||||
case 'last24h': {
|
||||
// 近24小时:从当前时间往前推24小时
|
||||
endDate = new Date(now)
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
}
|
||||
case 'yesterday': {
|
||||
// 昨天:获取本地时间的昨天
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
// 转换为系统时区的昨天0点和23:59
|
||||
startDate = getSystemTimezoneDay(yesterday, true)
|
||||
endDate = getSystemTimezoneDay(yesterday, false)
|
||||
break
|
||||
}
|
||||
case 'dayBefore': {
|
||||
// 前天:获取本地时间的前天
|
||||
const dayBefore = new Date()
|
||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||
// 转换为系统时区的前天0点和23:59
|
||||
startDate = getSystemTimezoneDay(dayBefore, true)
|
||||
endDate = getSystemTimezoneDay(dayBefore, false)
|
||||
break
|
||||
}
|
||||
if (trendGranularity.value === 'hour') {
|
||||
switch (normalizedPreset) {
|
||||
case 'today': {
|
||||
startDate = getSystemTimezoneDay(now, true)
|
||||
endDate = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// 天粒度的预设
|
||||
startDate = new Date(now)
|
||||
endDate = new Date(now)
|
||||
|
||||
if (preset === 'today') {
|
||||
// 今日:从凌晨开始
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
} else {
|
||||
// 其他预设:按天数计算
|
||||
startDate.setDate(now.getDate() - (option.days - 1))
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
case 'last24h': {
|
||||
endDate = new Date(now)
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
}
|
||||
case 'yesterday': {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
startDate = getSystemTimezoneDay(yesterday, true)
|
||||
endDate = getSystemTimezoneDay(yesterday, false)
|
||||
break
|
||||
}
|
||||
case 'dayBefore': {
|
||||
const dayBefore = new Date()
|
||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||
startDate = getSystemTimezoneDay(dayBefore, true)
|
||||
endDate = getSystemTimezoneDay(dayBefore, false)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
endDate = new Date(now)
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startDate = new Date(now)
|
||||
endDate = new Date(now)
|
||||
|
||||
dateFilter.value.customStart = startDate.toISOString().split('T')[0]
|
||||
dateFilter.value.customEnd = endDate.toISOString().split('T')[0]
|
||||
|
||||
// 设置 customRange 为 Element Plus 需要的格式
|
||||
// 对于小时粒度的昨天/前天,需要特殊处理显示
|
||||
if (trendGranularity.value === 'hour' && (preset === 'yesterday' || preset === 'dayBefore')) {
|
||||
// 获取本地日期
|
||||
const targetDate = new Date()
|
||||
if (preset === 'yesterday') {
|
||||
targetDate.setDate(targetDate.getDate() - 1)
|
||||
} else {
|
||||
targetDate.setDate(targetDate.getDate() - 2)
|
||||
}
|
||||
|
||||
// 显示系统时区的完整一天
|
||||
const year = targetDate.getFullYear()
|
||||
const month = String(targetDate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(targetDate.getDate()).padStart(2, '0')
|
||||
|
||||
dateFilter.value.customRange = [
|
||||
`${year}-${month}-${day} 00:00:00`,
|
||||
`${year}-${month}-${day} 23:59:59`
|
||||
]
|
||||
} else {
|
||||
// 其他情况:近24小时或天粒度
|
||||
const formatDateForDisplay = (date) => {
|
||||
// 固定使用UTC+8来显示时间
|
||||
const systemTz = 8
|
||||
const tzOffset = systemTz * 60 * 60 * 1000
|
||||
const localTime = new Date(date.getTime() + tzOffset)
|
||||
|
||||
const year = localTime.getUTCFullYear()
|
||||
const month = String(localTime.getUTCMonth() + 1).padStart(2, '0')
|
||||
const day = String(localTime.getUTCDate()).padStart(2, '0')
|
||||
const hours = String(localTime.getUTCHours()).padStart(2, '0')
|
||||
const minutes = String(localTime.getUTCMinutes()).padStart(2, '0')
|
||||
const seconds = String(localTime.getUTCSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
dateFilter.value.customRange = [
|
||||
formatDateForDisplay(startDate),
|
||||
formatDateForDisplay(endDate)
|
||||
]
|
||||
if (normalizedPreset === 'today') {
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
} else if (option?.days) {
|
||||
startDate.setDate(now.getDate() - (option.days - 1))
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
}
|
||||
}
|
||||
|
||||
// 触发数据刷新
|
||||
refreshChartsData()
|
||||
const formatDateForDisplay = (date) => {
|
||||
// 使用本地时间直接格式化,避免多余的时区偏移导致日期错位
|
||||
const localTime = new Date(date)
|
||||
const year = localTime.getFullYear()
|
||||
const month = String(localTime.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(localTime.getDate()).padStart(2, '0')
|
||||
const hours = String(localTime.getHours()).padStart(2, '0')
|
||||
const minutes = String(localTime.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(localTime.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
dateFilter.value.customStart = startDate ? startDate.toISOString().split('T')[0] : ''
|
||||
dateFilter.value.customEnd = endDate ? endDate.toISOString().split('T')[0] : ''
|
||||
dateFilter.value.customRange =
|
||||
startDate && endDate ? [formatDateForDisplay(startDate), formatDateForDisplay(endDate)] : null
|
||||
|
||||
if (!skipSave) {
|
||||
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
refreshChartsData()
|
||||
}
|
||||
}
|
||||
|
||||
function onCustomDateRangeChange(value) {
|
||||
@@ -751,20 +759,17 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
refreshChartsData()
|
||||
} else if (value === null) {
|
||||
// 清空时恢复默认
|
||||
setDateFilterPreset(trendGranularity.value === 'hour' ? 'last24h' : '7days')
|
||||
setDateFilterPreset(trendGranularity.value === 'hour' ? 'last24h' : defaultPreset)
|
||||
}
|
||||
}
|
||||
|
||||
function setTrendGranularity(granularity) {
|
||||
function setTrendGranularity(granularity, options = {}) {
|
||||
const { silent = false, skipSave = false, presetOverride } = options
|
||||
trendGranularity.value = granularity
|
||||
|
||||
// 根据粒度更新预设选项
|
||||
if (granularity === 'hour') {
|
||||
dateFilter.value.presetOptions = [
|
||||
{ value: 'last24h', label: '近24小时', hours: 24 },
|
||||
{ value: 'yesterday', label: '昨天', hours: 24 },
|
||||
{ value: 'dayBefore', label: '前天', hours: 24 }
|
||||
]
|
||||
dateFilter.value.presetOptions = getPresetOptions('hour')
|
||||
|
||||
// 检查当前自定义日期范围是否超过24小时
|
||||
if (
|
||||
@@ -777,46 +782,53 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
const hoursDiff = (end - start) / (1000 * 60 * 60)
|
||||
if (hoursDiff > 24) {
|
||||
showToast('小时粒度下日期范围不能超过24小时,已切换到近24小时', 'warning')
|
||||
setDateFilterPreset('last24h')
|
||||
setDateFilterPreset('last24h', { silent, skipSave })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果当前是天粒度的预设,切换到小时粒度的默认预设
|
||||
if (['today', '7days', '30days'].includes(dateFilter.value.preset)) {
|
||||
setDateFilterPreset('last24h')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 天粒度
|
||||
dateFilter.value.presetOptions = [
|
||||
{ value: 'today', label: '今日', days: 1 },
|
||||
{ value: '7days', label: '7天', days: 7 },
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
|
||||
// 如果当前是小时粒度的预设,切换到天粒度的默认预设
|
||||
if (['last24h', 'yesterday', 'dayBefore'].includes(dateFilter.value.preset)) {
|
||||
setDateFilterPreset('7days')
|
||||
return
|
||||
}
|
||||
dateFilter.value.presetOptions = getPresetOptions('day')
|
||||
}
|
||||
|
||||
// 触发数据刷新
|
||||
refreshChartsData()
|
||||
if (dateFilter.value.type === 'custom') {
|
||||
if (!skipSave) {
|
||||
persistDatePreferences(dateFilter.value.preset || defaultPreset, trendGranularity.value)
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
refreshChartsData()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const nextPreset =
|
||||
presetOverride ||
|
||||
normalizePresetForGranularity(dateFilter.value.preset, trendGranularity.value)
|
||||
|
||||
setDateFilterPreset(nextPreset, { silent: true, skipSave: true })
|
||||
|
||||
if (!skipSave) {
|
||||
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
refreshChartsData()
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshChartsData() {
|
||||
// 根据当前筛选条件刷新数据
|
||||
let days
|
||||
let modelPeriod = 'monthly'
|
||||
const effectiveGranularity = getEffectiveGranularity()
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
const option = dateFilter.value.presetOptions.find(
|
||||
(opt) => opt.value === dateFilter.value.preset
|
||||
)
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
if (effectiveGranularity === 'hour') {
|
||||
// 小时粒度
|
||||
days = 1 // 小时粒度默认查看1天的数据
|
||||
modelPeriod = 'daily' // 小时粒度使用日统计
|
||||
@@ -832,7 +844,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
} else {
|
||||
// 自定义日期范围
|
||||
if (trendGranularity.value === 'hour') {
|
||||
if (effectiveGranularity === 'hour') {
|
||||
// 小时粒度下的自定义范围,计算小时数
|
||||
const start = new Date(dateFilter.value.customRange[0])
|
||||
const end = new Date(dateFilter.value.customRange[1])
|
||||
@@ -845,16 +857,16 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
loadUsageTrend(days, trendGranularity.value),
|
||||
loadModelStats(modelPeriod),
|
||||
loadApiKeysTrend(apiKeysTrendMetric.value),
|
||||
loadAccountUsageTrend(accountUsageGroup.value)
|
||||
loadUsageTrend(days, effectiveGranularity),
|
||||
loadModelStats(modelPeriod, effectiveGranularity),
|
||||
loadApiKeysTrend(apiKeysTrendMetric.value, effectiveGranularity),
|
||||
loadAccountUsageTrend(accountUsageGroup.value, effectiveGranularity)
|
||||
])
|
||||
}
|
||||
|
||||
function setAccountUsageGroup(group) {
|
||||
accountUsageGroup.value = group
|
||||
return loadAccountUsageTrend(group)
|
||||
return loadAccountUsageTrend(group, getEffectiveGranularity())
|
||||
}
|
||||
|
||||
function calculateDaysBetween(start, end) {
|
||||
@@ -870,6 +882,10 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
return date > new Date()
|
||||
}
|
||||
|
||||
// 初始化日期筛选:同步本地偏好并填充范围
|
||||
setDateFilterPreset(dateFilter.value.preset, { silent: true, skipSave: true })
|
||||
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
|
||||
|
||||
return {
|
||||
// 状态
|
||||
loading,
|
||||
|
||||
549
web/admin-spa/src/views/ApiKeyUsageRecordsView.vue
Normal file
549
web/admin-spa/src/views/ApiKeyUsageRecordsView.vue
Normal file
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div class="space-y-4 p-4 lg:p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-100 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
@click="goBack"
|
||||
>
|
||||
← 返回
|
||||
</button>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||
API Key 请求详情时间线
|
||||
</p>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ apiKeyDisplayName }}
|
||||
</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">ID: {{ keyId }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-clock text-blue-500" />
|
||||
<span v-if="dateRangeHint">{{ dateRangeHint }}</span>
|
||||
<span v-else>显示近 5000 条记录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总请求</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(summary.totalRequests) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总 Token</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(summary.totalTokens) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总费用</p>
|
||||
<p class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{{ formatCost(summary.totalCost) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">平均费用/次</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatCost(summary.avgCost) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<el-date-picker
|
||||
v-model="filters.dateRange"
|
||||
class="max-w-[320px]"
|
||||
clearable
|
||||
end-placeholder="结束时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
start-placeholder="开始时间"
|
||||
type="datetimerange"
|
||||
unlink-panels
|
||||
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
|
||||
/>
|
||||
|
||||
<el-select
|
||||
v-model="filters.model"
|
||||
class="w-[180px]"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="所有模型"
|
||||
>
|
||||
<el-option
|
||||
v-for="modelOption in availableModels"
|
||||
:key="modelOption"
|
||||
:label="modelOption"
|
||||
:value="modelOption"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-model="filters.accountId"
|
||||
class="w-[220px]"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="所有账户"
|
||||
>
|
||||
<el-option
|
||||
v-for="account in availableAccounts"
|
||||
:key="account.id"
|
||||
:label="`${account.name}(${account.accountTypeName})`"
|
||||
:value="account.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select v-model="filters.sortOrder" class="w-[140px]" placeholder="排序">
|
||||
<el-option label="时间降序" value="desc" />
|
||||
<el-option label="时间升序" value="asc" />
|
||||
</el-select>
|
||||
|
||||
<el-button @click="resetFilters"> <i class="fas fa-undo mr-2" /> 重置 </el-button>
|
||||
<el-button :loading="exporting" type="primary" @click="exportCsv">
|
||||
<i class="fas fa-file-export mr-2" /> 导出 CSV
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center justify-center p-10 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-spinner fa-spin mr-2" /> 加载中...
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="records.length === 0"
|
||||
class="flex flex-col items-center gap-2 p-10 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-inbox text-2xl" />
|
||||
<p>暂无记录</p>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div class="hidden overflow-x-auto md:block">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
时间
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
账户
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
模型
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
输入
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
输出
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
缓存(创/读)
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
总 Token
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
费用
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<tr v-for="record in records" :key="record.timestamp + record.model">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ formatDate(record.timestamp) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold">{{ record.accountName || '未知账户' }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ record.accountTypeName || '未知渠道' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ record.model }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-blue-600 dark:text-blue-400">
|
||||
{{ formatNumber(record.inputTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
{{ formatNumber(record.outputTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-purple-600 dark:text-purple-400"
|
||||
>
|
||||
{{ formatNumber(record.cacheCreateTokens) }} /
|
||||
{{ formatNumber(record.cacheReadTokens) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ formatNumber(record.totalTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400"
|
||||
>
|
||||
{{ record.costFormatted || formatCost(record.cost) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 md:hidden">
|
||||
<div
|
||||
v-for="record in records"
|
||||
:key="record.timestamp + record.model"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ record.accountName || '未知账户' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatDate(record.timestamp) }}
|
||||
</p>
|
||||
</div>
|
||||
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div>模型:{{ record.model }}</div>
|
||||
<div>总 Token:{{ formatNumber(record.totalTokens) }}</div>
|
||||
<div>输入:{{ formatNumber(record.inputTokens) }}</div>
|
||||
<div>输出:{{ formatNumber(record.outputTokens) }}</div>
|
||||
<div>
|
||||
缓存创/读:{{ formatNumber(record.cacheCreateTokens) }} /
|
||||
{{ formatNumber(record.cacheReadTokens) }}
|
||||
</div>
|
||||
<div class="text-yellow-600 dark:text-yellow-400">
|
||||
费用:{{ record.costFormatted || formatCost(record.cost) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-4 pb-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
共 {{ pagination.totalRecords }} 条记录
|
||||
</div>
|
||||
<el-pagination
|
||||
background
|
||||
:current-page="pagination.currentPage"
|
||||
layout="prev, pager, next, sizes"
|
||||
:page-size="pagination.pageSize"
|
||||
:page-sizes="[20, 50, 100, 200]"
|
||||
:total="pagination.totalRecords"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RecordDetailModal :record="activeRecord" :show="detailVisible" @close="closeDetail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const keyId = computed(() => route.params.keyId)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const records = ref([])
|
||||
const availableModels = ref([])
|
||||
const availableAccounts = ref([])
|
||||
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 50,
|
||||
totalRecords: 0
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
dateRange: null,
|
||||
model: '',
|
||||
accountId: '',
|
||||
sortOrder: 'desc'
|
||||
})
|
||||
|
||||
const summary = reactive({
|
||||
totalRequests: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
avgCost: 0
|
||||
})
|
||||
|
||||
const apiKeyInfo = reactive({
|
||||
id: keyId.value,
|
||||
name: ''
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const activeRecord = ref(null)
|
||||
|
||||
const apiKeyDisplayName = computed(() => apiKeyInfo.name || apiKeyInfo.id || keyId.value)
|
||||
|
||||
const dateRangeHint = computed(() => {
|
||||
if (!filters.dateRange || filters.dateRange.length !== 2) return ''
|
||||
return `${formatDate(filters.dateRange[0])} ~ ${formatDate(filters.dateRange[1])}`
|
||||
})
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '--'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const formatCost = (value) => {
|
||||
const num = typeof value === 'number' ? value : 0
|
||||
if (num >= 1) return `$${num.toFixed(2)}`
|
||||
if (num >= 0.001) return `$${num.toFixed(4)}`
|
||||
return `$${num.toFixed(6)}`
|
||||
}
|
||||
|
||||
const buildParams = (page) => {
|
||||
const params = {
|
||||
page,
|
||||
pageSize: pagination.pageSize,
|
||||
sortOrder: filters.sortOrder
|
||||
}
|
||||
|
||||
if (filters.model) params.model = filters.model
|
||||
if (filters.accountId) params.accountId = filters.accountId
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
params.startDate = dayjs(filters.dateRange[0]).toISOString()
|
||||
params.endDate = dayjs(filters.dateRange[1]).toISOString()
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
const syncResponseState = (data) => {
|
||||
records.value = data.records || []
|
||||
|
||||
const pageInfo = data.pagination || {}
|
||||
pagination.currentPage = pageInfo.currentPage || 1
|
||||
pagination.pageSize = pageInfo.pageSize || pagination.pageSize
|
||||
pagination.totalRecords = pageInfo.totalRecords || 0
|
||||
|
||||
const filterEcho = data.filters || {}
|
||||
if (filterEcho.model !== undefined) filters.model = filterEcho.model || ''
|
||||
if (filterEcho.accountId !== undefined) filters.accountId = filterEcho.accountId || ''
|
||||
if (filterEcho.sortOrder) filters.sortOrder = filterEcho.sortOrder
|
||||
if (filterEcho.startDate && filterEcho.endDate) {
|
||||
const nextRange = [filterEcho.startDate, filterEcho.endDate]
|
||||
const currentRange = filters.dateRange || []
|
||||
if (currentRange[0] !== nextRange[0] || currentRange[1] !== nextRange[1]) {
|
||||
filters.dateRange = nextRange
|
||||
}
|
||||
}
|
||||
|
||||
const summaryData = data.summary || {}
|
||||
summary.totalRequests = summaryData.totalRequests || 0
|
||||
summary.totalTokens = summaryData.totalTokens || 0
|
||||
summary.totalCost = summaryData.totalCost || 0
|
||||
summary.avgCost = summaryData.avgCost || 0
|
||||
|
||||
apiKeyInfo.id = data.apiKeyInfo?.id || keyId.value
|
||||
apiKeyInfo.name = data.apiKeyInfo?.name || ''
|
||||
|
||||
availableModels.value = data.availableFilters?.models || []
|
||||
availableAccounts.value = data.availableFilters?.accounts || []
|
||||
}
|
||||
|
||||
const fetchRecords = async (page = pagination.currentPage) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/api-keys/${keyId.value}/usage-records`, {
|
||||
params: buildParams(page)
|
||||
})
|
||||
syncResponseState(response.data || {})
|
||||
} catch (error) {
|
||||
showToast(`加载请求记录失败:${error.message || '未知错误'}`, 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
pagination.currentPage = page
|
||||
fetchRecords(page)
|
||||
}
|
||||
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.pageSize = size
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.model = ''
|
||||
filters.accountId = ''
|
||||
filters.dateRange = null
|
||||
filters.sortOrder = 'desc'
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
|
||||
const openDetail = (record) => {
|
||||
activeRecord.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const closeDetail = () => {
|
||||
detailVisible.value = false
|
||||
activeRecord.value = null
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/api-keys')
|
||||
}
|
||||
|
||||
const exportCsv = async () => {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const aggregated = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
const maxPages = 50 // 50 * 200 = 10000,超过后端 5000 上限已足够
|
||||
|
||||
while (page <= totalPages && page <= maxPages) {
|
||||
const response = await apiClient.get(`/admin/api-keys/${keyId.value}/usage-records`, {
|
||||
params: { ...buildParams(page), pageSize: 200 }
|
||||
})
|
||||
const payload = response.data || {}
|
||||
aggregated.push(...(payload.records || []))
|
||||
totalPages = payload.pagination?.totalPages || 1
|
||||
page += 1
|
||||
}
|
||||
|
||||
if (aggregated.length === 0) {
|
||||
showToast('没有可导出的记录', 'info')
|
||||
return
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'时间',
|
||||
'账户',
|
||||
'渠道',
|
||||
'模型',
|
||||
'输入Token',
|
||||
'输出Token',
|
||||
'缓存创建Token',
|
||||
'缓存读取Token',
|
||||
'总Token',
|
||||
'费用'
|
||||
]
|
||||
|
||||
const csvRows = [headers.join(',')]
|
||||
aggregated.forEach((record) => {
|
||||
const row = [
|
||||
formatDate(record.timestamp),
|
||||
record.accountName || '',
|
||||
record.accountTypeName || '',
|
||||
record.model || '',
|
||||
record.inputTokens || 0,
|
||||
record.outputTokens || 0,
|
||||
record.cacheCreateTokens || 0,
|
||||
record.cacheReadTokens || 0,
|
||||
record.totalTokens || 0,
|
||||
record.costFormatted || formatCost(record.cost)
|
||||
]
|
||||
csvRows.push(row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
})
|
||||
|
||||
const blob = new Blob([csvRows.join('\n')], {
|
||||
type: 'text/csv;charset=utf-8;'
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `api-key-${keyId.value}-usage-records.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
showToast('导出 CSV 成功', 'success')
|
||||
} catch (error) {
|
||||
showToast(`导出失败:${error.message || '未知错误'}`, 'error')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [filters.model, filters.accountId, filters.sortOrder],
|
||||
() => {
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filters.dateRange,
|
||||
() => {
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchRecords()
|
||||
})
|
||||
</script>
|
||||
@@ -2085,17 +2085,18 @@
|
||||
@save="handleSaveExpiry"
|
||||
/>
|
||||
|
||||
<!-- 使用详情弹窗 -->
|
||||
<UsageDetailModal
|
||||
:api-key="selectedApiKeyForDetail || {}"
|
||||
:show="showUsageDetailModal"
|
||||
@close="showUsageDetailModal = false"
|
||||
@open-timeline="openTimeline"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
@@ -2114,6 +2115,7 @@ import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||
|
||||
// 响应式数据
|
||||
const router = useRouter()
|
||||
const clientsStore = useClientsStore()
|
||||
const authStore = useAuthStore()
|
||||
const apiKeys = ref([])
|
||||
@@ -4193,19 +4195,15 @@ const formatWindowTime = (seconds) => {
|
||||
|
||||
// 显示使用详情
|
||||
const showUsageDetails = (apiKey) => {
|
||||
// 获取异步加载的统计数据
|
||||
const cachedStats = getCachedStats(apiKey.id)
|
||||
|
||||
// 合并异步统计数据到 apiKey 对象
|
||||
const enrichedApiKey = {
|
||||
...apiKey,
|
||||
// 合并实时限制数据
|
||||
dailyCost: cachedStats?.dailyCost ?? apiKey.dailyCost ?? 0,
|
||||
currentWindowCost: cachedStats?.currentWindowCost ?? apiKey.currentWindowCost ?? 0,
|
||||
windowRemainingSeconds: cachedStats?.windowRemainingSeconds ?? apiKey.windowRemainingSeconds,
|
||||
windowStartTime: cachedStats?.windowStartTime ?? apiKey.windowStartTime ?? null,
|
||||
windowEndTime: cachedStats?.windowEndTime ?? apiKey.windowEndTime ?? null,
|
||||
// 合并 usage 数据(用于详情弹窗中的统计卡片)
|
||||
usage: {
|
||||
...apiKey.usage,
|
||||
total: {
|
||||
@@ -4226,6 +4224,13 @@ const showUsageDetails = (apiKey) => {
|
||||
showUsageDetailModal.value = true
|
||||
}
|
||||
|
||||
const openTimeline = (keyId) => {
|
||||
const id = keyId || selectedApiKeyForDetail.value?.id
|
||||
if (!id) return
|
||||
showUsageDetailModal.value = false
|
||||
router.push(`/api-keys/${id}/usage-records`)
|
||||
}
|
||||
|
||||
// 格式化时间(秒转换为可读格式) - 已移到 WindowLimitBar 组件中
|
||||
// const formatTime = (seconds) => {
|
||||
// if (seconds === null || seconds === undefined) return '--:--'
|
||||
|
||||
@@ -1036,6 +1036,7 @@ function createUsageTrendChart() {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
min: 0,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Token数量',
|
||||
@@ -1055,6 +1056,7 @@ function createUsageTrendChart() {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
min: 0,
|
||||
title: {
|
||||
display: true,
|
||||
text: '请求数',
|
||||
@@ -1073,7 +1075,8 @@ function createUsageTrendChart() {
|
||||
y2: {
|
||||
type: 'linear',
|
||||
display: false, // 隐藏费用轴,在tooltip中显示
|
||||
position: 'right'
|
||||
position: 'right',
|
||||
min: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1253,6 +1256,7 @@ function createApiKeysUsageTrendChart() {
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
min: 0,
|
||||
title: {
|
||||
display: true,
|
||||
text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数',
|
||||
@@ -1428,6 +1432,7 @@ function createAccountUsageTrendChart() {
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
min: 0,
|
||||
title: {
|
||||
display: true,
|
||||
text: '消耗金额 (USD)',
|
||||
|
||||
Reference in New Issue
Block a user