- 扩展 Icon.vue 组件,新增 60+ 图标路径 - 导航类: arrowRight, arrowLeft, arrowUp, arrowDown, chevronUp, externalLink - 状态类: checkCircle, xCircle, exclamationCircle, exclamationTriangle, infoCircle - 用户类: user, userCircle, userPlus, users - 文档类: document, clipboard, copy, inbox - 操作类: download, upload, filter, sort - 安全类: key, lock, shield - UI类: menu, calendar, home, terminal, gift, creditCard, mail - 数据类: chartBar, trendingUp, database, cube - 其他: bolt, sparkles, cloud, server, sun, moon, book 等 - 重构 56 个 Vue 组件,用 Icon 组件替换内联 SVG - 净减少约 2200 行代码 - 提升代码可维护性和一致性 - 统一图标样式和尺寸管理
561 lines
18 KiB
Vue
561 lines
18 KiB
Vue
<template>
|
|
<AppLayout>
|
|
<div class="space-y-6">
|
|
<!-- Loading State -->
|
|
<div v-if="loading" class="flex items-center justify-center py-12">
|
|
<LoadingSpinner />
|
|
</div>
|
|
|
|
<template v-else-if="stats">
|
|
<!-- Row 1: Core Stats -->
|
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
<!-- Total API Keys -->
|
|
<div class="card p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
|
<Icon name="key" size="md" class="text-blue-600 dark:text-blue-400" :stroke-width="2" />
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.dashboard.apiKeys') }}
|
|
</p>
|
|
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
|
{{ stats.total_api_keys }}
|
|
</p>
|
|
<p class="text-xs text-green-600 dark:text-green-400">
|
|
{{ stats.active_api_keys }} {{ t('common.active') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Service Accounts -->
|
|
<div class="card p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
|
|
<Icon name="server" size="md" class="text-purple-600 dark:text-purple-400" :stroke-width="2" />
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.dashboard.accounts') }}
|
|
</p>
|
|
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
|
{{ stats.total_accounts }}
|
|
</p>
|
|
<p class="text-xs">
|
|
<span class="text-green-600 dark:text-green-400"
|
|
>{{ stats.normal_accounts }} {{ t('common.active') }}</span
|
|
>
|
|
<span v-if="stats.error_accounts > 0" class="ml-1 text-red-500"
|
|
>{{ stats.error_accounts }} {{ t('common.error') }}</span
|
|
>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Today Requests -->
|
|
<div class="card p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
|
|
<Icon name="chart" size="md" class="text-green-600 dark:text-green-400" :stroke-width="2" />
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.dashboard.todayRequests') }}
|
|
</p>
|
|
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
|
{{ stats.today_requests }}
|
|
</p>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
{{ t('common.total') }}: {{ formatNumber(stats.total_requests) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- New Users Today -->
|
|
<div class="card p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
|
<Icon name="userPlus" size="md" class="text-emerald-600 dark:text-emerald-400" :stroke-width="2" />
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.dashboard.users') }}
|
|
</p>
|
|
<p class="text-xl font-bold text-emerald-600 dark:text-emerald-400">
|
|
+{{ stats.today_new_users }}
|
|
</p>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
{{ t('common.total') }}: {{ formatNumber(stats.total_users) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Row 2: Token Stats -->
|
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
<!-- Today Tokens -->
|
|
<div class="card p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30">
|
|
<Icon name="cube" size="md" class="text-amber-600 dark:text-amber-400" :stroke-width="2" />
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.dashboard.todayTokens') }}
|
|
</p>
|
|
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
|
{{ formatTokens(stats.today_tokens) }}
|
|
</p>
|
|
<p class="text-xs">
|
|
<span
|
|
class="text-amber-600 dark:text-amber-400"
|
|
:title="t('admin.dashboard.actual')"
|
|
>${{ formatCost(stats.today_actual_cost) }}</span
|
|
>
|
|
<span
|
|
class="text-gray-400 dark:text-gray-500"
|
|
:title="t('admin.dashboard.standard')"
|
|
>
|
|
/ ${{ formatCost(stats.today_cost) }}</span
|
|
>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Total Tokens -->
|
|
<div class="card p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="rounded-lg bg-indigo-100 p-2 dark:bg-indigo-900/30">
|
|
<Icon name="database" size="md" class="text-indigo-600 dark:text-indigo-400" :stroke-width="2" />
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.dashboard.totalTokens') }}
|
|
</p>
|
|
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
|
{{ formatTokens(stats.total_tokens) }}
|
|
</p>
|
|
<p class="text-xs">
|
|
<span
|
|
class="text-indigo-600 dark:text-indigo-400"
|
|
:title="t('admin.dashboard.actual')"
|
|
>${{ formatCost(stats.total_actual_cost) }}</span
|
|
>
|
|
<span
|
|
class="text-gray-400 dark:text-gray-500"
|
|
:title="t('admin.dashboard.standard')"
|
|
>
|
|
/ ${{ formatCost(stats.total_cost) }}</span
|
|
>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Performance (RPM/TPM) -->
|
|
<div class="card p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="rounded-lg bg-violet-100 p-2 dark:bg-violet-900/30">
|
|
<Icon name="bolt" size="md" class="text-violet-600 dark:text-violet-400" :stroke-width="2" />
|
|
</div>
|
|
<div class="flex-1">
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.dashboard.performance') }}
|
|
</p>
|
|
<div class="flex items-baseline gap-2">
|
|
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
|
{{ formatTokens(stats.rpm) }}
|
|
</p>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">RPM</span>
|
|
</div>
|
|
<div class="flex items-baseline gap-2">
|
|
<p class="text-sm font-semibold text-violet-600 dark:text-violet-400">
|
|
{{ formatTokens(stats.tpm) }}
|
|
</p>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">TPM</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Avg Response Time -->
|
|
<div class="card p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="rounded-lg bg-rose-100 p-2 dark:bg-rose-900/30">
|
|
<Icon name="clock" size="md" class="text-rose-600 dark:text-rose-400" :stroke-width="2" />
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.dashboard.avgResponse') }}
|
|
</p>
|
|
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
|
{{ formatDuration(stats.average_duration_ms) }}
|
|
</p>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
{{ stats.active_users }} {{ t('admin.dashboard.activeUsers') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts Section -->
|
|
<div class="space-y-6">
|
|
<!-- Date Range Filter -->
|
|
<div class="card p-4">
|
|
<div class="flex flex-wrap items-center gap-4">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
>{{ t('admin.dashboard.timeRange') }}:</span
|
|
>
|
|
<DateRangePicker
|
|
v-model:start-date="startDate"
|
|
v-model:end-date="endDate"
|
|
@change="onDateRangeChange"
|
|
/>
|
|
</div>
|
|
<div class="ml-auto flex items-center gap-2">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
>{{ t('admin.dashboard.granularity') }}:</span
|
|
>
|
|
<div class="w-28">
|
|
<Select
|
|
v-model="granularity"
|
|
:options="granularityOptions"
|
|
@change="loadChartData"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts Grid -->
|
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
<ModelDistributionChart :model-stats="modelStats" :loading="chartsLoading" />
|
|
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
|
</div>
|
|
|
|
<!-- User Usage Trend (Full Width) -->
|
|
<div class="card p-4">
|
|
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
|
{{ t('admin.dashboard.recentUsage') }} (Top 12)
|
|
</h3>
|
|
<div class="h-64">
|
|
<Line v-if="userTrendChartData" :data="userTrendChartData" :options="lineOptions" />
|
|
<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>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</AppLayout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useAppStore } from '@/stores/app'
|
|
|
|
const { t } = useI18n()
|
|
import { adminAPI } from '@/api/admin'
|
|
import type { DashboardStats, TrendDataPoint, ModelStat, UserUsageTrendPoint } from '@/types'
|
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
|
import Icon from '@/components/icons/Icon.vue'
|
|
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
|
import Select from '@/components/common/Select.vue'
|
|
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
|
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
|
|
|
import {
|
|
Chart as ChartJS,
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler
|
|
} from 'chart.js'
|
|
import { Line } from 'vue-chartjs'
|
|
|
|
// Register Chart.js components
|
|
ChartJS.register(
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler
|
|
)
|
|
|
|
const appStore = useAppStore()
|
|
const stats = ref<DashboardStats | null>(null)
|
|
const loading = ref(false)
|
|
const chartsLoading = ref(false)
|
|
|
|
// Chart data
|
|
const trendData = ref<TrendDataPoint[]>([])
|
|
const modelStats = ref<ModelStat[]>([])
|
|
const userTrend = ref<UserUsageTrendPoint[]>([])
|
|
|
|
// Helper function to format date in local timezone
|
|
const formatLocalDate = (date: Date): string => {
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
}
|
|
|
|
// Initialize date range immediately
|
|
const now = new Date()
|
|
const weekAgo = new Date(now)
|
|
weekAgo.setDate(weekAgo.getDate() - 6)
|
|
|
|
// Date range
|
|
const granularity = ref<'day' | 'hour'>('day')
|
|
const startDate = ref(formatLocalDate(weekAgo))
|
|
const endDate = ref(formatLocalDate(now))
|
|
|
|
// Granularity options for Select component
|
|
const granularityOptions = computed(() => [
|
|
{ value: 'day', label: t('admin.dashboard.day') },
|
|
{ value: 'hour', label: t('admin.dashboard.hour') }
|
|
])
|
|
|
|
// 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 options (for user trend chart)
|
|
const lineOptions = 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) => {
|
|
return `${context.dataset.label}: ${formatTokens(context.raw)}`
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
grid: {
|
|
color: chartColors.value.grid
|
|
},
|
|
ticks: {
|
|
color: chartColors.value.text,
|
|
font: {
|
|
size: 10
|
|
}
|
|
}
|
|
},
|
|
y: {
|
|
grid: {
|
|
color: chartColors.value.grid
|
|
},
|
|
ticks: {
|
|
color: chartColors.value.text,
|
|
font: {
|
|
size: 10
|
|
},
|
|
callback: (value: string | number) => formatTokens(Number(value))
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
|
|
// User trend chart data
|
|
const userTrendChartData = computed(() => {
|
|
if (!userTrend.value?.length) return null
|
|
|
|
// Extract display name from email (part before @)
|
|
const getDisplayName = (email: string, userId: number): string => {
|
|
if (email && email.includes('@')) {
|
|
return email.split('@')[0]
|
|
}
|
|
return t('admin.redeem.userPrefix', { id: userId })
|
|
}
|
|
|
|
// Group by user
|
|
const userGroups = new Map<string, { name: string; data: Map<string, number> }>()
|
|
const allDates = new Set<string>()
|
|
|
|
userTrend.value.forEach((point) => {
|
|
allDates.add(point.date)
|
|
const key = getDisplayName(point.email, point.user_id)
|
|
if (!userGroups.has(key)) {
|
|
userGroups.set(key, { name: key, data: new Map() })
|
|
}
|
|
userGroups.get(key)!.data.set(point.date, point.tokens)
|
|
})
|
|
|
|
const sortedDates = Array.from(allDates).sort()
|
|
const colors = [
|
|
'#3b82f6',
|
|
'#10b981',
|
|
'#f59e0b',
|
|
'#ef4444',
|
|
'#8b5cf6',
|
|
'#ec4899',
|
|
'#14b8a6',
|
|
'#f97316',
|
|
'#6366f1',
|
|
'#84cc16',
|
|
'#06b6d4',
|
|
'#a855f7'
|
|
]
|
|
|
|
const datasets = Array.from(userGroups.values()).map((group, idx) => ({
|
|
label: group.name,
|
|
data: sortedDates.map((date) => group.data.get(date) || 0),
|
|
borderColor: colors[idx % colors.length],
|
|
backgroundColor: `${colors[idx % colors.length]}20`,
|
|
fill: false,
|
|
tension: 0.3
|
|
}))
|
|
|
|
return {
|
|
labels: sortedDates,
|
|
datasets
|
|
}
|
|
})
|
|
|
|
// Format helpers
|
|
const formatTokens = (value: number | undefined): string => {
|
|
if (value === undefined || value === null) return '0'
|
|
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 formatNumber = (value: number): string => {
|
|
return value.toLocaleString()
|
|
}
|
|
|
|
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 formatDuration = (ms: number): string => {
|
|
if (ms >= 1000) {
|
|
return `${(ms / 1000).toFixed(2)}s`
|
|
}
|
|
return `${Math.round(ms)}ms`
|
|
}
|
|
|
|
// Date range change handler
|
|
const onDateRangeChange = (range: {
|
|
startDate: string
|
|
endDate: string
|
|
preset: string | null
|
|
}) => {
|
|
// Auto-select granularity based on date range
|
|
const start = new Date(range.startDate)
|
|
const end = new Date(range.endDate)
|
|
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
|
|
|
|
// If range is 1 day, use hourly granularity
|
|
if (daysDiff <= 1) {
|
|
granularity.value = 'hour'
|
|
} else {
|
|
granularity.value = 'day'
|
|
}
|
|
|
|
loadChartData()
|
|
}
|
|
|
|
// Load data
|
|
const loadDashboardStats = async () => {
|
|
loading.value = true
|
|
try {
|
|
stats.value = await adminAPI.dashboard.getStats()
|
|
} catch (error) {
|
|
appStore.showError(t('admin.dashboard.failedToLoad'))
|
|
console.error('Error loading dashboard stats:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const loadChartData = async () => {
|
|
chartsLoading.value = true
|
|
try {
|
|
const params = {
|
|
start_date: startDate.value,
|
|
end_date: endDate.value,
|
|
granularity: granularity.value
|
|
}
|
|
|
|
const [trendResponse, modelResponse, userResponse] = await Promise.all([
|
|
adminAPI.dashboard.getUsageTrend(params),
|
|
adminAPI.dashboard.getModelStats({ start_date: startDate.value, end_date: endDate.value }),
|
|
adminAPI.dashboard.getUserUsageTrend({ ...params, limit: 12 })
|
|
])
|
|
|
|
trendData.value = trendResponse.trend || []
|
|
modelStats.value = modelResponse.models || []
|
|
userTrend.value = userResponse.trend || []
|
|
} catch (error) {
|
|
console.error('Error loading chart data:', error)
|
|
} finally {
|
|
chartsLoading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadDashboardStats()
|
|
loadChartData()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
</style>
|