mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 18:59:50 +08:00
feat: 统计数据优化 - 支持细粒度时间范围和多维度分析
- 新增 StatsHourly/StatsDaily 预聚合表,支持任意时区的精确统计 - 实现 UTC datetime 范围查询策略,边界数据实时聚合 - 新增统计 API:用户/API Key 维度、成本分析、性能百分位、错误分类 - 新增前端页面:成本分析、性能分析、用户统计 - 新增 TimeRangePicker 组件和统计可视化组件 - 优化 Dashboard 和 Usage 页面支持时间范围筛选 Close #135
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user