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
-7
View File
@@ -20,13 +20,6 @@
<h3 class="text-sm sm:text-base font-semibold">
独立余额 API Keys
</h3>
<p class="text-xs text-muted-foreground mt-0.5">
活跃 {{ activeKeyCount }} · 禁用 {{ inactiveKeyCount }} · 无限 Key {{ unlimitedKeyCount }}
<span
v-if="expiringSoonCount > 0"
class="text-amber-600"
> · 即将到期 {{ expiringSoonCount }}</span>
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<!-- 搜索框 -->
+2 -1
View File
@@ -56,7 +56,7 @@
<h3 class="text-base font-semibold">异步任务</h3>
<div class="flex items-center gap-2">
<!-- 状态筛选 -->
<Select v-model="filterStatus">
<Select v-model:open="statusSelectOpen" v-model="filterStatus">
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="状态" />
</SelectTrigger>
@@ -690,6 +690,7 @@ const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const filterStatus = ref('all')
const statusSelectOpen = ref(false)
const filterModel = ref('')
const showDetail = ref(false)
const selectedTask = ref<AsyncTaskDetail | null>(null)
+129
View File
@@ -0,0 +1,129 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">成本分析</h1>
<p class="text-xs text-muted-foreground">成本趋势、预测与节省统计</p>
</div>
<TimeRangePicker v-model="timeRange" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">缓存节省</div>
<div class="text-lg font-semibold">{{ formatCurrency(costSavings?.cache_savings ?? 0) }}</div>
<div class="text-xs text-muted-foreground">
读取成本 {{ formatCurrency(costSavings?.cache_read_cost ?? 0) }}
</div>
</Card>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">缓存读取 Tokens</div>
<div class="text-lg font-semibold">{{ formatTokens(costSavings?.cache_read_tokens ?? 0) }}</div>
<div class="text-xs text-muted-foreground">
预计全额成本 {{ formatCurrency(costSavings?.estimated_full_cost ?? 0) }}
</div>
</Card>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">缓存创建成本</div>
<div class="text-lg font-semibold">{{ formatCurrency(costSavings?.cache_creation_cost ?? 0) }}</div>
<div class="text-xs text-muted-foreground">基于当前时间范围</div>
</Card>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card class="p-4">
<CostForecastChart
title="成本趋势预测"
:history="forecastHistory"
:forecast="forecastFuture"
:loading="forecastLoading"
/>
</Card>
<QuotaProgressCard
title="月卡消耗进度"
:providers="quotaProviders"
:loading="quotaLoading"
/>
</div>
<UsageProviderTable
:data="providerStats"
:is-admin="true"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import { TimeRangePicker } from '@/components/common'
import { CostForecastChart, QuotaProgressCard } from '@/components/stats'
import { UsageProviderTable } from '@/features/usage/components'
import { adminApi, type CostForecastResponse, type CostSavingsResponse, type QuotaUsageProvider } from '@/api/admin'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
import type { ProviderStatsItem } from '@/features/usage/types'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
const forecast = ref<CostForecastResponse | null>(null)
const costSavings = ref<CostSavingsResponse | null>(null)
const quotaProviders = ref<QuotaUsageProvider[]>([])
const providerStats = ref<ProviderStatsItem[]>([])
const forecastLoading = ref(false)
const quotaLoading = ref(false)
const forecastHistory = computed(() => forecast.value?.history || [])
const forecastFuture = computed(() => forecast.value?.forecast || [])
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes
}
}
async function loadForecast() {
forecastLoading.value = true
try {
forecast.value = await adminApi.getCostForecast(buildTimeRangeParams())
} finally {
forecastLoading.value = false
}
}
async function loadSavings() {
costSavings.value = await adminApi.getCostSavings(buildTimeRangeParams())
}
async function loadQuotaUsage() {
quotaLoading.value = true
try {
const response = await adminApi.getQuotaUsage()
quotaProviders.value = response.providers
} finally {
quotaLoading.value = false
}
}
async function loadProviderStats() {
providerStats.value = await usageApi.getUsageByProvider({
...buildTimeRangeParams(),
limit: 8
})
}
async function loadAll() {
await Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()])
}
watch(timeRange, loadAll, { deep: true })
onMounted(loadAll)
</script>
@@ -0,0 +1,155 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">性能分析</h1>
<p class="text-xs text-muted-foreground">延迟分布与错误统计</p>
</div>
<TimeRangePicker v-model="timeRange" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card class="p-4">
<PercentileChart
title="响应延迟百分位"
:series="percentiles"
mode="response"
:loading="percentileLoading"
/>
</Card>
<Card class="p-4">
<PercentileChart
title="首字节延迟百分位"
:series="percentiles"
mode="ttfb"
:loading="percentileLoading"
/>
</Card>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card class="p-4">
<ErrorDistributionChart
title="错误分布"
:distribution="errorDistribution"
:loading="errorLoading"
/>
</Card>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">错误趋势</h3>
<div v-if="errorLoading" class="p-6">
<LoadingState />
</div>
<div v-else class="h-[260px]">
<LineChart :data="errorTrendChartData" />
</div>
</Card>
</div>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">提供商健康度</h3>
<div v-if="providerLoading" class="p-4">
<LoadingState />
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-sm">
<div
v-for="provider in providerStatus"
:key="provider.name"
class="p-3 border rounded-lg"
>
<div class="flex items-center justify-between">
<span class="font-medium">{{ provider.name }}</span>
<span class="text-xs text-muted-foreground">{{ provider.requests }} 请求</span>
</div>
<div class="text-xs text-muted-foreground mt-1">
状态: {{ provider.status }}
</div>
</div>
</div>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import { LoadingState, TimeRangePicker } from '@/components/common'
import { ErrorDistributionChart, PercentileChart } from '@/components/stats'
import LineChart from '@/components/charts/LineChart.vue'
import { adminApi, type ErrorDistributionResponse, type PercentileItem } from '@/api/admin'
import { dashboardApi, type ProviderStatus } from '@/api/dashboard'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
const percentiles = ref<PercentileItem[]>([])
const percentileLoading = ref(false)
const errorDistribution = ref<ErrorDistributionResponse['distribution']>([])
const errorTrend = ref<ErrorDistributionResponse['trend']>([])
const errorLoading = ref(false)
const providerStatus = ref<ProviderStatus[]>([])
const providerLoading = ref(false)
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes
}
}
async function loadPercentiles() {
percentileLoading.value = true
try {
percentiles.value = await adminApi.getPercentiles(buildTimeRangeParams())
} finally {
percentileLoading.value = false
}
}
async function loadErrors() {
errorLoading.value = true
try {
const response = await adminApi.getErrorDistribution(buildTimeRangeParams())
errorDistribution.value = response.distribution
errorTrend.value = response.trend
} finally {
errorLoading.value = false
}
}
async function loadProviders() {
providerLoading.value = true
try {
providerStatus.value = await dashboardApi.getProviderStatus()
} finally {
providerLoading.value = false
}
}
const errorTrendChartData = computed(() => ({
labels: errorTrend.value.map(item => item.date),
datasets: [
{
label: '错误数',
data: errorTrend.value.map(item => item.total),
borderColor: 'rgb(239, 68, 68)',
tension: 0.25,
pointRadius: 2
}
]
}))
async function loadAll() {
await Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
}
watch(timeRange, loadAll, { deep: true })
onMounted(loadAll)
</script>
+239
View File
@@ -0,0 +1,239 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">用户统计</h1>
<p class="text-xs text-muted-foreground">查看用户排行榜与使用趋势</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<TimeRangePicker v-model="timeRange" :allow-hourly="true" />
<Select v-model:open="userSelectOpen" v-model="selectedUserId">
<SelectTrigger class="h-8 text-xs w-52">
<SelectValue placeholder="选择用户" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
<Select v-model:open="compareUserSelectOpen" v-model="compareUserId">
<SelectTrigger class="h-8 text-xs w-52">
<SelectValue placeholder="对比用户(可选)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不对比</SelectItem>
<SelectItem
v-for="user in users"
:key="`compare-${user.id}`"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<LeaderboardTable
title="用户排行榜"
:items="leaderboard"
:metric="metric"
:loading="leaderboardLoading"
@update:metric="metric = $event"
/>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">用户摘要</h3>
<div v-if="summaryLoading" class="p-6">
<LoadingState />
</div>
<div v-else class="grid grid-cols-2 gap-3 text-sm">
<div>
<div class="text-xs text-muted-foreground">请求数</div>
<div class="font-semibold">{{ userSummary?.total_requests ?? 0 }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">Tokens</div>
<div class="font-semibold">{{ formatTokens(userSummary?.total_tokens ?? 0) }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">成本</div>
<div class="font-semibold">{{ formatCurrency(userSummary?.total_cost ?? 0) }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">错误率</div>
<div class="font-semibold">{{ userSummary?.error_rate ?? 0 }}%</div>
</div>
</div>
</Card>
</div>
<Card class="p-4 space-y-4">
<h3 class="text-sm font-semibold">用户使用趋势</h3>
<div v-if="seriesLoading" class="p-6">
<LoadingState />
</div>
<div v-else class="h-[280px]">
<LineChart :data="seriesChartData" />
</div>
</Card>
<Card v-if="comparisonSeries.length > 0" class="p-4 space-y-4">
<h3 class="text-sm font-semibold">用户对比趋势</h3>
<div class="h-[280px]">
<LineChart :data="comparisonChartData" />
</div>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { Card, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState, TimeRangePicker } from '@/components/common'
import { LeaderboardTable } from '@/components/stats'
import { adminApi, type LeaderboardItem } from '@/api/admin'
import { usersApi, type User } from '@/api/users'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
const metric = ref<'requests' | 'tokens' | 'cost'>('requests')
const users = ref<User[]>([])
const selectedUserId = ref<string | null>(null)
const compareUserId = ref<string>('__none__')
const userSelectOpen = ref(false)
const compareUserSelectOpen = ref(false)
const leaderboard = ref<LeaderboardItem[]>([])
const leaderboardLoading = ref(false)
const userSummary = ref<any | null>(null)
const summaryLoading = ref(false)
const series = ref<any[]>([])
const comparisonSeries = ref<any[]>([])
const seriesLoading = ref(false)
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes,
granularity: timeRange.value.granularity || 'day'
}
}
async function loadUsers() {
users.value = await usersApi.getAllUsers()
if (!selectedUserId.value && users.value.length > 0) {
selectedUserId.value = users.value[0].id
}
}
async function loadLeaderboard() {
leaderboardLoading.value = true
try {
const response = await adminApi.getLeaderboardUsers({
...buildTimeRangeParams(),
metric: metric.value,
limit: 10
})
leaderboard.value = response.items
} finally {
leaderboardLoading.value = false
}
}
async function loadSummary() {
if (!selectedUserId.value) return
summaryLoading.value = true
try {
userSummary.value = await usageApi.getUsageStats({
...buildTimeRangeParams(),
user_id: selectedUserId.value
})
} finally {
summaryLoading.value = false
}
}
async function loadSeries() {
if (!selectedUserId.value) return
seriesLoading.value = true
try {
series.value = await adminApi.getTimeSeries({
...buildTimeRangeParams(),
user_id: selectedUserId.value
})
comparisonSeries.value = []
if (compareUserId.value && compareUserId.value !== '__none__') {
comparisonSeries.value = await adminApi.getTimeSeries({
...buildTimeRangeParams(),
user_id: compareUserId.value
})
}
} finally {
seriesLoading.value = false
}
}
const seriesChartData = computed(() => ({
labels: series.value.map(item => item.date),
datasets: [
{
label: '成本',
data: series.value.map(item => item.total_cost),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
}
]
}))
const comparisonChartData = computed(() => ({
labels: series.value.map(item => item.date),
datasets: [
{
label: '当前用户',
data: series.value.map(item => item.total_cost),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
},
{
label: '对比用户',
data: comparisonSeries.value.map(item => item.total_cost),
borderColor: 'rgb(234, 179, 8)',
tension: 0.25,
pointRadius: 2
}
]
}))
watch([timeRange, metric], loadLeaderboard, { deep: true })
watch([timeRange, selectedUserId, compareUserId], () => {
loadSummary()
loadSeries()
}, { deep: true })
onMounted(async () => {
await loadUsers()
await loadLeaderboard()
await loadSummary()
await loadSeries()
})
</script>