## 修复内容 ### 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
152 lines
6.7 KiB
Vue
152 lines
6.7 KiB
Vue
<template>
|
|
<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('dashboard.timeRange') }}:</span>
|
|
<DateRangePicker :start-date="startDate" :end-date="endDate" @update:startDate="$emit('update:startDate', $event)" @update:endDate="$emit('update:endDate', $event)" @change="$emit('dateRangeChange', $event)" />
|
|
</div>
|
|
<div class="ml-auto flex items-center gap-2">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dashboard.granularity') }}:</span>
|
|
<div class="w-28">
|
|
<Select :model-value="granularity" :options="[{value:'day', label:t('dashboard.day')}, {value:'hour', label:t('dashboard.hour')}]" @update:model-value="$emit('update:granularity', $event)" @change="$emit('granularityChange')" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts Grid -->
|
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
<!-- Model Distribution Chart -->
|
|
<div class="card relative overflow-hidden p-4">
|
|
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50">
|
|
<LoadingSpinner size="md" />
|
|
</div>
|
|
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('dashboard.modelDistribution') }}</h3>
|
|
<div class="flex items-center gap-6">
|
|
<div class="h-48 w-48">
|
|
<Doughnut v-if="modelData" :data="modelData" :options="doughnutOptions" />
|
|
<div v-else class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('dashboard.noDataAvailable') }}</div>
|
|
</div>
|
|
<div class="max-h-48 flex-1 overflow-y-auto">
|
|
<table class="w-full text-xs">
|
|
<thead>
|
|
<tr class="text-gray-500 dark:text-gray-400">
|
|
<th class="pb-2 text-left">{{ t('dashboard.model') }}</th>
|
|
<th class="pb-2 text-right">{{ t('dashboard.requests') }}</th>
|
|
<th class="pb-2 text-right">{{ t('dashboard.tokens') }}</th>
|
|
<th class="pb-2 text-right">{{ t('dashboard.actual') }}</th>
|
|
<th class="pb-2 text-right">{{ t('dashboard.standard') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="model in models" :key="model.model" class="border-t border-gray-100 dark:border-gray-700">
|
|
<td class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white" :title="model.model">{{ model.model }}</td>
|
|
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatNumber(model.requests) }}</td>
|
|
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatTokens(model.total_tokens) }}</td>
|
|
<td class="py-1.5 text-right text-green-600 dark:text-green-400">${{ formatCost(model.actual_cost) }}</td>
|
|
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">${{ formatCost(model.cost) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Token Usage Trend Chart -->
|
|
<div class="card relative overflow-hidden p-4">
|
|
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50">
|
|
<LoadingSpinner size="md" />
|
|
</div>
|
|
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('dashboard.tokenUsageTrend') }}</h3>
|
|
<div class="h-48">
|
|
<Line v-if="trendData" :data="trendData" :options="lineOptions" />
|
|
<div v-else class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('dashboard.noDataAvailable') }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
|
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
|
import Select from '@/components/common/Select.vue'
|
|
import { Line, Doughnut } from 'vue-chartjs'
|
|
import type { TrendDataPoint, ModelStat } from '@/types'
|
|
import { formatCostFixed as formatCost, formatNumberLocaleString as formatNumber, formatTokensK as formatTokens } from '@/utils/format'
|
|
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Title, Tooltip, Legend, Filler } from 'chart.js'
|
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Title, Tooltip, Legend, Filler)
|
|
|
|
const props = defineProps<{ loading: boolean, startDate: string, endDate: string, granularity: string, trend: TrendDataPoint[], models: ModelStat[] }>()
|
|
defineEmits(['update:startDate', 'update:endDate', 'update:granularity', 'dateRangeChange', 'granularityChange'])
|
|
const { t } = useI18n()
|
|
|
|
const modelData = computed(() => !props.models?.length ? null : {
|
|
labels: props.models.map((m: ModelStat) => m.model),
|
|
datasets: [{
|
|
data: props.models.map((m: ModelStat) => m.total_tokens),
|
|
backgroundColor: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']
|
|
}]
|
|
})
|
|
|
|
const trendData = computed(() => !props.trend?.length ? null : {
|
|
labels: props.trend.map((d: TrendDataPoint) => d.date),
|
|
datasets: [
|
|
{
|
|
label: t('dashboard.input'),
|
|
data: props.trend.map((d: TrendDataPoint) => d.input_tokens),
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
tension: 0.3,
|
|
fill: true
|
|
},
|
|
{
|
|
label: t('dashboard.output'),
|
|
data: props.trend.map((d: TrendDataPoint) => d.output_tokens),
|
|
borderColor: '#10b981',
|
|
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
|
tension: 0.3,
|
|
fill: true
|
|
}
|
|
]
|
|
})
|
|
|
|
const doughnutOptions = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (context: any) => `${context.label}: ${formatTokens(context.parsed)} tokens`
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const lineOptions = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: true, position: 'top' as const },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (context: any) => `${context.dataset.label}: ${formatTokens(context.parsed.y)} tokens`
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
ticks: {
|
|
callback: (value: any) => formatTokens(value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|