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

@@ -37,17 +37,20 @@
</div>
<div class="flex items-center gap-1.5">
<!-- 格式转换按钮 -->
<Button
variant="ghost"
size="icon"
class="h-7 w-7 mr-1"
:class="endpoint.format_acceptance_config?.enabled ? 'text-primary' : ''"
:title="endpoint.format_acceptance_config?.enabled ? '已启用格式转换(点击关闭)' : '启用格式转换'"
:disabled="togglingFormatEndpointId === endpoint.id"
@click="handleToggleFormatConversion(endpoint)"
<span
class="mr-1"
:title="isEndpointFormatConversionDisabled ? formatConversionDisabledTooltip : (endpoint.format_acceptance_config?.enabled ? '已启用格式转换(点击关闭)' : '启用格式转换')"
>
<Shuffle class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
:class="`h-7 w-7 ${endpoint.format_acceptance_config?.enabled ? 'text-primary' : ''} ${isEndpointFormatConversionDisabled ? 'opacity-50' : ''}`"
:disabled="togglingFormatEndpointId === endpoint.id || isEndpointFormatConversionDisabled"
@click="handleToggleFormatConversion(endpoint)"
>
<Shuffle class="w-3.5 h-3.5" />
</Button>
</span>
<!-- 启用/停用 -->
<Button
variant="ghost"
@@ -63,7 +66,7 @@
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive hover:text-destructive"
class="h-7 w-7 hover:text-destructive"
title="删除"
:disabled="deletingEndpointId === endpoint.id"
@click="handleDeleteEndpoint(endpoint)"
@@ -369,55 +372,62 @@
<!-- 添加新端点 -->
<div
v-if="availableFormats.length > 0"
class="rounded-lg border border-dashed p-3"
class="rounded-lg border border-dashed"
>
<div class="flex items-end gap-3">
<div class="w-32 shrink-0 space-y-1">
<Label class="text-xs text-muted-foreground">API 格式</Label>
<Select
v-model="newEndpoint.api_format"
:open="formatSelectOpen"
@update:open="handleFormatSelectOpen"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="选择格式" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="format in availableFormats"
:key="format.value"
:value="format.value"
>
{{ format.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex-1 min-w-0 space-y-1">
<Label class="text-xs text-muted-foreground">Base URL</Label>
<Input
v-model="newEndpoint.base_url"
size="sm"
:placeholder="provider?.website || 'https://api.example.com'"
/>
</div>
<div class="w-36 shrink-0 space-y-1">
<Label class="text-xs text-muted-foreground">自定义路径</Label>
<Input
v-model="newEndpoint.custom_path"
size="sm"
:placeholder="newEndpointDefaultPath || '留空使用默认'"
/>
</div>
<!-- 卡片头部API 格式选择 + 添加按钮 -->
<div class="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b border-dashed">
<Select
v-model="newEndpoint.api_format"
:open="formatSelectOpen"
@update:open="handleFormatSelectOpen"
>
<SelectTrigger class="h-auto w-auto gap-1.5 !border-0 bg-transparent !shadow-none p-0 font-medium rounded-none flex-row-reverse !ring-0 !ring-offset-0 !outline-none [&>svg]:h-4 [&>svg]:w-4 [&>svg]:opacity-70">
<SelectValue placeholder="选择格式..." />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="format in availableFormats"
:key="format.value"
:value="format.value"
>
{{ format.label }}
</SelectItem>
</SelectContent>
</Select>
<Button
size="sm"
class="shrink-0 h-8"
variant="ghost"
size="icon"
class="h-7 w-7 text-primary hover:text-primary"
title="添加"
:disabled="!newEndpoint.api_format || (!newEndpoint.base_url?.trim() && !provider?.website?.trim()) || addingEndpoint"
@click="handleAddEndpoint"
>
{{ addingEndpoint ? '添加中...' : '添加' }}
<Plus class="w-3.5 h-3.5" />
</Button>
</div>
<!-- 卡片内容URL 配置 -->
<div class="p-4">
<div class="flex items-end gap-3">
<div class="flex-1 min-w-0 grid grid-cols-3 gap-3">
<div class="col-span-2 space-y-1.5">
<Label class="text-xs text-muted-foreground">Base URL</Label>
<Input
v-model="newEndpoint.base_url"
size="sm"
:placeholder="provider?.website || 'https://api.example.com'"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs text-muted-foreground">自定义路径</Label>
<Input
v-model="newEndpoint.custom_path"
size="sm"
:placeholder="newEndpointDefaultPath || '留空使用默认'"
/>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
@@ -516,8 +526,26 @@ const props = defineProps<{
modelValue: boolean
provider: ProviderWithEndpointsSummary | null
endpoints?: ProviderEndpoint[]
systemFormatConversionEnabled?: boolean
providerFormatConversionEnabled?: boolean
}>()
// 计算端点级格式转换是否应该被禁用
const isEndpointFormatConversionDisabled = computed(() => {
return props.systemFormatConversionEnabled || props.providerFormatConversionEnabled
})
// 获取禁用提示
const formatConversionDisabledTooltip = computed(() => {
if (props.systemFormatConversionEnabled) {
return '请先关闭系统级开关'
}
if (props.providerFormatConversionEnabled) {
return '请先关闭提供商级开关'
}
return ''
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'endpointCreated': []

View File

@@ -104,7 +104,7 @@
type="button"
variant="ghost"
size="icon"
class="shrink-0 text-destructive hover:text-destructive h-8 w-8"
class="shrink-0 hover:text-destructive h-8 w-8"
@click="removeAlias(index)"
>
<X class="w-4 h-4" />

View File

@@ -55,15 +55,17 @@
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
:title="provider.enable_format_conversion ? '已启用格式转换(点击关闭)' : '启用格式转换'"
:class="provider.enable_format_conversion ? 'text-primary' : ''"
@click="toggleFormatConversion"
>
<Shuffle class="w-4 h-4" />
</Button>
<span :title="systemFormatConversionEnabled ? '请先关闭系统级开关' : (provider.enable_format_conversion ? '已启用格式转换(点击关闭)' : '启用格式转换')">
<Button
variant="ghost"
size="icon"
:class="`${provider.enable_format_conversion ? 'text-primary' : ''} ${systemFormatConversionEnabled ? 'opacity-50' : ''}`"
:disabled="systemFormatConversionEnabled"
@click="toggleFormatConversion"
>
<Shuffle class="w-4 h-4" />
</Button>
</span>
<Button
variant="ghost"
size="icon"
@@ -437,6 +439,8 @@
v-model="endpointDialogOpen"
:provider="provider"
:endpoints="endpoints"
:system-format-conversion-enabled="systemFormatConversionEnabled"
:provider-format-conversion-enabled="provider.enable_format_conversion"
@endpoint-created="handleEndpointChanged"
@endpoint-updated="handleEndpointChanged"
/>
@@ -522,6 +526,7 @@ import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { useCountdownTimer, formatCountdown } from '@/composables/useCountdownTimer'
import { getProvider, getProviderEndpoints, updateProvider } from '@/api/endpoints'
import { adminApi } from '@/api/admin'
import {
KeyFormDialog,
KeyAllowedModelsEditDialog,
@@ -575,6 +580,9 @@ const provider = ref<any>(null)
const endpoints = ref<ProviderEndpointWithKeys[]>([])
const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys
// 系统级格式转换配置
const systemFormatConversionEnabled = ref(false)
// 端点相关状态
const endpointDialogOpen = ref(false)
@@ -1256,13 +1264,29 @@ function getFormatProbeCountdown(key: EndpointAPIKey, format: string): string {
return ''
}
// 加载系统级格式转换配置
async function loadSystemFormatConversionConfig() {
try {
const result = await adminApi.getSystemConfig('enable_format_conversion')
systemFormatConversionEnabled.value = result.value === true
} catch {
// 获取失败时默认为关闭
systemFormatConversionEnabled.value = false
}
}
// 加载 Provider 信息
async function loadProvider() {
if (!props.providerId) return
try {
loading.value = true
provider.value = await getProvider(props.providerId)
// 并行加载 Provider 信息和系统级格式转换配置
const [providerData] = await Promise.all([
getProvider(props.providerId),
loadSystemFormatConversionConfig(),
])
provider.value = providerData
if (!provider.value) {
throw new Error('Provider 不存在')

View File

@@ -92,7 +92,7 @@
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
class="h-8 w-8 hover:text-destructive"
title="删除映射组"
@click="deleteGroup(group)"
>

View File

@@ -123,7 +123,7 @@
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
class="h-8 w-8 hover:text-destructive"
title="删除映射"
@click="deleteGroup(item.group!)"
>

View File

@@ -1,33 +1,11 @@
<template>
<TableCard title="使用记录">
<template #actions>
<!-- 时间筛选 -->
<Select
v-model:open="periodSelectOpen"
:model-value="selectedPeriod"
@update:model-value="$emit('update:selectedPeriod', $event)"
>
<SelectTrigger class="w-24 sm:w-32 h-8 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>
</SelectContent>
</Select>
<!-- 时间范围筛选 -->
<TimeRangePicker
v-model="timeRangeModel"
:show-granularity="false"
/>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
@@ -114,6 +92,29 @@
</SelectContent>
</Select>
<!-- API格式筛选 -->
<Select
v-model:open="filterApiFormatSelectOpen"
:model-value="filterApiFormat"
@update:model-value="$emit('update:filterApiFormat', $event)"
>
<SelectTrigger class="w-24 sm:w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="全部格式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部格式
</SelectItem>
<SelectItem
v-for="format in availableApiFormats"
:key="format.value"
:value="format.value"
>
{{ format.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 状态筛选 -->
<Select
v-model:open="filterStatusSelectOpen"
@@ -127,20 +128,23 @@
<SelectItem value="__all__">
全部状态
</SelectItem>
<SelectItem value="active">
进行中
<SelectItem value="stream">
流式
</SelectItem>
<SelectItem value="standard">
标准
</SelectItem>
<SelectItem value="pending">
等待中
</SelectItem>
<SelectItem value="streaming">
流式传输
传输
</SelectItem>
<SelectItem value="completed">
完成
完成
</SelectItem>
<SelectItem value="failed">
失败
失败
</SelectItem>
</SelectContent>
</Select>
@@ -520,7 +524,8 @@
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import { useDebounceFn, useIntervalFn } from '@vueuse/core'
import {
TableCard,
Badge,
@@ -544,7 +549,8 @@ import { formatTokens, formatCurrency } from '@/utils/format'
import { formatDateTime } from '../composables'
import { useRowClick } from '@/composables/useRowClick'
import { API_FORMAT_LABELS } from '@/api/endpoints/types'
import type { UsageRecord } from '../types'
import type { DateRangeParams, UsageRecord } from '../types'
import { TimeRangePicker } from '@/components/common'
export interface UserOption {
id: string
@@ -557,13 +563,14 @@ const props = defineProps<{
isAdmin: boolean
showActualCost: boolean
loading: boolean
// 时间
selectedPeriod: string
// 时间范围
timeRange: DateRangeParams
// 筛选
filterSearch: string
filterUser: string
filterModel: string
filterProvider: string
filterApiFormat: string
filterStatus: string
availableUsers: UserOption[]
availableModels: string[]
@@ -578,11 +585,12 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
'update:selectedPeriod': [value: string]
'update:timeRange': [value: DateRangeParams]
'update:filterSearch': [value: string]
'update:filterUser': [value: string]
'update:filterModel': [value: string]
'update:filterProvider': [value: string]
'update:filterApiFormat': [value: string]
'update:filterStatus': [value: string]
'update:currentPage': [value: number]
'update:pageSize': [value: number]
@@ -591,16 +599,38 @@ const emit = defineEmits<{
'showDetail': [id: string]
}>()
// 静态常量(放在 defineProps/defineEmits 之后)
const AVAILABLE_API_FORMATS = [
{ value: 'openai:chat', label: 'OpenAI Chat' },
{ value: 'openai:cli', label: 'OpenAI CLI' },
{ value: 'openai:video', label: 'OpenAI Video' },
{ value: 'claude:chat', label: 'Claude Chat' },
{ value: 'claude:cli', label: 'Claude CLI' },
{ value: 'gemini:chat', label: 'Gemini Chat' },
{ value: 'gemini:cli', label: 'Gemini CLI' },
{ value: 'gemini:video', label: 'Gemini Video' },
] as const
// Select 打开状态
const periodSelectOpen = ref(false)
const filterUserSelectOpen = ref(false)
const filterModelSelectOpen = ref(false)
const filterProviderSelectOpen = ref(false)
const filterApiFormatSelectOpen = ref(false)
const filterStatusSelectOpen = ref(false)
// 使用模块级常量
const availableApiFormats = AVAILABLE_API_FORMATS
const timeRangeModel = computed({
get: () => props.timeRange,
set: (value: DateRangeParams) => emit('update:timeRange', value)
})
// 通用搜索(输入防抖)
const localSearch = ref(props.filterSearch)
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
const emitSearchDebounced = useDebounceFn((value: string) => {
emit('update:filterSearch', value)
}, 300)
watch(() => props.filterSearch, (value) => {
if (value !== localSearch.value) {
@@ -609,36 +639,23 @@ watch(() => props.filterSearch, (value) => {
})
watch(localSearch, (value) => {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => {
emit('update:filterSearch', value)
}, 300)
emitSearchDebounced(value)
})
// 动态计时器相关
const now = ref(Date.now())
let timerInterval: ReturnType<typeof setInterval> | null = null
// 检查是否有活跃请求
const hasActiveRecords = computed(() => {
return props.records.some(r => r.status === 'pending' || r.status === 'streaming')
})
// 启动计时器
function startTimer() {
if (timerInterval) return
timerInterval = setInterval(() => {
now.value = Date.now()
}, 100) // 每 100ms 更新一次
}
// 停止计时器
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
}
// 使用 VueUse 的 useIntervalFn 管理计时器(自动清理)
const { pause: stopTimer, resume: startTimer } = useIntervalFn(
() => { now.value = Date.now() },
100,
{ immediate: false }
)
// 计算活跃请求的实时耗时
function getElapsedTime(record: UsageRecord): string {
@@ -679,14 +696,7 @@ function handleRowClick(event: MouseEvent, id: string) {
emit('showDetail', id)
}
// 组件卸载时清理
onUnmounted(() => {
stopTimer()
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
})
// useIntervalFn 和 useDebounceFn 自动处理清理,无需 onUnmounted
// 格式化 API 格式显示名称
function formatApiFormat(format: string): string {

View File

@@ -4,7 +4,16 @@ import type { PeriodValue, DateRangeParams } from '../types'
* 格式化日期为 ISO 格式(不带毫秒,兼容 FastAPI datetime 解析)
*/
function formatDateForApi(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function getTimezoneParams() {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const tz_offset_minutes = -new Date().getTimezoneOffset()
return { timezone, tz_offset_minutes }
}
/**
@@ -38,7 +47,9 @@ export function getDateRangeFromPeriod(period: PeriodValue): DateRangeParams {
return {
start_date: formatDateForApi(startDate),
end_date: formatDateForApi(endDate)
end_date: formatDateForApi(endDate),
preset: period,
...getTimezoneParams()
}
}

View File

@@ -27,6 +27,7 @@ export interface FilterParams {
user_id?: string
model?: string
provider?: string
api_format?: string
status?: string
}
@@ -263,6 +264,9 @@ export function useUsageData(options: UseUsageDataOptions) {
if (filters?.provider) {
params.provider = filters.provider
}
if (filters?.api_format) {
params.api_format = filters.api_format
}
if (filters?.status) {
params.status = filters.status
}

View File

@@ -100,6 +100,10 @@ export interface UsageRecord {
export interface DateRangeParams {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
}
// 时间段选项