From cc4e28ad1606fdd85515137e83e2cba7f02e696d Mon Sep 17 00:00:00 2001 From: fawney19 Date: Thu, 11 Dec 2025 17:52:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E9=87=8F=E7=BB=9F=E8=AE=A1=E5=92=8C=E6=95=B0=E6=8D=AE=E5=88=86?= =?UTF-8?q?=E6=9E=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/me.ts | 13 ++ .../src/components/stats/ActivityHeatmap.vue | 2 +- frontend/src/composables/useTTLAnalysis.ts | 208 ++++++++++++++++++ .../usage/components/IntervalTimelineCard.vue | 205 +++++++++++++++++ .../src/features/usage/components/index.ts | 1 + frontend/src/utils/format.ts | 17 ++ frontend/src/views/shared/Usage.vue | 20 +- 7 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 frontend/src/composables/useTTLAnalysis.ts create mode 100644 frontend/src/features/usage/components/IntervalTimelineCard.vue diff --git a/frontend/src/api/me.ts b/frontend/src/api/me.ts index 2b2357b..7d2b5c3 100644 --- a/frontend/src/api/me.ts +++ b/frontend/src/api/me.ts @@ -253,5 +253,18 @@ export const meApi = { }> { const response = await apiClient.put('/api/users/me/model-capabilities', data) return response.data + }, + + // 获取请求间隔时间线(用于散点图) + async getIntervalTimeline(params?: { + hours?: number + limit?: number + }): Promise<{ + analysis_period_hours: number + total_points: number + points: Array<{ x: string; y: number }> + }> { + const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params }) + return response.data } } diff --git a/frontend/src/components/stats/ActivityHeatmap.vue b/frontend/src/components/stats/ActivityHeatmap.vue index c63680b..ee1a048 100644 --- a/frontend/src/components/stats/ActivityHeatmap.vue +++ b/frontend/src/components/stats/ActivityHeatmap.vue @@ -188,7 +188,7 @@ const monthMarkers = computed(() => { if (month === lastMonth) { return } - markers[index] = String(month + 1) + markers[index] = `${month + 1}月` lastMonth = month }) diff --git a/frontend/src/composables/useTTLAnalysis.ts b/frontend/src/composables/useTTLAnalysis.ts new file mode 100644 index 0000000..71d8d96 --- /dev/null +++ b/frontend/src/composables/useTTLAnalysis.ts @@ -0,0 +1,208 @@ +/** + * TTL 分析 composable + * 封装缓存亲和性 TTL 分析相关的状态和逻辑 + */ +import { ref, computed, watch } from 'vue' +import { useToast } from '@/composables/useToast' +import { + cacheAnalysisApi, + type TTLAnalysisResponse, + type CacheHitAnalysisResponse, + type IntervalTimelineResponse +} from '@/api/cache' +import type { ChartData } from 'chart.js' + +// 时间范围选项 +export const ANALYSIS_HOURS_OPTIONS = [ + { value: '12', label: '12 小时' }, + { value: '24', label: '24 小时' }, + { value: '72', label: '3 天' }, + { value: '168', label: '7 天' }, + { value: '336', label: '14 天' }, + { value: '720', label: '30 天' } +] as const + +// 间隔颜色配置 +export const INTERVAL_COLORS = { + short: 'rgba(34, 197, 94, 0.6)', // green: 0-5 分钟 + medium: 'rgba(59, 130, 246, 0.6)', // blue: 5-15 分钟 + normal: 'rgba(168, 85, 247, 0.6)', // purple: 15-30 分钟 + long: 'rgba(249, 115, 22, 0.6)', // orange: 30-60 分钟 + veryLong: 'rgba(239, 68, 68, 0.6)' // red: >60 分钟 +} as const + +/** + * 根据间隔时间获取对应的颜色 + */ +export function getIntervalColor(interval: number): string { + if (interval <= 5) return INTERVAL_COLORS.short + if (interval <= 15) return INTERVAL_COLORS.medium + if (interval <= 30) return INTERVAL_COLORS.normal + if (interval <= 60) return INTERVAL_COLORS.long + return INTERVAL_COLORS.veryLong +} + +/** + * 获取 TTL 推荐的 Badge 样式 + */ +export function getTTLBadgeVariant(ttl: number): 'default' | 'secondary' | 'outline' | 'destructive' { + if (ttl <= 5) return 'default' + if (ttl <= 15) return 'secondary' + if (ttl <= 30) return 'outline' + return 'destructive' +} + +/** + * 获取使用频率标签 + */ +export function getFrequencyLabel(ttl: number): string { + if (ttl <= 5) return '高频' + if (ttl <= 15) return '中高频' + if (ttl <= 30) return '中频' + return '低频' +} + +/** + * 获取使用频率样式类名 + */ +export function getFrequencyClass(ttl: number): string { + if (ttl <= 5) return 'text-success font-medium' + if (ttl <= 15) return 'text-blue-500 font-medium' + if (ttl <= 30) return 'text-muted-foreground' + return 'text-destructive' +} + +export function useTTLAnalysis() { + const { error: showError, info: showInfo } = useToast() + + // 状态 + const ttlAnalysis = ref(null) + const hitAnalysis = ref(null) + const ttlAnalysisLoading = ref(false) + const hitAnalysisLoading = ref(false) + const analysisHours = ref('24') + + // 用户散点图展开状态 + const expandedUserId = ref(null) + const userTimelineData = ref(null) + const userTimelineLoading = ref(false) + + // 计算属性:是否正在加载 + const isLoading = computed(() => ttlAnalysisLoading.value || hitAnalysisLoading.value) + + // 获取 TTL 分析数据 + async function fetchTTLAnalysis() { + ttlAnalysisLoading.value = true + try { + const hours = parseInt(analysisHours.value) + const result = await cacheAnalysisApi.analyzeTTL({ hours }) + ttlAnalysis.value = result + + if (result.total_users_analyzed === 0) { + const periodText = hours >= 24 ? `${hours / 24} 天` : `${hours} 小时` + showInfo(`未找到符合条件的数据(最近 ${periodText})`) + } + } catch (error) { + showError('获取 TTL 分析失败') + console.error(error) + } finally { + ttlAnalysisLoading.value = false + } + } + + // 获取缓存命中分析数据 + async function fetchHitAnalysis() { + hitAnalysisLoading.value = true + try { + hitAnalysis.value = await cacheAnalysisApi.analyzeHit({ + hours: parseInt(analysisHours.value) + }) + } catch (error) { + showError('获取缓存命中分析失败') + console.error(error) + } finally { + hitAnalysisLoading.value = false + } + } + + // 获取指定用户的时间线数据 + async function fetchUserTimeline(userId: string) { + userTimelineLoading.value = true + try { + userTimelineData.value = await cacheAnalysisApi.getIntervalTimeline({ + hours: parseInt(analysisHours.value), + limit: 2000, + user_id: userId + }) + } catch (error) { + showError('获取用户时间线数据失败') + console.error(error) + } finally { + userTimelineLoading.value = false + } + } + + // 切换用户行展开状态 + async function toggleUserExpand(userId: string) { + if (expandedUserId.value === userId) { + expandedUserId.value = null + userTimelineData.value = null + } else { + expandedUserId.value = userId + await fetchUserTimeline(userId) + } + } + + // 刷新所有分析数据 + async function refreshAnalysis() { + expandedUserId.value = null + userTimelineData.value = null + await Promise.all([fetchTTLAnalysis(), fetchHitAnalysis()]) + } + + // 用户时间线散点图数据 + const userTimelineChartData = computed>(() => { + if (!userTimelineData.value || userTimelineData.value.points.length === 0) { + return { datasets: [] } + } + + const points = userTimelineData.value.points + + return { + datasets: [{ + label: '请求间隔', + data: points.map(p => ({ x: p.x, y: p.y })), + backgroundColor: points.map(p => getIntervalColor(p.y)), + borderColor: points.map(p => getIntervalColor(p.y).replace('0.6', '1')), + pointRadius: 3, + pointHoverRadius: 5 + }] + } + }) + + // 监听时间范围变化 + watch(analysisHours, () => { + refreshAnalysis() + }) + + return { + // 状态 + ttlAnalysis, + hitAnalysis, + ttlAnalysisLoading, + hitAnalysisLoading, + analysisHours, + expandedUserId, + userTimelineData, + userTimelineLoading, + isLoading, + userTimelineChartData, + + // 方法 + fetchTTLAnalysis, + fetchHitAnalysis, + fetchUserTimeline, + toggleUserExpand, + refreshAnalysis + } +} diff --git a/frontend/src/features/usage/components/IntervalTimelineCard.vue b/frontend/src/features/usage/components/IntervalTimelineCard.vue new file mode 100644 index 0000000..d9a932b --- /dev/null +++ b/frontend/src/features/usage/components/IntervalTimelineCard.vue @@ -0,0 +1,205 @@ + + + diff --git a/frontend/src/features/usage/components/index.ts b/frontend/src/features/usage/components/index.ts index e41b01c..c477f01 100644 --- a/frontend/src/features/usage/components/index.ts +++ b/frontend/src/features/usage/components/index.ts @@ -5,3 +5,4 @@ export { default as UsageRecordsTable } from './UsageRecordsTable.vue' export { default as ActivityHeatmapCard } from './ActivityHeatmapCard.vue' export { default as RequestDetailDrawer } from './RequestDetailDrawer.vue' export { default as HorizontalRequestTimeline } from './HorizontalRequestTimeline.vue' +export { default as IntervalTimelineCard } from './IntervalTimelineCard.vue' diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index 460cc9e..0865876 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -125,4 +125,21 @@ export function formatBillingType(type: string | undefined | null): string { 'free_tier': '免费套餐' } return typeMap[type || ''] || type || '按量付费' +} + +// Format cost with 4 decimal places (for cache analysis) +export function formatCost(cost: number | null | undefined): string { + if (cost === null || cost === undefined) return '-' + return `$${cost.toFixed(4)}` +} + +// Format remaining time from unix timestamp +export function formatRemainingTime(expireAt: number | undefined, currentTime: number): string { + if (!expireAt) return '未知' + const remaining = expireAt - currentTime + if (remaining <= 0) return '已过期' + + const minutes = Math.floor(remaining / 60) + const seconds = Math.floor(remaining % 60) + return `${minutes}分${seconds}秒` } \ No newline at end of file diff --git a/frontend/src/views/shared/Usage.vue b/frontend/src/views/shared/Usage.vue index efa5a80..6a0ba19 100644 --- a/frontend/src/views/shared/Usage.vue +++ b/frontend/src/views/shared/Usage.vue @@ -1,10 +1,17 @@