mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-08 17:14:45 +08:00
* feat(frontend): 前端界面优化与使用统计功能增强
主要改动:
1. 表格布局统一优化
- 新增 TablePageLayout 通用布局组件
- 统一所有管理页面的表格样式和交互
- 优化 DataTable、Pagination、Select 等通用组件
2. 使用统计功能增强
- 管理端: 添加完整的筛选和显示功能
- 用户端: 完善 API Key 列显示
- 后端: 优化使用统计数据结构和查询
3. 账户组件优化
- 优化 AccountStatsModal、AccountUsageCell 等组件
- 统一进度条和统计显示样式
4. 其他改进
- 完善中英文国际化
- 统一页面样式和交互体验
- 优化各视图页面的响应式布局
* fix(test): 修复 stubUsageLogRepo.ListWithFilters 测试 stub
测试用例 GET /api/v1/usage 返回 500 是因为 stub 方法未实现,
现在正确返回基于 UserID 过滤的日志数据。
* feat(frontend): 统一日期时间显示格式
**主要改动**:
1. 增强 utils/format.ts:
- 新增 formatDateOnly() - 格式: YYYY-MM-DD
- 新增 formatDateTime() - 格式: YYYY-MM-DD HH:mm:ss
2. 全局替换视图中的格式化函数:
- 移除各视图中的自定义 formatDate 函数
- 统一导入使用 @/utils/format 中的函数
- created_at/updated_at 使用 formatDateTime
- expires_at 使用 formatDateOnly
3. 受影响的视图 (8个):
- frontend/src/views/user/KeysView.vue
- frontend/src/views/user/DashboardView.vue
- frontend/src/views/user/UsageView.vue
- frontend/src/views/user/RedeemView.vue
- frontend/src/views/admin/UsersView.vue
- frontend/src/views/admin/UsageView.vue
- frontend/src/views/admin/RedeemView.vue
- frontend/src/views/admin/SubscriptionsView.vue
**效果**:
- 日期统一显示为 YYYY-MM-DD
- 时间统一显示为 YYYY-MM-DD HH:mm:ss
- 提升可维护性,避免格式不一致
* fix(frontend): 补充遗漏的时间格式化统一
**补充修复**(基于 code review 发现的遗漏):
1. 增强 utils/format.ts:
- 新增 formatTime() - 格式: HH:mm
2. 修复 4 个遗漏的文件:
- src/views/admin/UsersView.vue
* 删除 formatExpiresAt(),改用 formatDateTime()
* 修复订阅过期时间 tooltip 显示格式不一致问题
- src/views/user/ProfileView.vue
* 删除 formatMemberSince(),改用 formatDate(date, 'YYYY-MM')
* 统一会员起始时间显示格式
- src/views/user/SubscriptionsView.vue
* 修改 formatExpirationDate() 使用 formatDateOnly()
* 保留天数计算逻辑
- src/components/account/AccountStatusIndicator.vue
* 删除本地 formatTime(),改用 utils/format 中的统一函数
* 修复 rate limit 和 overload 重置时间显示
**验证**:
- TypeScript 类型检查通过 ✓
- 前端构建成功 ✓
- 所有剩余的 toLocaleString() 都是数字格式化,属于正确用法 ✓
**效果**:
- 订阅过期时间统一为 YYYY-MM-DD HH:mm:ss
- 会员起始时间统一为 YYYY-MM
- 重置时间统一为 HH:mm
- 消除所有不规范的原生 locale 方法调用
779 lines
28 KiB
Vue
779 lines
28 KiB
Vue
<template>
|
|
<Modal :show="show" :title="t('admin.accounts.usageStatistics')" size="2xl" @close="handleClose">
|
|
<div class="space-y-6">
|
|
<!-- Account Info Header -->
|
|
<div
|
|
v-if="account"
|
|
class="flex items-center justify-between rounded-xl border border-primary-200 bg-gradient-to-r from-primary-50 to-primary-100 p-3 dark:border-primary-700/50 dark:from-primary-900/20 dark:to-primary-800/20"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<div
|
|
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
|
|
>
|
|
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.accounts.last30DaysUsage') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span
|
|
:class="[
|
|
'rounded-full px-2.5 py-1 text-xs font-semibold',
|
|
account.status === 'active'
|
|
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
|
|
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
|
]"
|
|
>
|
|
{{ account.status }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="loading" class="flex items-center justify-center py-12">
|
|
<LoadingSpinner />
|
|
</div>
|
|
|
|
<template v-else-if="stats">
|
|
<!-- Row 1: Main Stats Cards -->
|
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
<!-- 30-Day Total Cost -->
|
|
<div
|
|
class="card border-emerald-200 bg-gradient-to-br from-emerald-50 to-white p-4 dark:border-emerald-800/30 dark:from-emerald-900/10 dark:to-dark-700"
|
|
>
|
|
<div class="mb-2 flex items-center justify-between">
|
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.totalCost')
|
|
}}</span>
|
|
<div class="rounded-lg bg-emerald-100 p-1.5 dark:bg-emerald-900/30">
|
|
<svg
|
|
class="h-4 w-4 text-emerald-600 dark:text-emerald-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
${{ formatCost(stats.summary.total_cost) }}
|
|
</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.accounts.stats.accumulatedCost') }}
|
|
<span class="text-gray-400 dark:text-gray-500"
|
|
>({{ t('admin.accounts.stats.standardCost') }}: ${{
|
|
formatCost(stats.summary.total_standard_cost)
|
|
}})</span
|
|
>
|
|
</p>
|
|
</div>
|
|
|
|
<!-- 30-Day Total Requests -->
|
|
<div
|
|
class="card border-blue-200 bg-gradient-to-br from-blue-50 to-white p-4 dark:border-blue-800/30 dark:from-blue-900/10 dark:to-dark-700"
|
|
>
|
|
<div class="mb-2 flex items-center justify-between">
|
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.totalRequests')
|
|
}}</span>
|
|
<div class="rounded-lg bg-blue-100 p-1.5 dark:bg-blue-900/30">
|
|
<svg
|
|
class="h-4 w-4 text-blue-600 dark:text-blue-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{{ formatNumber(stats.summary.total_requests) }}
|
|
</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.accounts.stats.totalCalls') }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Daily Average Cost -->
|
|
<div
|
|
class="card border-amber-200 bg-gradient-to-br from-amber-50 to-white p-4 dark:border-amber-800/30 dark:from-amber-900/10 dark:to-dark-700"
|
|
>
|
|
<div class="mb-2 flex items-center justify-between">
|
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.avgDailyCost')
|
|
}}</span>
|
|
<div class="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/30">
|
|
<svg
|
|
class="h-4 w-4 text-amber-600 dark:text-amber-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
${{ formatCost(stats.summary.avg_daily_cost) }}
|
|
</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{{
|
|
t('admin.accounts.stats.basedOnActualDays', {
|
|
days: stats.summary.actual_days_used
|
|
})
|
|
}}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Daily Average Requests -->
|
|
<div
|
|
class="card border-purple-200 bg-gradient-to-br from-purple-50 to-white p-4 dark:border-purple-800/30 dark:from-purple-900/10 dark:to-dark-700"
|
|
>
|
|
<div class="mb-2 flex items-center justify-between">
|
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.avgDailyRequests')
|
|
}}</span>
|
|
<div class="rounded-lg bg-purple-100 p-1.5 dark:bg-purple-900/30">
|
|
<svg
|
|
class="h-4 w-4 text-purple-600 dark:text-purple-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{{ formatNumber(Math.round(stats.summary.avg_daily_requests)) }}
|
|
</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.accounts.stats.avgDailyUsage') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Row 2: Today, Highest Cost, Highest Requests -->
|
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
<!-- Today Overview -->
|
|
<div class="card p-4">
|
|
<div class="mb-3 flex items-center gap-2">
|
|
<div class="rounded-lg bg-cyan-100 p-1.5 dark:bg-cyan-900/30">
|
|
<svg
|
|
class="h-4 w-4 text-cyan-600 dark:text-cyan-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
t('admin.accounts.stats.todayOverview')
|
|
}}</span>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.cost')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
|
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
|
|
>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.requests')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
formatNumber(stats.summary.today?.requests || 0)
|
|
}}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.tokens')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
formatTokens(stats.summary.today?.tokens || 0)
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Highest Cost Day -->
|
|
<div class="card p-4">
|
|
<div class="mb-3 flex items-center gap-2">
|
|
<div class="rounded-lg bg-orange-100 p-1.5 dark:bg-orange-900/30">
|
|
<svg
|
|
class="h-4 w-4 text-orange-600 dark:text-orange-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
t('admin.accounts.stats.highestCostDay')
|
|
}}</span>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.date')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
stats.summary.highest_cost_day?.label || '-'
|
|
}}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.cost')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
|
|
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
|
|
>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.requests')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
formatNumber(stats.summary.highest_cost_day?.requests || 0)
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Highest Request Day -->
|
|
<div class="card p-4">
|
|
<div class="mb-3 flex items-center gap-2">
|
|
<div class="rounded-lg bg-indigo-100 p-1.5 dark:bg-indigo-900/30">
|
|
<svg
|
|
class="h-4 w-4 text-indigo-600 dark:text-indigo-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
t('admin.accounts.stats.highestRequestDay')
|
|
}}</span>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.date')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
stats.summary.highest_request_day?.label || '-'
|
|
}}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.requests')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-indigo-600 dark:text-indigo-400">{{
|
|
formatNumber(stats.summary.highest_request_day?.requests || 0)
|
|
}}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.cost')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
|
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Row 3: Token Stats -->
|
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
<!-- Accumulated Tokens -->
|
|
<div class="card p-4">
|
|
<div class="mb-3 flex items-center gap-2">
|
|
<div class="rounded-lg bg-teal-100 p-1.5 dark:bg-teal-900/30">
|
|
<svg
|
|
class="h-4 w-4 text-teal-600 dark:text-teal-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
t('admin.accounts.stats.accumulatedTokens')
|
|
}}</span>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.totalTokens')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
formatTokens(stats.summary.total_tokens)
|
|
}}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.dailyAvgTokens')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
formatTokens(Math.round(stats.summary.avg_daily_tokens))
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Performance -->
|
|
<div class="card p-4">
|
|
<div class="mb-3 flex items-center gap-2">
|
|
<div class="rounded-lg bg-rose-100 p-1.5 dark:bg-rose-900/30">
|
|
<svg
|
|
class="h-4 w-4 text-rose-600 dark:text-rose-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
t('admin.accounts.stats.performance')
|
|
}}</span>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.avgResponseTime')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
formatDuration(stats.summary.avg_duration_ms)
|
|
}}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.daysActive')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
|
>{{ stats.summary.actual_days_used }} / {{ stats.summary.days }}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Activity -->
|
|
<div class="card p-4">
|
|
<div class="mb-3 flex items-center gap-2">
|
|
<div class="rounded-lg bg-lime-100 p-1.5 dark:bg-lime-900/30">
|
|
<svg
|
|
class="h-4 w-4 text-lime-600 dark:text-lime-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
t('admin.accounts.stats.recentActivity')
|
|
}}</span>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.todayRequests')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
formatNumber(stats.summary.today?.requests || 0)
|
|
}}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.todayTokens')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
|
formatTokens(stats.summary.today?.tokens || 0)
|
|
}}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('admin.accounts.stats.todayCost')
|
|
}}</span>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
|
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Usage Trend Chart -->
|
|
<div class="card p-4">
|
|
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
|
{{ t('admin.accounts.stats.usageTrend') }}
|
|
</h3>
|
|
<div class="h-64">
|
|
<Line v-if="trendChartData" :data="trendChartData" :options="lineChartOptions" />
|
|
<div
|
|
v-else
|
|
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
|
>
|
|
{{ t('admin.dashboard.noDataAvailable') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Model Distribution -->
|
|
<ModelDistributionChart :model-stats="stats.models" :loading="false" />
|
|
</template>
|
|
|
|
<!-- No Data State -->
|
|
<div
|
|
v-else-if="!loading"
|
|
class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400"
|
|
>
|
|
<svg class="mb-4 h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="1.5"
|
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
|
/>
|
|
</svg>
|
|
<p class="text-sm">{{ t('admin.accounts.stats.noData') }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end">
|
|
<button
|
|
@click="handleClose"
|
|
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
|
|
>
|
|
{{ t('common.close') }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</Modal>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch, computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import {
|
|
Chart as ChartJS,
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler
|
|
} from 'chart.js'
|
|
import { Line } from 'vue-chartjs'
|
|
import Modal from '@/components/common/Modal.vue'
|
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
|
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
|
import { adminAPI } from '@/api/admin'
|
|
import type { Account, AccountUsageStatsResponse } from '@/types'
|
|
|
|
ChartJS.register(
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler
|
|
)
|
|
|
|
const { t } = useI18n()
|
|
|
|
const props = defineProps<{
|
|
show: boolean
|
|
account: Account | null
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'close'): void
|
|
}>()
|
|
|
|
const loading = ref(false)
|
|
const stats = ref<AccountUsageStatsResponse | null>(null)
|
|
|
|
// Dark mode detection
|
|
const isDarkMode = computed(() => {
|
|
return document.documentElement.classList.contains('dark')
|
|
})
|
|
|
|
// Chart colors
|
|
const chartColors = computed(() => ({
|
|
text: isDarkMode.value ? '#e5e7eb' : '#374151',
|
|
grid: isDarkMode.value ? '#374151' : '#e5e7eb'
|
|
}))
|
|
|
|
// Line chart data
|
|
const trendChartData = computed(() => {
|
|
if (!stats.value?.history?.length) return null
|
|
|
|
return {
|
|
labels: stats.value.history.map((h) => h.label),
|
|
datasets: [
|
|
{
|
|
label: t('admin.accounts.stats.cost') + ' (USD)',
|
|
data: stats.value.history.map((h) => h.cost),
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
fill: true,
|
|
tension: 0.3,
|
|
yAxisID: 'y'
|
|
},
|
|
{
|
|
label: t('admin.accounts.stats.requests'),
|
|
data: stats.value.history.map((h) => h.requests),
|
|
borderColor: '#f97316',
|
|
backgroundColor: 'rgba(249, 115, 22, 0.1)',
|
|
fill: false,
|
|
tension: 0.3,
|
|
yAxisID: 'y1'
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
// Line chart options with dual Y-axis
|
|
const lineChartOptions = computed(() => ({
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: {
|
|
intersect: false,
|
|
mode: 'index' as const
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
position: 'top' as const,
|
|
labels: {
|
|
color: chartColors.value.text,
|
|
usePointStyle: true,
|
|
pointStyle: 'circle',
|
|
padding: 15,
|
|
font: {
|
|
size: 11
|
|
}
|
|
}
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (context: any) => {
|
|
const label = context.dataset.label || ''
|
|
const value = context.raw
|
|
if (label.includes('USD')) {
|
|
return `${label}: $${formatCost(value)}`
|
|
}
|
|
return `${label}: ${formatNumber(value)}`
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
grid: {
|
|
color: chartColors.value.grid
|
|
},
|
|
ticks: {
|
|
color: chartColors.value.text,
|
|
font: {
|
|
size: 10
|
|
},
|
|
maxRotation: 45,
|
|
minRotation: 0
|
|
}
|
|
},
|
|
y: {
|
|
type: 'linear' as const,
|
|
display: true,
|
|
position: 'left' as const,
|
|
grid: {
|
|
color: chartColors.value.grid
|
|
},
|
|
ticks: {
|
|
color: '#3b82f6',
|
|
font: {
|
|
size: 10
|
|
},
|
|
callback: (value: string | number) => '$' + formatCost(Number(value))
|
|
},
|
|
title: {
|
|
display: true,
|
|
text: t('admin.accounts.stats.cost') + ' (USD)',
|
|
color: '#3b82f6',
|
|
font: {
|
|
size: 11
|
|
}
|
|
}
|
|
},
|
|
y1: {
|
|
type: 'linear' as const,
|
|
display: true,
|
|
position: 'right' as const,
|
|
grid: {
|
|
drawOnChartArea: false
|
|
},
|
|
ticks: {
|
|
color: '#f97316',
|
|
font: {
|
|
size: 10
|
|
},
|
|
callback: (value: string | number) => formatNumber(Number(value))
|
|
},
|
|
title: {
|
|
display: true,
|
|
text: t('admin.accounts.stats.requests'),
|
|
color: '#f97316',
|
|
font: {
|
|
size: 11
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
|
|
// Load stats when modal opens
|
|
watch(
|
|
() => props.show,
|
|
async (newVal) => {
|
|
if (newVal && props.account) {
|
|
await loadStats()
|
|
} else {
|
|
stats.value = null
|
|
}
|
|
}
|
|
)
|
|
|
|
const loadStats = async () => {
|
|
if (!props.account) return
|
|
|
|
loading.value = true
|
|
try {
|
|
stats.value = await adminAPI.accounts.getStats(props.account.id, 30)
|
|
} catch (error) {
|
|
console.error('Failed to load account stats:', error)
|
|
stats.value = null
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const handleClose = () => {
|
|
emit('close')
|
|
}
|
|
|
|
// Format helpers
|
|
const formatCost = (value: number): string => {
|
|
if (value >= 1000) {
|
|
return (value / 1000).toFixed(2) + 'K'
|
|
} else if (value >= 1) {
|
|
return value.toFixed(2)
|
|
} else if (value >= 0.01) {
|
|
return value.toFixed(3)
|
|
}
|
|
return value.toFixed(4)
|
|
}
|
|
|
|
const formatNumber = (value: number): string => {
|
|
if (value >= 1_000_000) {
|
|
return (value / 1_000_000).toFixed(2) + 'M'
|
|
} else if (value >= 1_000) {
|
|
return (value / 1_000).toFixed(2) + 'K'
|
|
}
|
|
return value.toLocaleString()
|
|
}
|
|
|
|
const formatTokens = (value: number): string => {
|
|
if (value >= 1_000_000_000) {
|
|
return `${(value / 1_000_000_000).toFixed(2)}B`
|
|
} else if (value >= 1_000_000) {
|
|
return `${(value / 1_000_000).toFixed(2)}M`
|
|
} else if (value >= 1_000) {
|
|
return `${(value / 1_000).toFixed(2)}K`
|
|
}
|
|
return value.toLocaleString()
|
|
}
|
|
|
|
const formatDuration = (ms: number): string => {
|
|
if (ms >= 1000) {
|
|
return `${(ms / 1000).toFixed(2)}s`
|
|
}
|
|
return `${Math.round(ms)}ms`
|
|
}
|
|
</script>
|