Files
Aether/frontend/src/features/usage/components/IntervalTimelineCard.vue
fawney19 0e8bf0a23b feat: 请求间隔散点图按模型区分颜色
- 后端 get_interval_timeline 接口返回数据添加 model 字段
- 前端散点图按模型分组显示不同颜色的数据点
- 横线统计信息支持按模型分别显示统计数据
- 管理员视图保持按用户分组,用户视图按模型分组
- 更新 mock 数据支持模型字段
2025-12-11 21:33:39 +08:00

274 lines
8.5 KiB
Vue

<template>
<Card class="p-4">
<div class="flex items-center justify-between mb-3">
<p class="text-sm font-semibold">{{ title }}</p>
<div v-if="legendItems.length > 0" class="flex items-center gap-2 flex-wrap justify-end text-[11px]">
<div
v-for="item in legendItems"
:key="item.id"
class="flex items-center gap-1"
>
<div
class="w-2.5 h-2.5 rounded-full"
:style="{ backgroundColor: item.color }"
/>
<span class="text-muted-foreground">{{ item.name }}</span>
</div>
</div>
</div>
<div v-if="loading" class="h-[160px] flex items-center justify-center">
<div class="text-sm text-muted-foreground">Loading...</div>
</div>
<div v-else-if="hasData" class="h-[160px]">
<ScatterChart :data="chartData" :options="chartOptions" />
</div>
<div v-else class="h-[160px] flex items-center justify-center text-sm text-muted-foreground">
暂无请求间隔数据
</div>
</Card>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import ScatterChart from '@/components/charts/ScatterChart.vue'
import { cacheAnalysisApi, type IntervalTimelineResponse } from '@/api/cache'
import { meApi } from '@/api/me'
import type { ChartData, ChartOptions } from 'chart.js'
const props = withDefaults(defineProps<{
title: string
isAdmin: boolean
hours?: number
}>(), {
hours: 24 // 默认当天
})
const loading = ref(false)
const timelineData = ref<IntervalTimelineResponse | null>(null)
const primaryColor = ref('201, 100, 66') // 默认主题色
// 获取主题色
function getPrimaryColor(): string {
if (typeof window === 'undefined') return '201, 100, 66'
// CSS 变量定义在 body 上,不是 documentElement
const body = document.body
const style = getComputedStyle(body)
const rgb = style.getPropertyValue('--color-primary-rgb').trim()
return rgb || '201, 100, 66'
}
onMounted(() => {
primaryColor.value = getPrimaryColor()
loadData()
})
// 预定义的颜色列表(用于区分不同用户/模型)
const COLORS = [
'rgba(59, 130, 246, 0.7)', // blue
'rgba(236, 72, 153, 0.7)', // pink
'rgba(34, 197, 94, 0.7)', // green
'rgba(249, 115, 22, 0.7)', // orange
'rgba(168, 85, 247, 0.7)', // purple
'rgba(234, 179, 8, 0.7)', // yellow
'rgba(14, 165, 233, 0.7)', // sky
'rgba(239, 68, 68, 0.7)', // red
'rgba(20, 184, 166, 0.7)', // teal
'rgba(99, 102, 241, 0.7)', // indigo
]
const hasData = computed(() =>
timelineData.value && timelineData.value.points && timelineData.value.points.length > 0
)
// 判断是否有多个分组(管理员按用户分组,普通用户按模型分组)
const hasMultipleGroups = computed(() => {
if (props.isAdmin) {
// 管理员视图:按用户分组
return timelineData.value?.users && Object.keys(timelineData.value.users).length > 1
} else {
// 用户视图:按模型分组
return timelineData.value?.models && timelineData.value.models.length > 1
}
})
// 图例项(管理员显示用户,普通用户显示模型)
const legendItems = computed(() => {
if (props.isAdmin && timelineData.value?.users) {
// 管理员视图:显示用户图例
const users = Object.entries(timelineData.value.users)
if (users.length <= 1) return []
return users.map(([userId, username], index) => ({
id: userId,
name: username || userId.slice(0, 8),
color: COLORS[index % COLORS.length]
}))
} else if (timelineData.value?.models && timelineData.value.models.length > 1) {
// 用户视图:显示模型图例
return timelineData.value.models.map((model, index) => ({
id: model,
name: formatModelName(model),
color: COLORS[index % COLORS.length]
}))
}
return []
})
// 格式化模型名称,使其更简洁
function formatModelName(model: string): string {
// 常见的简化规则
if (model.includes('claude')) {
// claude-3-5-sonnet-20241022 -> Claude 3.5 Sonnet
const match = model.match(/claude-(\d+)-(\d+)?-?(\w+)?/i)
if (match) {
const major = match[1]
const minor = match[2]
const variant = match[3]
let name = `Claude ${major}`
if (minor) name += `.${minor}`
if (variant) name += ` ${variant.charAt(0).toUpperCase() + variant.slice(1)}`
return name
}
}
// 其他模型保持原样但截断
return model.length > 20 ? model.slice(0, 17) + '...' : model
}
// 构建图表数据
const chartData = computed<ChartData<'scatter'>>(() => {
if (!timelineData.value?.points) {
return { datasets: [] }
}
const points = timelineData.value.points
// 管理员视图且有多个用户:按用户分组
if (props.isAdmin && timelineData.value.users && Object.keys(timelineData.value.users).length > 1) {
const userIds = Object.keys(timelineData.value.users)
const userColorMap: Record<string, string> = {}
userIds.forEach((userId, index) => {
userColorMap[userId] = COLORS[index % COLORS.length]
})
// 按用户分组数据
const groupedData: Record<string, Array<{ x: string; y: number }>> = {}
for (const point of points) {
const userId = point.user_id || 'unknown'
if (!groupedData[userId]) {
groupedData[userId] = []
}
groupedData[userId].push({ x: point.x, y: point.y })
}
// 创建每个用户的 dataset
const datasets = Object.entries(groupedData).map(([userId, data]) => ({
label: timelineData.value?.users?.[userId] || userId.slice(0, 8),
data,
backgroundColor: userColorMap[userId] || 'rgba(59, 130, 246, 0.6)',
borderColor: userColorMap[userId] || 'rgba(59, 130, 246, 0.8)',
pointRadius: 3,
pointHoverRadius: 5,
}))
return { datasets }
}
// 用户视图且有多个模型:按模型分组
if (!props.isAdmin && timelineData.value.models && timelineData.value.models.length > 1) {
const models = timelineData.value.models
const modelColorMap: Record<string, string> = {}
models.forEach((model, index) => {
modelColorMap[model] = COLORS[index % COLORS.length]
})
// 按模型分组数据
const groupedData: Record<string, Array<{ x: string; y: number }>> = {}
for (const point of points) {
const model = point.model || 'unknown'
if (!groupedData[model]) {
groupedData[model] = []
}
groupedData[model].push({ x: point.x, y: point.y })
}
// 创建每个模型的 dataset
const datasets = Object.entries(groupedData).map(([model, data]) => ({
label: formatModelName(model),
data,
backgroundColor: modelColorMap[model] || 'rgba(59, 130, 246, 0.6)',
borderColor: modelColorMap[model] || 'rgba(59, 130, 246, 0.8)',
pointRadius: 3,
pointHoverRadius: 5,
}))
return { datasets }
}
// 单用户或单模型:使用主题色
return {
datasets: [{
label: '请求间隔',
data: points.map(p => ({ x: p.x, y: p.y })),
backgroundColor: `rgba(${primaryColor.value}, 0.6)`,
borderColor: `rgba(${primaryColor.value}, 0.8)`,
pointRadius: 3,
pointHoverRadius: 5,
}]
}
})
const chartOptions = computed<ChartOptions<'scatter'>>(() => ({
plugins: {
legend: {
display: false // 使用自定义图例
},
tooltip: {
callbacks: {
label: (context) => {
const point = context.raw as { x: string; y: number; _originalY?: number }
const realY = point._originalY ?? point.y
const datasetLabel = context.dataset.label || ''
if (hasMultipleGroups.value) {
return `${datasetLabel}: ${realY.toFixed(1)} 分钟`
}
return `间隔: ${realY.toFixed(1)} 分钟`
}
}
}
}
}))
async function loadData() {
loading.value = true
try {
if (props.isAdmin) {
// 管理员:获取所有用户数据(按比例采样)
timelineData.value = await cacheAnalysisApi.getIntervalTimeline({
hours: props.hours,
include_user_info: true,
limit: 10000,
})
} else {
// 普通用户:获取自己的数据
timelineData.value = await meApi.getIntervalTimeline({
hours: props.hours,
limit: 5000,
})
}
} catch (error) {
console.error('加载请求间隔时间线失败:', error)
timelineData.value = null
} finally {
loading.value = false
}
}
watch(() => props.hours, () => {
loadData()
})
watch(() => props.isAdmin, () => {
loadData()
})
</script>