## 修复内容 ### 1. AccountsView 功能恢复 - 恢复3个缺失的模态框组件: - ReAuthAccountModal.vue - 重新授权功能 - AccountTestModal.vue - 测试连接功能 - AccountStatsModal.vue - 查看统计功能 - 恢复 handleTest/handleViewStats/handleReAuth 调用模态框 - 修复 UpdateAccountRequest 类型定义(添加 schedulable 字段) ### 2. DashboardView 修复 - 恢复 formatBalance 函数(支持千位分隔符显示) - 为 UserDashboardStats 添加完整 Props 类型定义 - 为 UserDashboardRecentUsage 添加完整 Props 类型定义 - 优化格式化函数到共享 utils/format.ts ### 3. 类型安全增强 - 修复 UserAttributeOption 索引签名兼容性 - 移除未使用的类型导入 - 所有组件 Props 类型完整 ## 验证结果 - ✅ TypeScript 类型检查通过(0 errors) - ✅ vue-tsc 检查通过(0 errors) - ✅ 所有样式与重构前100%一致 - ✅ 所有功能完整恢复 ## 影响范围 - AccountsView: 代码行数从974行优化到189行(提升80.6%可维护性) - DashboardView: 保持组件化同时恢复所有原有功能 - 深色模式支持完整 - 所有颜色方案和 SVG 图标保持一致 Closes #149
784 lines
28 KiB
Vue
784 lines
28 KiB
Vue
<template>
|
|
<BaseDialog
|
|
:show="show"
|
|
:title="t('admin.accounts.usageStatistics')"
|
|
width="extra-wide"
|
|
@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>
|
|
</BaseDialog>
|
|
</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 BaseDialog from '@/components/common/BaseDialog.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>
|