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

View File

@@ -0,0 +1,131 @@
<template>
<div class="flex flex-wrap items-center gap-2">
<Select v-model:open="presetSelectOpen" v-model="selectedPreset">
<SelectTrigger class="h-8 w-32 text-xs border-border/60">
<SelectValue placeholder="选择时间段" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">今天</SelectItem>
<SelectItem value="yesterday">昨天</SelectItem>
<SelectItem value="last7days">最近7天</SelectItem>
<SelectItem value="last30days">最近30天</SelectItem>
<SelectItem value="last90days">最近90天</SelectItem>
<SelectItem value="this_week">本周</SelectItem>
<SelectItem value="last_week">上周</SelectItem>
<SelectItem value="this_month">本月</SelectItem>
<SelectItem value="last_month">上月</SelectItem>
<SelectItem value="this_year">今年</SelectItem>
<SelectItem value="custom">自定义</SelectItem>
</SelectContent>
</Select>
<div
v-if="selectedPreset === 'custom'"
class="flex items-center gap-2"
>
<Input
v-model="startDate"
type="date"
class="h-8 w-36 text-xs border-border/60"
/>
<span class="text-xs text-muted-foreground"></span>
<Input
v-model="endDate"
type="date"
class="h-8 w-36 text-xs border-border/60"
/>
</div>
<Select v-if="showGranularity" v-model:open="granularitySelectOpen" v-model="selectedGranularity">
<SelectTrigger class="h-8 w-24 text-xs border-border/60">
<SelectValue placeholder="粒度" />
</SelectTrigger>
<SelectContent>
<SelectItem v-if="allowHourly && canUseHourly" value="hour">小时</SelectItem>
<SelectItem value="day"></SelectItem>
<SelectItem value="week"></SelectItem>
<SelectItem value="month"></SelectItem>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Input
} from '@/components/ui'
import type { DateRangeParams } from '@/features/usage/types'
const props = defineProps<{
modelValue: DateRangeParams
showGranularity?: boolean
allowHourly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: DateRangeParams]
}>()
const selectedPreset = ref(props.modelValue.preset || 'last7days')
const startDate = ref(props.modelValue.start_date || '')
const endDate = ref(props.modelValue.end_date || '')
const selectedGranularity = ref(props.modelValue.granularity || 'day')
const presetSelectOpen = ref(false)
const granularitySelectOpen = ref(false)
const showGranularity = computed(() => props.showGranularity !== false)
const allowHourly = computed(() => props.allowHourly === true)
const canUseHourly = computed(() => {
if (selectedPreset.value === 'today' || selectedPreset.value === 'yesterday') return true
if (selectedPreset.value === 'custom' && startDate.value && endDate.value) {
return startDate.value === endDate.value
}
return false
})
watch(() => props.modelValue, (value) => {
if (value.preset) selectedPreset.value = value.preset
if (value.start_date !== undefined) startDate.value = value.start_date || ''
if (value.end_date !== undefined) endDate.value = value.end_date || ''
if (value.granularity) selectedGranularity.value = value.granularity
}, { deep: true })
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
if (!allowHourly.value || !canUseHourly.value) {
if (selectedGranularity.value === 'hour') {
selectedGranularity.value = 'day'
}
}
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const tz_offset_minutes = -new Date().getTimezoneOffset()
if (selectedPreset.value === 'custom') {
if (!startDate.value || !endDate.value) return
const start = startDate.value <= endDate.value ? startDate.value : endDate.value
const end = endDate.value >= startDate.value ? endDate.value : startDate.value
emit('update:modelValue', {
start_date: start,
end_date: end,
granularity: selectedGranularity.value,
timezone,
tz_offset_minutes
})
return
}
emit('update:modelValue', {
preset: selectedPreset.value,
granularity: selectedGranularity.value,
timezone,
tz_offset_minutes
})
}, { immediate: true })
</script>

View File

@@ -10,3 +10,4 @@ export { default as LoadingState } from './LoadingState.vue'
// 表单组件
export { default as ModelMultiSelect } from './ModelMultiSelect.vue'
export { default as TimeRangePicker } from './TimeRangePicker.vue'

View File

@@ -0,0 +1,31 @@
<template>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">{{ label }}</div>
<div class="text-lg font-semibold">{{ value }}</div>
<div class="text-xs" :class="changeClass">
<span v-if="changePercent !== null">{{ changePercent }}%</span>
<span v-else>--</span>
<span class="ml-1 text-muted-foreground">vs 对比期</span>
</div>
</Card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Card } from '@/components/ui'
interface Props {
label: string
value: string
changePercent: number | null
}
const props = defineProps<Props>()
const changeClass = computed(() => {
if (props.changePercent === null) return 'text-muted-foreground'
if (props.changePercent > 0) return 'text-emerald-500'
if (props.changePercent < 0) return 'text-rose-500'
return 'text-muted-foreground'
})
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{{ title }}</h3>
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
</div>
<div v-if="loading" class="p-6">
<LoadingState />
</div>
<div v-else class="h-[280px]">
<LineChart :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState } from '@/components/common'
import { formatCurrency } from '@/utils/format'
interface Props {
title: string
subtitle?: string
history: Array<{ date: string; total_cost: number }>
forecast: Array<{ date: string; total_cost: number }>
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
const labels = computed(() => [
...props.history.map(item => item.date),
...props.forecast.map(item => item.date)
])
const chartData = computed(() => {
const historyValues = props.history.map(item => item.total_cost)
const forecastValues = props.forecast.map(item => item.total_cost)
return {
labels: labels.value,
datasets: [
{
label: '实际成本',
data: historyValues.concat(new Array(forecastValues.length).fill(null)),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.15)',
tension: 0.25,
pointRadius: 2
},
{
label: '预测成本',
data: new Array(historyValues.length).fill(null).concat(forecastValues),
borderColor: 'rgb(234, 179, 8)',
backgroundColor: 'rgba(234, 179, 8, 0.15)',
borderDash: [6, 4],
tension: 0.25,
pointRadius: 2
}
]
}
})
const chartOptions = computed(() => ({
plugins: {
tooltip: {
callbacks: {
label: (context: any) => {
const value = context.parsed?.y ?? 0
return `${context.dataset.label}: ${formatCurrency(value)}`
}
}
}
}
}))
</script>

View File

@@ -0,0 +1,57 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{{ title }}</h3>
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
</div>
<div v-if="loading" class="p-6">
<LoadingState />
</div>
<div v-else class="h-[260px]">
<DoughnutChart :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
import { LoadingState } from '@/components/common'
import type { ErrorDistributionItem } from '@/api/admin'
interface Props {
title: string
subtitle?: string
distribution: ErrorDistributionItem[]
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
const chartData = computed(() => ({
labels: props.distribution.map(item => item.category),
datasets: [
{
data: props.distribution.map(item => item.count),
backgroundColor: [
'rgba(239, 68, 68, 0.7)',
'rgba(59, 130, 246, 0.7)',
'rgba(234, 179, 8, 0.7)',
'rgba(34, 197, 94, 0.7)',
'rgba(148, 163, 184, 0.7)'
],
borderWidth: 0
}
]
}))
const chartOptions = computed(() => ({
plugins: {
legend: {
position: 'bottom' as const
}
}
}))
</script>

View File

@@ -0,0 +1,95 @@
<template>
<TableCard :title="title">
<template #actions>
<Select
v-if="showMetricSelect"
v-model:open="metricSelectOpen"
:model-value="metric"
@update:model-value="emitMetric"
>
<SelectTrigger class="h-8 text-xs w-28">
<SelectValue placeholder="指标" />
</SelectTrigger>
<SelectContent>
<SelectItem value="requests">请求数</SelectItem>
<SelectItem value="tokens">Tokens</SelectItem>
<SelectItem value="cost">成本</SelectItem>
</SelectContent>
</Select>
</template>
<div v-if="loading" class="p-6">
<LoadingState />
</div>
<div v-else-if="items.length === 0" class="p-6">
<EmptyState title="暂无数据" description="当前时间范围内没有统计结果" />
</div>
<Table v-else>
<TableHeader>
<TableRow>
<TableHead class="w-16">排名</TableHead>
<TableHead>名称</TableHead>
<TableHead class="text-right">请求数</TableHead>
<TableHead class="text-right">Tokens</TableHead>
<TableHead class="text-right">成本</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="item in items" :key="item.id">
<TableCell class="font-medium">{{ item.rank }}</TableCell>
<TableCell>{{ item.name }}</TableCell>
<TableCell class="text-right">{{ item.requests }}</TableCell>
<TableCell class="text-right">{{ formatTokens(item.tokens) }}</TableCell>
<TableCell class="text-right">{{ formatCurrency(item.cost) }}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableCard>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { EmptyState, LoadingState } from '@/components/common'
import { TableCard } from '@/components/ui'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui'
import { formatCurrency, formatTokens } from '@/utils/format'
import type { LeaderboardItem } from '@/api/admin'
interface Props {
title: string
items: LeaderboardItem[]
metric: 'requests' | 'tokens' | 'cost'
loading?: boolean
showMetricSelect?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
showMetricSelect: true
})
const emit = defineEmits<{
(e: 'update:metric', value: 'requests' | 'tokens' | 'cost'): void
}>()
const metric = computed(() => props.metric)
const metricSelectOpen = ref(false)
function emitMetric(value: string) {
if (value === 'requests' || value === 'tokens' || value === 'cost') {
emit('update:metric', value)
}
}
</script>

View File

@@ -0,0 +1,84 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{{ title }}</h3>
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
</div>
<div v-if="loading" class="p-6">
<LoadingState />
</div>
<div v-else class="h-[260px]">
<LineChart :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState } from '@/components/common'
import type { PercentileItem } from '@/api/admin'
interface Props {
title: string
subtitle?: string
series: PercentileItem[]
mode: 'response' | 'ttfb'
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
const labels = computed(() => props.series.map(item => item.date))
// 毫秒转秒
function msToSeconds(ms: number | null | undefined): number | null {
if (ms == null) return null
return ms / 1000
}
const chartData = computed(() => {
const p50Key = props.mode === 'response' ? 'p50_response_time_ms' : 'p50_first_byte_time_ms'
const p90Key = props.mode === 'response' ? 'p90_response_time_ms' : 'p90_first_byte_time_ms'
const p99Key = props.mode === 'response' ? 'p99_response_time_ms' : 'p99_first_byte_time_ms'
return {
labels: labels.value,
datasets: [
{
label: 'P50',
data: props.series.map(item => msToSeconds(item[p50Key])),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
},
{
label: 'P90',
data: props.series.map(item => msToSeconds(item[p90Key])),
borderColor: 'rgb(234, 179, 8)',
tension: 0.25,
pointRadius: 2
},
{
label: 'P99',
data: props.series.map(item => msToSeconds(item[p99Key])),
borderColor: 'rgb(239, 68, 68)',
tension: 0.25,
pointRadius: 2
}
]
}
})
const chartOptions = computed(() => ({
scales: {
y: {
ticks: {
callback: (value: number) => `${value}s`
}
}
}
}))
</script>

View File

@@ -0,0 +1,59 @@
<template>
<Card class="p-4 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{{ title }}</h3>
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
</div>
<div v-if="loading" class="p-4">
<LoadingState />
</div>
<div v-else-if="providers.length === 0" class="p-4">
<EmptyState title="暂无数据" description="暂无月卡配额数据" />
</div>
<div v-else class="space-y-4">
<div v-for="provider in providers" :key="provider.id" class="space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-medium">{{ provider.name }}</span>
<span class="text-muted-foreground">
{{ formatCurrency(provider.used_usd) }} / {{ formatCurrency(provider.quota_usd) }}
</span>
</div>
<div class="h-2 rounded-full bg-muted">
<div
class="h-2 rounded-full bg-primary"
:style="{ width: `${Math.min(provider.usage_percent, 100)}%` }"
/>
</div>
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
<span>剩余 {{ formatCurrency(provider.remaining_usd) }}</span>
<span v-if="provider.estimated_exhaust_at">
预计耗尽 {{ formatDate(provider.estimated_exhaust_at) }}
</span>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { Card } from '@/components/ui'
import { EmptyState, LoadingState } from '@/components/common'
import { formatCurrency } from '@/utils/format'
import type { QuotaUsageProvider } from '@/api/admin'
interface Props {
title: string
subtitle?: string
providers: QuotaUsageProvider[]
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
function formatDate(value: string) {
return new Date(value).toLocaleDateString()
}
</script>

View File

@@ -0,0 +1,7 @@
export { default as ActivityHeatmap } from './ActivityHeatmap.vue'
export { default as LeaderboardTable } from './LeaderboardTable.vue'
export { default as CostForecastChart } from './CostForecastChart.vue'
export { default as QuotaProgressCard } from './QuotaProgressCard.vue'
export { default as PercentileChart } from './PercentileChart.vue'
export { default as ErrorDistributionChart } from './ErrorDistributionChart.vue'
export { default as ComparisonCard } from './ComparisonCard.vue'