mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 统计数据优化 - 支持细粒度时间范围和多维度分析
- 新增 StatsHourly/StatsDaily 预聚合表,支持任意时区的精确统计 - 实现 UTC datetime 范围查询策略,边界数据实时聚合 - 新增统计 API:用户/API Key 维度、成本分析、性能百分位、错误分类 - 新增前端页面:成本分析、性能分析、用户统计 - 新增 TimeRangePicker 组件和统计可视化组件 - 优化 Dashboard 和 Usage 页面支持时间范围筛选 Close #135
This commit is contained in:
@@ -340,6 +340,102 @@ export interface AdminApiKeysResponse {
|
||||
skip: number
|
||||
}
|
||||
|
||||
export interface LeaderboardItem {
|
||||
rank: number
|
||||
id: string
|
||||
name: string
|
||||
value: number
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
export interface LeaderboardResponse {
|
||||
items: LeaderboardItem[]
|
||||
total: number
|
||||
metric: string
|
||||
start_date?: string | null
|
||||
end_date?: string | null
|
||||
}
|
||||
|
||||
export interface CostForecastResponse {
|
||||
history: Array<{ date: string; total_cost: number }>
|
||||
forecast: Array<{ date: string; total_cost: number }>
|
||||
slope: number
|
||||
intercept: number
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
export interface CostSavingsResponse {
|
||||
cache_read_tokens: number
|
||||
cache_read_cost: number
|
||||
cache_creation_cost: number
|
||||
estimated_full_cost: number
|
||||
cache_savings: number
|
||||
}
|
||||
|
||||
export interface QuotaUsageProvider {
|
||||
id: string
|
||||
name: string
|
||||
quota_usd: number
|
||||
used_usd: number
|
||||
remaining_usd: number
|
||||
usage_percent: number
|
||||
quota_expires_at?: string | null
|
||||
estimated_exhaust_at?: string | null
|
||||
}
|
||||
|
||||
export interface QuotaUsageResponse {
|
||||
providers: QuotaUsageProvider[]
|
||||
}
|
||||
|
||||
export interface PercentileItem {
|
||||
date: string
|
||||
p50_response_time_ms?: number | null
|
||||
p90_response_time_ms?: number | null
|
||||
p99_response_time_ms?: number | null
|
||||
p50_first_byte_time_ms?: number | null
|
||||
p90_first_byte_time_ms?: number | null
|
||||
p99_first_byte_time_ms?: number | null
|
||||
}
|
||||
|
||||
export interface ErrorDistributionItem {
|
||||
category: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface ErrorTrendItem {
|
||||
date: string
|
||||
total: number
|
||||
categories: Record<string, number>
|
||||
}
|
||||
|
||||
export interface ErrorDistributionResponse {
|
||||
distribution: ErrorDistributionItem[]
|
||||
trend: ErrorTrendItem[]
|
||||
}
|
||||
|
||||
export interface ComparisonMetric {
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
actual_total_cost: number
|
||||
avg_response_time_ms: number
|
||||
error_requests: number
|
||||
}
|
||||
|
||||
export interface ComparisonResponse {
|
||||
current: ComparisonMetric
|
||||
comparison: ComparisonMetric
|
||||
change_percent: Record<string, number | null>
|
||||
current_start: string
|
||||
current_end: string
|
||||
comparison_start: string
|
||||
comparison_end: string
|
||||
}
|
||||
|
||||
|
||||
export interface ApiKeyToggleResponse {
|
||||
id: string // UUID
|
||||
is_active: boolean
|
||||
@@ -617,4 +713,156 @@ export const adminApi = {
|
||||
const response = await apiClient.post<LdapTestResponse>('/api/admin/ldap/test', config)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Stats / Leaderboards
|
||||
async getLeaderboardUsers(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
metric?: 'requests' | 'tokens' | 'cost'
|
||||
order?: 'asc' | 'desc'
|
||||
limit?: number
|
||||
offset?: number
|
||||
provider_name?: string
|
||||
model?: string
|
||||
include_inactive?: boolean
|
||||
exclude_admin?: boolean
|
||||
}): Promise<LeaderboardResponse> {
|
||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/users', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getLeaderboardApiKeys(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
metric?: 'requests' | 'tokens' | 'cost'
|
||||
order?: 'asc' | 'desc'
|
||||
limit?: number
|
||||
offset?: number
|
||||
provider_name?: string
|
||||
model?: string
|
||||
include_inactive?: boolean
|
||||
exclude_admin?: boolean
|
||||
}): Promise<LeaderboardResponse> {
|
||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/api-keys', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getLeaderboardModels(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
metric?: 'requests' | 'tokens' | 'cost'
|
||||
order?: 'asc' | 'desc'
|
||||
limit?: number
|
||||
offset?: number
|
||||
provider_name?: string
|
||||
model?: string
|
||||
}): Promise<LeaderboardResponse> {
|
||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/models', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getCostForecast(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
days?: number
|
||||
forecast_days?: number
|
||||
}): Promise<CostForecastResponse> {
|
||||
const response = await apiClient.get<CostForecastResponse>('/api/admin/stats/cost/forecast', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getCostSavings(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
provider_name?: string
|
||||
model?: string
|
||||
}): Promise<CostSavingsResponse> {
|
||||
const response = await apiClient.get<CostSavingsResponse>('/api/admin/stats/cost/savings', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getQuotaUsage(): Promise<QuotaUsageResponse> {
|
||||
const response = await apiClient.get<QuotaUsageResponse>('/api/admin/stats/providers/quota-usage')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getPercentiles(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
}): Promise<PercentileItem[]> {
|
||||
const response = await apiClient.get<PercentileItem[]>('/api/admin/stats/performance/percentiles', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getErrorDistribution(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
}): Promise<ErrorDistributionResponse> {
|
||||
const response = await apiClient.get<ErrorDistributionResponse>('/api/admin/stats/errors/distribution', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getComparison(params: {
|
||||
current_start: string
|
||||
current_end: string
|
||||
comparison_type?: 'period' | 'year'
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
}): Promise<ComparisonResponse> {
|
||||
const response = await apiClient.get<ComparisonResponse>('/api/admin/stats/comparison', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getTimeSeries(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
user_id?: string
|
||||
model?: string
|
||||
provider_name?: string
|
||||
}): Promise<any[]> {
|
||||
const response = await apiClient.get<any[]>('/api/admin/stats/time-series', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
@@ -250,10 +250,19 @@ export interface DailyStatsResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export interface TimeRangeParams {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
// 获取仪表盘统计数据
|
||||
async getStats(): Promise<DashboardStatsResponse> {
|
||||
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats')
|
||||
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
|
||||
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -279,9 +288,9 @@ export const dashboardApi = {
|
||||
},
|
||||
|
||||
// 获取每日统计数据
|
||||
async getDailyStats(days: number = 7): Promise<DailyStatsResponse> {
|
||||
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
|
||||
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', {
|
||||
params: { days }
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -201,6 +201,9 @@ export const meApi = {
|
||||
async getUsage(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
search?: string // 通用搜索:密钥名、模型名
|
||||
limit?: number
|
||||
offset?: number
|
||||
|
||||
@@ -78,6 +78,10 @@ export interface UsageFilters {
|
||||
model?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
@@ -164,11 +168,16 @@ export const usageApi = {
|
||||
async getAllUsageRecords(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
|
||||
user_id?: string // UUID
|
||||
username?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
api_format?: string // API 格式筛选(如 openai:chat, claude:chat)
|
||||
status?: string // 'stream' | 'standard' | 'error'
|
||||
limit?: number
|
||||
offset?: number
|
||||
|
||||
131
frontend/src/components/common/TimeRangePicker.vue
Normal file
131
frontend/src/components/common/TimeRangePicker.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
31
frontend/src/components/stats/ComparisonCard.vue
Normal file
31
frontend/src/components/stats/ComparisonCard.vue
Normal 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>
|
||||
78
frontend/src/components/stats/CostForecastChart.vue
Normal file
78
frontend/src/components/stats/CostForecastChart.vue
Normal 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>
|
||||
57
frontend/src/components/stats/ErrorDistributionChart.vue
Normal file
57
frontend/src/components/stats/ErrorDistributionChart.vue
Normal 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>
|
||||
95
frontend/src/components/stats/LeaderboardTable.vue
Normal file
95
frontend/src/components/stats/LeaderboardTable.vue
Normal 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>
|
||||
84
frontend/src/components/stats/PercentileChart.vue
Normal file
84
frontend/src/components/stats/PercentileChart.vue
Normal 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>
|
||||
59
frontend/src/components/stats/QuotaProgressCard.vue
Normal file
59
frontend/src/components/stats/QuotaProgressCard.vue
Normal 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>
|
||||
7
frontend/src/components/stats/index.ts
Normal file
7
frontend/src/components/stats/index.ts
Normal 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'
|
||||
@@ -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': []
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 不存在')
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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!)"
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 时间段选项
|
||||
|
||||
@@ -544,6 +544,9 @@ const navigation = computed(() => {
|
||||
items: [
|
||||
{ name: '仪表盘', href: '/admin/dashboard', icon: Home },
|
||||
{ name: '健康监控', href: '/admin/health-monitor', icon: Activity },
|
||||
{ name: '用户统计', href: '/admin/user-stats', icon: BarChart3 },
|
||||
{ name: '成本分析', href: '/admin/cost-analysis', icon: Gauge },
|
||||
{ name: '性能分析', href: '/admin/performance-analysis', icon: Activity },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2559,3 +2559,185 @@ mockHandlers['GET /api/admin/usage/cache-affinity/hit-analysis'] = async (config
|
||||
const data = generateCacheHitAnalysisData(hours)
|
||||
return createMockResponse(data)
|
||||
}
|
||||
|
||||
// ========== Admin: Stats / Leaderboard ==========
|
||||
mockHandlers['GET /api/admin/stats/leaderboard/users'] = async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse({
|
||||
items: [
|
||||
{ rank: 1, id: 'user-1', name: 'Demo Admin', value: 1200, requests: 1200, tokens: 240000, cost: 123.4 },
|
||||
{ rank: 2, id: 'user-2', name: 'Demo User', value: 980, requests: 980, tokens: 180000, cost: 98.7 }
|
||||
],
|
||||
total: 2,
|
||||
metric: 'requests',
|
||||
start_date: '2026-02-01',
|
||||
end_date: '2026-02-07'
|
||||
})
|
||||
}
|
||||
|
||||
mockHandlers['GET /api/admin/stats/leaderboard/api-keys'] = async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse({
|
||||
items: [
|
||||
{ rank: 1, id: 'key-1', name: 'Key A', value: 800, requests: 800, tokens: 160000, cost: 76.2 },
|
||||
{ rank: 2, id: 'key-2', name: 'Key B', value: 620, requests: 620, tokens: 120000, cost: 55.1 }
|
||||
],
|
||||
total: 2,
|
||||
metric: 'requests',
|
||||
start_date: '2026-02-01',
|
||||
end_date: '2026-02-07'
|
||||
})
|
||||
}
|
||||
|
||||
mockHandlers['GET /api/admin/stats/leaderboard/models'] = async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse({
|
||||
items: [
|
||||
{ rank: 1, id: 'gpt-4', name: 'gpt-4', value: 500, requests: 500, tokens: 100000, cost: 44.2 },
|
||||
{ rank: 2, id: 'claude-3', name: 'claude-3', value: 420, requests: 420, tokens: 90000, cost: 40.1 }
|
||||
],
|
||||
total: 2,
|
||||
metric: 'requests',
|
||||
start_date: '2026-02-01',
|
||||
end_date: '2026-02-07'
|
||||
})
|
||||
}
|
||||
|
||||
mockHandlers['GET /api/admin/stats/cost/forecast'] = async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse({
|
||||
history: [
|
||||
{ date: '2026-02-01', total_cost: 120 },
|
||||
{ date: '2026-02-02', total_cost: 132 },
|
||||
{ date: '2026-02-03', total_cost: 140 }
|
||||
],
|
||||
forecast: [
|
||||
{ date: '2026-02-04', total_cost: 150 },
|
||||
{ date: '2026-02-05', total_cost: 158 }
|
||||
],
|
||||
slope: 5.2,
|
||||
intercept: 110.5,
|
||||
start_date: '2026-02-01',
|
||||
end_date: '2026-02-03'
|
||||
})
|
||||
}
|
||||
|
||||
mockHandlers['GET /api/admin/stats/cost/savings'] = async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse({
|
||||
cache_read_tokens: 120000,
|
||||
cache_read_cost: 8.2,
|
||||
cache_creation_cost: 3.4,
|
||||
estimated_full_cost: 82.0,
|
||||
cache_savings: 73.8
|
||||
})
|
||||
}
|
||||
|
||||
mockHandlers['GET /api/admin/stats/providers/quota-usage'] = async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse({
|
||||
providers: [
|
||||
{
|
||||
id: 'prov-1',
|
||||
name: 'Provider A',
|
||||
quota_usd: 500,
|
||||
used_usd: 320,
|
||||
remaining_usd: 180,
|
||||
usage_percent: 64,
|
||||
quota_expires_at: null,
|
||||
estimated_exhaust_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString()
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
mockHandlers['GET /api/admin/stats/performance/percentiles'] = async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse([
|
||||
{
|
||||
date: '2026-02-01',
|
||||
p50_response_time_ms: 320,
|
||||
p90_response_time_ms: 560,
|
||||
p99_response_time_ms: 860,
|
||||
p50_first_byte_time_ms: 120,
|
||||
p90_first_byte_time_ms: 210,
|
||||
p99_first_byte_time_ms: 400
|
||||
},
|
||||
{
|
||||
date: '2026-02-02',
|
||||
p50_response_time_ms: 300,
|
||||
p90_response_time_ms: 540,
|
||||
p99_response_time_ms: 820,
|
||||
p50_first_byte_time_ms: 110,
|
||||
p90_first_byte_time_ms: 200,
|
||||
p99_first_byte_time_ms: 380
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
mockHandlers['GET /api/admin/stats/errors/distribution'] = async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse({
|
||||
distribution: [
|
||||
{ category: 'rate_limit', count: 24 },
|
||||
{ category: 'server_error', count: 12 },
|
||||
{ category: 'timeout', count: 6 }
|
||||
],
|
||||
trend: [
|
||||
{ date: '2026-02-01', total: 8, categories: { rate_limit: 5, server_error: 3 } },
|
||||
{ date: '2026-02-02', total: 6, categories: { rate_limit: 4, timeout: 2 } }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
mockHandlers['GET /api/admin/stats/comparison'] = async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse({
|
||||
current: {
|
||||
total_requests: 1200,
|
||||
total_tokens: 320000,
|
||||
total_cost: 180,
|
||||
actual_total_cost: 190,
|
||||
avg_response_time_ms: 350,
|
||||
error_requests: 42
|
||||
},
|
||||
comparison: {
|
||||
total_requests: 900,
|
||||
total_tokens: 260000,
|
||||
total_cost: 150,
|
||||
actual_total_cost: 160,
|
||||
avg_response_time_ms: 370,
|
||||
error_requests: 38
|
||||
},
|
||||
change_percent: {
|
||||
total_requests: 33.3,
|
||||
total_tokens: 23.1,
|
||||
total_cost: 20.0,
|
||||
actual_total_cost: 18.8,
|
||||
avg_response_time_ms: -5.4,
|
||||
error_requests: 10.5
|
||||
},
|
||||
current_start: '2026-02-01',
|
||||
current_end: '2026-02-07',
|
||||
comparison_start: '2026-01-25',
|
||||
comparison_end: '2026-01-31'
|
||||
})
|
||||
}
|
||||
|
||||
mockHandlers['GET /api/admin/stats/time-series'] = async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse([
|
||||
{ date: '2026-02-01', total_requests: 120, input_tokens: 20000, output_tokens: 30000, total_cost: 12.3 },
|
||||
{ date: '2026-02-02', total_requests: 140, input_tokens: 22000, output_tokens: 32000, total_cost: 13.8 }
|
||||
])
|
||||
}
|
||||
|
||||
@@ -161,6 +161,21 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'Usage',
|
||||
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
|
||||
},
|
||||
{
|
||||
path: 'user-stats',
|
||||
name: 'UserStats',
|
||||
component: () => importWithRetry(() => import('@/views/admin/UserStats.vue'))
|
||||
},
|
||||
{
|
||||
path: 'cost-analysis',
|
||||
name: 'CostAnalysis',
|
||||
component: () => importWithRetry(() => import('@/views/admin/CostAnalysis.vue'))
|
||||
},
|
||||
{
|
||||
path: 'performance-analysis',
|
||||
name: 'PerformanceAnalysis',
|
||||
component: () => importWithRetry(() => import('@/views/admin/PerformanceAnalysis.vue'))
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
name: 'SystemSettings',
|
||||
|
||||
@@ -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">
|
||||
<!-- 搜索框 -->
|
||||
|
||||
@@ -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
frontend/src/views/admin/CostAnalysis.vue
Normal file
129
frontend/src/views/admin/CostAnalysis.vue
Normal 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>
|
||||
155
frontend/src/views/admin/PerformanceAnalysis.vue
Normal file
155
frontend/src/views/admin/PerformanceAnalysis.vue
Normal file
@@ -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
frontend/src/views/admin/UserStats.vue
Normal file
239
frontend/src/views/admin/UserStats.vue
Normal 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>
|
||||
@@ -393,6 +393,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 趋势图表筛选 -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
统计周期
|
||||
</h3>
|
||||
<TimeRangePicker v-model="dailyTimeRange" :allow-hourly="true" />
|
||||
</div>
|
||||
|
||||
<!-- 趋势图表区域 -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- 每日使用趋势(折线图)- 普通用户可见 -->
|
||||
@@ -784,6 +792,8 @@
|
||||
import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { dashboardApi, type DashboardStat, type DailyStat, type ProviderSummary } from '@/api/dashboard'
|
||||
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
||||
import type { DateRangeParams } from '@/features/usage/types'
|
||||
import { announcementApi, type Announcement } from '@/api/announcements'
|
||||
import {
|
||||
Card,
|
||||
@@ -798,6 +808,7 @@ import {
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from '@/components/ui'
|
||||
import { TimeRangePicker } from '@/components/common'
|
||||
import BarChart from '@/components/charts/BarChart.vue'
|
||||
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
@@ -972,7 +983,8 @@ const tokenBreakdown = ref<{
|
||||
const activeUsers = ref(0)
|
||||
const dailyStats = ref<DailyStat[]>([])
|
||||
const providerSummary = ref<ProviderSummary[]>([])
|
||||
const selectedDays = ref(7)
|
||||
const dailyTimeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
|
||||
// 统计周期
|
||||
const loadingDaily = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -1317,7 +1329,7 @@ async function loadDashboardData() {
|
||||
async function loadDailyStats() {
|
||||
loadingDaily.value = true
|
||||
try {
|
||||
const response = await dashboardApi.getDailyStats(selectedDays.value)
|
||||
const response = await dashboardApi.getDailyStats(dailyTimeRange.value)
|
||||
dailyStats.value = response.daily_stats
|
||||
providerSummary.value = response.provider_summary || []
|
||||
} catch {
|
||||
@@ -1328,6 +1340,10 @@ async function loadDailyStats() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(dailyTimeRange, async () => {
|
||||
await loadDailyStats()
|
||||
}, { deep: true })
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const today = new Date()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user