diff --git a/frontend/src/api/cache.ts b/frontend/src/api/cache.ts index c8d3b59..6960fe6 100644 --- a/frontend/src/api/cache.ts +++ b/frontend/src/api/cache.ts @@ -220,6 +220,7 @@ export interface IntervalTimelinePoint { x: string // ISO 时间字符串 y: number // 间隔分钟数 user_id?: string // 用户 ID(仅 include_user_info=true 时存在) + model?: string // 模型名称 } export interface IntervalTimelineResponse { @@ -227,6 +228,7 @@ export interface IntervalTimelineResponse { total_points: number points: IntervalTimelinePoint[] users?: Record // user_id -> username 映射(仅 include_user_info=true 时存在) + models?: string[] // 出现的模型列表 } export const cacheAnalysisApi = { diff --git a/frontend/src/api/me.ts b/frontend/src/api/me.ts index 7d2b5c3..bbb6e93 100644 --- a/frontend/src/api/me.ts +++ b/frontend/src/api/me.ts @@ -262,7 +262,8 @@ export const meApi = { }): Promise<{ analysis_period_hours: number total_points: number - points: Array<{ x: string; y: number }> + points: Array<{ x: string; y: number; model?: string }> + models?: string[] }> { const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params }) return response.data diff --git a/frontend/src/components/charts/ScatterChart.vue b/frontend/src/components/charts/ScatterChart.vue index 24e43e4..3ecd639 100644 --- a/frontend/src/components/charts/ScatterChart.vue +++ b/frontend/src/components/charts/ScatterChart.vue @@ -6,9 +6,32 @@ class="absolute top-2 right-2 bg-gray-800/90 text-gray-100 px-3 py-2 rounded-lg text-sm shadow-lg border border-gray-600" >
Y = {{ crosshairStats.yValue.toFixed(1) }} 分钟
-
- {{ crosshairStats.belowCount }} / {{ crosshairStats.totalCount }} 点在横线以下 - ({{ crosshairStats.belowPercent.toFixed(1) }}%) + +
+ {{ crosshairStats.datasets[0].belowCount }} / {{ crosshairStats.datasets[0].totalCount }} 点在横线以下 + ({{ crosshairStats.datasets[0].belowPercent.toFixed(1) }}%) +
+ +
+
+
+ {{ ds.label }}: + {{ ds.belowCount }}/{{ ds.totalCount }} + ({{ ds.belowPercent.toFixed(0) }}%) +
+ +
+ 总计: + {{ crosshairStats.totalBelowCount }}/{{ crosshairStats.totalCount }} + ({{ crosshairStats.totalBelowPercent.toFixed(1) }}%) +
@@ -48,13 +71,22 @@ interface Props { height?: number } -interface CrosshairStats { - yValue: number +interface DatasetStats { + label: string + color: string belowCount: number totalCount: number belowPercent: number } +interface CrosshairStats { + yValue: number + datasets: DatasetStats[] + totalBelowCount: number + totalCount: number + totalBelowPercent: number +} + const props = withDefaults(defineProps(), { height: 300 }) @@ -67,29 +99,47 @@ const crosshairY = ref(null) const crosshairStats = computed(() => { if (crosshairY.value === null || !props.data.datasets) return null + const datasetStats: DatasetStats[] = [] + let totalBelowCount = 0 let totalCount = 0 - let belowCount = 0 for (const dataset of props.data.datasets) { if (!dataset.data) continue + + let belowCount = 0 + let dsTotal = 0 + for (const point of dataset.data) { const p = point as { x: string; y: number } if (typeof p.y === 'number') { + dsTotal++ totalCount++ if (p.y <= crosshairY.value) { belowCount++ + totalBelowCount++ } } } + + if (dsTotal > 0) { + datasetStats.push({ + label: dataset.label || 'Unknown', + color: (dataset.backgroundColor as string) || 'rgba(59, 130, 246, 0.7)', + belowCount, + totalCount: dsTotal, + belowPercent: (belowCount / dsTotal) * 100 + }) + } } if (totalCount === 0) return null return { yValue: crosshairY.value, - belowCount, + datasets: datasetStats, + totalBelowCount, totalCount, - belowPercent: (belowCount / totalCount) * 100 + totalBelowPercent: (totalBelowCount / totalCount) * 100 } }) diff --git a/frontend/src/features/usage/components/IntervalTimelineCard.vue b/frontend/src/features/usage/components/IntervalTimelineCard.vue index 05b7bab..357dcc0 100644 --- a/frontend/src/features/usage/components/IntervalTimelineCard.vue +++ b/frontend/src/features/usage/components/IntervalTimelineCard.vue @@ -2,18 +2,21 @@

{{ title }}

-
+
- {{ user.name }} + {{ item.name }}
+ + +{{ hiddenLegendCount }} 更多 +
@@ -63,8 +66,8 @@ onMounted(() => { loadData() }) -// 预定义的颜色列表(用于区分不同用户) -const USER_COLORS = [ +// 预定义的颜色列表(用于区分不同用户/模型) +const COLORS = [ 'rgba(59, 130, 246, 0.7)', // blue 'rgba(236, 72, 153, 0.7)', // pink 'rgba(34, 197, 94, 0.7)', // green @@ -81,22 +84,72 @@ const hasData = computed(() => timelineData.value && timelineData.value.points && timelineData.value.points.length > 0 ) -const hasMultipleUsers = computed(() => - props.isAdmin && timelineData.value?.users && Object.keys(timelineData.value.users).length > 1 -) - -// 用户图例 -const userLegend = computed(() => { - if (!props.isAdmin || !timelineData.value?.users) return [] - - const users = Object.entries(timelineData.value.users) - return users.map(([userId, username], index) => ({ - id: userId, - name: username || userId.slice(0, 8), - color: USER_COLORS[index % USER_COLORS.length] - })) +// 判断是否有多个分组(管理员按用户分组,普通用户按模型分组) +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 MAX_LEGEND_ITEMS = 6 + +// 图例项(管理员显示用户,普通用户显示模型) +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 [] +}) + +// 显示的图例项(限制数量) +const displayLegendItems = computed(() => { + return legendItems.value.slice(0, MAX_LEGEND_ITEMS) +}) + +// 隐藏的图例数量 +const hiddenLegendCount = computed(() => { + return Math.max(0, legendItems.value.length - MAX_LEGEND_ITEMS) +}) + +// 格式化模型名称,使其更简洁 +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>(() => { if (!timelineData.value?.points) { @@ -105,12 +158,12 @@ const chartData = computed>(() => { 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 = {} userIds.forEach((userId, index) => { - userColorMap[userId] = USER_COLORS[index % USER_COLORS.length] + userColorMap[userId] = COLORS[index % COLORS.length] }) // 按用户分组数据 @@ -136,7 +189,38 @@ const chartData = computed>(() => { return { datasets } } - // 单用户或用户视图:使用主题色 + // 用户视图且有多个模型:按模型分组 + if (!props.isAdmin && timelineData.value.models && timelineData.value.models.length > 1) { + const models = timelineData.value.models + const modelColorMap: Record = {} + models.forEach((model, index) => { + modelColorMap[model] = COLORS[index % COLORS.length] + }) + + // 按模型分组数据 + const groupedData: Record> = {} + 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: '请求间隔', @@ -160,7 +244,7 @@ const chartOptions = computed>(() => ({ const point = context.raw as { x: string; y: number; _originalY?: number } const realY = point._originalY ?? point.y const datasetLabel = context.dataset.label || '' - if (props.isAdmin && hasMultipleUsers.value) { + if (hasMultipleGroups.value) { return `${datasetLabel}: ${realY.toFixed(1)} 分钟` } return `间隔: ${realY.toFixed(1)} 分钟` diff --git a/frontend/src/mocks/handler.ts b/frontend/src/mocks/handler.ts index 1ac493c..c6f620b 100644 --- a/frontend/src/mocks/handler.ts +++ b/frontend/src/mocks/handler.ts @@ -2166,7 +2166,7 @@ function generateIntervalTimelineData( ) { const now = Date.now() const startTime = now - hours * 60 * 60 * 1000 - const points: Array<{ x: string; y: number; user_id?: string }> = [] + const points: Array<{ x: string; y: number; user_id?: string; model?: string }> = [] // 用户列表(用于管理员视图) const users = [ @@ -2176,6 +2176,14 @@ function generateIntervalTimelineData( { id: 'demo-user-uuid-0004', username: 'Bob Zhang' } ] + // 模型列表(用于按模型区分颜色) + const models = [ + 'claude-sonnet-4-20250514', + 'claude-3-5-sonnet-20241022', + 'claude-3-5-haiku-20241022', + 'claude-opus-4-20250514' + ] + // 生成模拟的请求间隔数据 // 间隔时间分布:大部分在 0-10 分钟,少量在 10-60 分钟,极少数在 60-120 分钟 const pointCount = Math.min(limit, Math.floor(hours * 80)) // 每小时约 80 个数据点 @@ -2211,9 +2219,10 @@ function generateIntervalTimelineData( // 确保间隔不超过 120 分钟 interval = Math.min(interval, 120) - const point: { x: string; y: number; user_id?: string } = { + const point: { x: string; y: number; user_id?: string; model?: string } = { x: new Date(currentTime).toISOString(), - y: Math.round(interval * 100) / 100 + y: Math.round(interval * 100) / 100, + model: models[Math.floor(Math.random() * models.length)] } if (includeUserInfo) { @@ -2231,15 +2240,20 @@ function generateIntervalTimelineData( // 按时间排序 points.sort((a, b) => new Date(a.x).getTime() - new Date(b.x).getTime()) + // 收集出现的模型 + const usedModels = [...new Set(points.map(p => p.model).filter(Boolean))] as string[] + const response: { analysis_period_hours: number total_points: number points: typeof points users?: Record + models?: string[] } = { analysis_period_hours: hours, total_points: points.length, - points + points, + models: usedModels } if (includeUserInfo) { diff --git a/src/services/usage/service.py b/src/services/usage/service.py index 135b83e..6605616 100644 --- a/src/services/usage/service.py +++ b/src/services/usage/service.py @@ -1746,7 +1746,7 @@ class UsageService: include_user_info: 是否包含用户信息(用于管理员多用户视图) Returns: - 包含时间线数据点的字典 + 包含时间线数据点的字典,每个数据点包含 model 字段用于按模型区分颜色 """ from sqlalchemy import text @@ -1764,6 +1764,7 @@ class UsageService: SELECT u.created_at, u.user_id, + u.model, usr.username, LAG(u.created_at) OVER ( PARTITION BY u.user_id @@ -1780,6 +1781,7 @@ class UsageService: SELECT created_at, user_id, + model, username, EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) as rn @@ -1804,6 +1806,7 @@ class UsageService: SELECT fi.created_at, fi.user_id, + fi.model, fi.username, fi.interval_minutes FROM filtered_intervals fi @@ -1812,12 +1815,13 @@ class UsageService: ORDER BY fi.created_at """) else: - # 普通视图:只返回时间和间隔 + # 普通视图:返回时间、间隔和模型信息 sql = text(f""" WITH request_intervals AS ( SELECT u.created_at, u.user_id, + u.model, LAG(u.created_at) OVER ( PARTITION BY u.user_id ORDER BY u.created_at @@ -1830,6 +1834,7 @@ class UsageService: ) SELECT created_at, + model, EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes FROM request_intervals WHERE prev_request_at IS NOT NULL @@ -1848,24 +1853,33 @@ class UsageService: # 转换为时间线数据点 points = [] users_map: Dict[str, str] = {} # user_id -> username + models_set: set = set() # 收集所有出现的模型 if include_user_info and not user_id: for row in rows: - created_at, row_user_id, username, interval_minutes = row - points.append({ + created_at, row_user_id, model, username, interval_minutes = row + point_data: Dict[str, Any] = { "x": created_at.isoformat(), "y": round(float(interval_minutes), 2), "user_id": str(row_user_id), - }) + } + if model: + point_data["model"] = model + models_set.add(model) + points.append(point_data) if row_user_id and username: users_map[str(row_user_id)] = username else: for row in rows: - created_at, interval_minutes = row - points.append({ + created_at, model, interval_minutes = row + point_data = { "x": created_at.isoformat(), "y": round(float(interval_minutes), 2) - }) + } + if model: + point_data["model"] = model + models_set.add(model) + points.append(point_data) response: Dict[str, Any] = { "analysis_period_hours": hours, @@ -1876,4 +1890,8 @@ class UsageService: if include_user_info and not user_id: response["users"] = users_map + # 如果有模型信息,返回模型列表 + if models_set: + response["models"] = sorted(models_set) + return response