feat: 统计数据优化 - 支持细粒度时间范围和多维度分析

- 新增 StatsHourly/StatsDaily 预聚合表,支持任意时区的精确统计
- 实现 UTC datetime 范围查询策略,边界数据实时聚合
- 新增统计 API:用户/API Key 维度、成本分析、性能百分位、错误分类
- 新增前端页面:成本分析、性能分析、用户统计
- 新增 TimeRangePicker 组件和统计可视化组件
- 优化 Dashboard 和 Usage 页面支持时间范围筛选

Close #135
This commit is contained in:
fawney19
2026-02-04 02:04:54 +08:00
parent 6f363a7703
commit f3e2f84b38
59 changed files with 6392 additions and 500 deletions
+31 -32
View File
@@ -55,11 +55,12 @@
:is-admin="isAdminPage"
:show-actual-cost="authStore.isAdmin"
:loading="isLoadingRecords"
:selected-period="selectedPeriod"
:time-range="timeRange"
:filter-search="filterSearch"
:filter-user="filterUser"
:filter-model="filterModel"
:filter-provider="filterProvider"
:filter-api-format="filterApiFormat"
:filter-status="filterStatus"
:available-users="availableUsers"
:available-models="availableModels"
@@ -69,17 +70,17 @@
:total-records="totalRecords"
:page-size-options="pageSizeOptions"
:auto-refresh="globalAutoRefresh"
@update:selected-period="handlePeriodChange"
@update:time-range="handleTimeRangeChange"
@update:filter-search="handleFilterSearchChange"
@update:filter-user="handleFilterUserChange"
@update:filter-model="handleFilterModelChange"
@update:filter-provider="handleFilterProviderChange"
@update:filter-api-format="handleFilterApiFormatChange"
@update:filter-status="handleFilterStatusChange"
@update:current-page="handlePageChange"
@update:page-size="handlePageSizeChange"
@update:auto-refresh="handleAutoRefreshChange"
@refresh="refreshData"
@export="exportData"
@show-detail="showRequestDetail"
/>
@@ -113,7 +114,7 @@ import {
useUsageData,
getDateRangeFromPeriod
} from '@/features/usage/composables'
import type { PeriodValue, FilterStatusValue } from '@/features/usage/types'
import type { DateRangeParams, FilterStatusValue } from '@/features/usage/types'
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
import { log } from '@/utils/logger'
import type { ActivityHeatmap } from '@/types/activity'
@@ -126,8 +127,8 @@ const authStore = useAuthStore()
// 判断是否是管理员页面
const isAdminPage = computed(() => route.path.startsWith('/admin'))
// 时间段选择
const selectedPeriod = ref<PeriodValue>('today')
// 时间范围选择
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('today'))
// 分页状态
const currentPage = ref(1)
@@ -139,6 +140,7 @@ const filterSearch = ref('')
const filterUser = ref('__all__')
const filterModel = ref('__all__')
const filterProvider = ref('__all__')
const filterApiFormat = ref('__all__')
const filterStatus = ref<FilterStatusValue>('__all__')
// 用户列表(仅管理员页面使用)
@@ -194,6 +196,12 @@ const filteredRecords = computed(() => {
records = records.filter(record => record.provider === filterProvider.value)
}
if (filterApiFormat.value !== '__all__') {
records = records.filter(record =>
record.api_format?.toUpperCase() === filterApiFormat.value.toUpperCase()
)
}
if (filterStatus.value !== '__all__') {
if (filterStatus.value === 'stream') {
records = records.filter(record =>
@@ -386,11 +394,9 @@ const selectedRequestId = ref<string | null>(null)
// 初始化加载
onMounted(async () => {
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
// 并行加载统计数据和热力图(使用 allSettled 避免其中一个失败影响另一个)
const [statsResult, heatmapResult] = await Promise.allSettled([
loadStats(dateRange),
loadStats(timeRange.value),
loadHeatmapData()
])
@@ -418,13 +424,11 @@ onMounted(async () => {
}
})
// 处理时间段变化
async function handlePeriodChange(value: string) {
selectedPeriod.value = value as PeriodValue
currentPage.value = 1 // 重置到第一页
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
await loadStats(dateRange)
// 处理时间范围变化
async function handleTimeRangeChange(value: DateRangeParams) {
timeRange.value = value
currentPage.value = 1 // 重置到第一页
await loadStats(timeRange.value)
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
@@ -448,6 +452,7 @@ function getCurrentFilters() {
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined
}
}
@@ -487,6 +492,15 @@ async function handleFilterProviderChange(value: string) {
}
}
async function handleFilterApiFormatChange(value: string) {
filterApiFormat.value = value
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
}
async function handleFilterStatusChange(value: string) {
filterStatus.value = value as FilterStatusValue
currentPage.value = 1
@@ -498,8 +512,7 @@ async function handleFilterStatusChange(value: string) {
// 刷新数据
async function refreshData() {
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
await loadStats(dateRange)
await loadStats(timeRange.value)
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
}
@@ -510,20 +523,6 @@ function showRequestDetail(id: string) {
detailModalOpen.value = true
}
// 导出数据
async function exportData(format: 'csv' | 'json') {
try {
const blob = await usageApi.exportUsage(format)
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `usage-stats.${format}`
a.click()
window.URL.revokeObjectURL(url)
} catch (error) {
log.error('导出失败:', error)
}
}
</script>
<style scoped>