基于React与Recharts的AI助手使用数据可视化工具开发实践
1. 项目概述:一个帮你“算账”的AI助手使用分析工具
如果你和我一样,日常重度依赖Cursor这类AI编程助手,那你肯定也好奇过:我这个月到底用了多少token?GPT-4和Claude哪个用得多?我的使用习惯有没有规律?更重要的是,这玩意儿到底花了多少钱?每次看到Cursor后台那个简陋的CSV导出按钮,再看看那一堆让人头大的数字,我就想,要是能有个直观的图表看看就好了。这就是我动手做这个“Cursor Usage Visualizer”的初衷——一个完全在浏览器里运行、帮你把枯燥的CSV数据变成清晰图表的可视化工具。
简单来说,它就是一个React应用,你从Cursor官网后台导出你的使用数据CSV文件,然后拖到这个网页里,它就能立刻生成一系列交互式图表,告诉你钱花哪儿了、什么时候用得最猛、哪个模型是你的主力。整个过程数据不上传任何服务器,就在你本地浏览器里处理,安全又私密。对于任何想精细化了解自己AI工具使用情况、优化订阅策略或者单纯想满足好奇心的开发者来说,这玩意儿都挺实用的。
2. 核心设计思路:为什么选择纯前端方案?
2.1 隐私优先的架构决策
这个项目的第一个,也是最重要的设计原则,就是隐私。我们处理的是个人开发者的使用数据,里面包含了日期、模型、token消耗甚至成本信息。把这些数据上传到某个第三方服务器去做分析?想想都觉得不安全,也完全没必要。因此,我选择了纯前端的实现方案。所有数据处理——从CSV文件解析、数据清洗、计算聚合,到最终渲染成图表——全部在用户的浏览器中完成。页面加载后,整个应用逻辑就自包含了,你甚至可以在断网的情况下打开之前加载过的页面查看数据。这种设计彻底消除了数据泄露的风险,也让我作为开发者省去了维护服务器、处理数据合规性等一系列麻烦。
注意:虽然现代浏览器处理能力很强,但如果你导出的CSV文件特别大(比如超过一年的高频使用数据),在低性能设备上可能会遇到解析缓慢或页面卡顿。这是纯前端方案的一个天然权衡。
2.2 技术栈选型背后的考量
为什么是React 19 + TypeScript + Vite + Tailwind CSS + Recharts这套组合拳?这背后是一系列务实的工程考量。
首先,React 19和TypeScript是开发现代、复杂交互界面的黄金标准。TypeScript的强类型系统对于处理从CSV解析出来的、结构可能多变的数据特别有用。它能在我编写数据处理工具函数(比如utils/parser.ts)时提供精准的类型提示和错误检查,避免因为字段名拼写错误或类型不匹配导致的运行时bug。React的组件化模型则完美契合了仪表盘的构建方式,每个图表、每个统计卡片都可以是独立的、可复用的组件。
Vite作为构建工具,其极快的冷启动和热更新速度,在开发这种数据密集型的可视化应用时体验提升巨大。你改一点样式或逻辑,几乎瞬间就能在浏览器里看到反馈,这对保持开发心流至关重要。
Tailwind CSS是我个人近年来最偏爱的样式方案。对于这种工具类应用,我需要快速搭建一个清晰、专业且响应式的界面,但又不想在CSS架构上花费太多精力。Tailwind的实用类(Utility-First)范式让我能在JSX里直接通过类名组合出想要的样式,效率极高,而且最终生成的CSS体积非常小。
图表库的选择上,我对比了ECharts、Chart.js和Recharts。Recharts最终胜出,原因有几个:第一,它专门为React设计,API是声明式的,和React组件的思维模式一致,集成起来非常自然。第二,它的文档清晰,社区活跃,常见的图表类型如折线图、柱状图、饼图、面积图都支持得很好。第三,它的包体积相对合理,且渲染性能足以应对个人使用数据这种量级。虽然像日历热力图(Calendar Heatmap)这种特殊图表Recharts没有原生支持,但我们可以用它的基础组件(比如Rectangle)自己拼出来,灵活性也够。
2.3 数据流与状态管理设计
对于这个规模的应用,引入Redux或Zustand这类状态管理库就有点杀鸡用牛刀了。我采用了最直接的方案:使用React的useState和useContext。
应用的核心状态就是用户上传并解析后的原始数据。我在顶层的App.tsx中,通过useState管理这个核心数据状态(通常是一个包含所有原始记录和聚合后数据的对象)。然后,通过React Context将这个状态和相关的操作方法(如设置数据、清除数据)提供给所有子组件。
这样设计的好处是结构清晰且轻量。Dashboard.tsx、FileUpload.tsx以及各个图表组件都作为这个Context的消费者,它们能直接读取到全局的数据状态,并根据需要从中提取自己需要的那部分数据进行渲染。当用户上传新文件时,FileUpload.tsx组件触发解析,更新全局Context,所有图表就会自动重新渲染,展示新数据。
3. 核心功能模块深度解析
3.1 数据上传与解析:从CSV到结构化数据
这是整个应用的入口,也是最容易出错的环节。Cursor导出的CSV文件格式相对固定,但我们在解析时仍需考虑健壮性。
FileUpload.tsx组件负责提供文件上传的UI。它通常是一个拖放区域加一个文件选择按钮。这里的关键是使用HTML5的File API来读取文件内容。用户选择文件后,我们通过FileReader对象以文本形式读取文件内容。
// 伪代码示例:在FileUpload组件中处理文件读取 const handleFileUpload = (event) => { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { const csvText = e.target.result; // 将csvText传递给解析函数 const parsedData = parseCSV(csvText); // 通过Context更新全局状态 updateGlobalData(parsedData); }; reader.readAsText(file); };真正的重头戏在utils/parser.ts里的parseCSV函数。我们不能简单地用split(','),因为CSV单元格内可能包含逗号或换行符。虽然可以自己写解析器,但为了可靠,我通常会引入一个轻量级的CSV解析库,比如papaparse。它的解析速度快,且能自动处理各种边界情况。
解析完成后,我们得到的是一个数组,每个元素是一行数据,属性名对应CSV的表头。但这还不够,原始数据是“一行一次使用记录”的粒度,我们需要为图表计算聚合数据。例如:
- 按日聚合:计算每天的总成本、总token数、各模型使用次数。
- 按模型聚合:计算每个模型使用的总成本、总token数、平均每次请求的token数。
- 按周/月聚合:用于更高维度的趋势分析。
这些聚合计算会在解析函数中同步完成,最终生成一个包含rawRecords(原始记录)、dailySummary、modelSummary等属性的完整数据对象,供后续所有组件使用。
3.2 仪表盘与核心图表实现
Dashboard.tsx是整个应用的门面,它负责布局和协调各个可视化组件。我采用了响应式网格布局,确保在桌面、平板和手机上都有不错的浏览体验。
3.2.1 时间序列折线图 (Time Series Chart)
这个图表通常用Recharts的LineChart或AreaChart实现,X轴是时间(Date),Y轴可以是成本(Cost)或总token数(Total Tokens)。这里有个细节:原始数据里Cost字段可能是字符串(如$0.12),我们需要在解析时将其转换为数字类型。
为了让用户能同时对比成本和token趋势,我常常会使用双Y轴。一个Y轴对应成本(单位是美元),另一个Y轴对应token数(单位是千或百万)。同时,用不同的颜色和线型(实线、虚线)来区分两条折线,并在图例中清晰标注。
3.2.2 模型使用统计图 (Model Statistics Chart)
这部分通常用PieChart(饼图)或BarChart(柱状图)来展示。饼图直观显示各模型使用量(按成本或请求次数)的占比。但如果你有超过5个模型,饼图会显得拥挤,此时横向柱状图可能是更好的选择,它能更清晰地展示排名和具体数值。
这里的数据来源于modelSummary聚合结果。图表上可以添加交互,比如鼠标悬停时显示该模型的详细数据:总成本、总请求数、平均每次请求token数。
3.2.3 日历热力图 (Calendar Heatmap)
这是视觉上最吸引人,也是实现上稍复杂的一个组件。Recharts没有现成的日历热力图,我们需要用ResponsiveContainer、XAxis、YAxis和Rectangle来自行构建。
思路是:将一年(或一个时间段)的每一天映射到一个网格中。X轴代表星期几(周一到周日),Y轴代表第几周。每个Rectangle代表一天,其颜色深浅由那天的活动强度(如总token数或总成本)决定。颜色映射可以使用一个从浅到深的色阶,例如浅黄色代表低使用量,深红色代表高使用量。
实现的关键是计算给定日期是当年的第几周,以及它是星期几。然后根据这个坐标,在对应位置绘制一个Rectangle,并根据该日期的聚合值设置其填充色。
3.2.4 数据概要卡片 (StatCard)
在图表周围,我会放置一些StatCard组件,用于展示关键的总计数字,例如:总成本、总Token数、总请求数、使用过的唯一模型数量。这些数字能给用户一个最快速、最宏观的概览。这些卡片通常用Tailwind CSS简单装饰,突出显示数字,并配以简洁的标签和图标(来自Lucide React)。
3.3 自定义导出链接生成器
这是一个非常实用的功能,源于Cursor官网的一个限制:其使用数据导出界面通常只允许导出最近30天的数据。如果你想分析去年一整年的情况,就得手动分十几次导出再合并,非常麻烦。
LinkBuilder.tsx组件就是为了解决这个问题。它的原理其实不复杂:Cursor的导出功能背后肯定有一个API接口,这个接口很可能接受start_date和end_date这样的查询参数。通过浏览器的开发者工具(F12,网络选项卡),在官网点击导出时,我们可以捕获到这个请求的URL格式。
然后,我在LinkBuilder组件里提供两个日期选择器(比如<input type="date">),让用户选择起始和结束日期。组件内部根据捕获到的URL格式,将用户选择的日期填充进去,动态生成一个完整的导出链接。用户点击这个生成的链接,浏览器就会直接开始下载指定日期范围的CSV文件。
实操心得:这个功能需要你事先去Cursor官网“侦察”一下。不同时期其接口格式可能会有变化,所以代码里最好把URL模板做成可配置的,或者提供一个说明让用户自己获取最新的格式。这是典型的“逆向工程”思维,能极大提升工具的好用程度。
4. 开发实操与核心代码要点
4.1 项目初始化与工程配置
首先,用Vite快速搭建一个React+TypeScript的项目骨架,这是最省事的起点。
npm create vite@latest cursor-usage-visualizer -- --template react-ts cd cursor-usage-visualizer npm install然后,安装我们选定的核心依赖:
npm install recharts lucide-react npm install -D tailwindcss postcss autoprefixer npx tailwindcss init -p接下来配置Tailwind CSS。修改生成的tailwind.config.js,确保它扫描了你的所有组件文件:
/** @type {import('tailwindcss').Config} */ export default { content: [ "./index.html", "./src/**/*.{js,ts,jsx,tsx}", ], theme: { extend: {}, }, plugins: [], }然后在你的主CSS文件(例如src/index.css)开头引入Tailwind的指令:
@tailwind base; @tailwind components; @tailwind utilities;4.2 类型定义与数据接口
在src/types.ts中,我们先定义好整个应用用到的核心数据类型。这是TypeScript项目保持清晰的关键。
// 对应CSV中的一行原始记录 export interface CursorUsageRecord { Date: string; // ISO格式或Cursor的特定格式 Kind: string; // 如 'completion', 'chat' 等 Model: string; // 如 'gpt-4', 'claude-3-5-sonnet' 'Max Mode': string; // 可能是 'true'/'false' 或 'yes'/'no' 'Input (w/ Cache Write)': number; 'Input (w/o Cache Write)': number; 'Cache Read': number; 'Output Tokens': number; 'Total Tokens': number; Cost: number; // 解析时应将货币字符串转为数字 } // 按日聚合后的数据 export interface DailySummary { date: string; // YYYY-MM-DD totalCost: number; totalTokens: number; requestCount: number; modelsUsed: string[]; // 当天使用的模型列表 } // 按模型聚合后的数据 export interface ModelSummary { modelName: string; totalCost: number; totalTokens: number; requestCount: number; avgTokensPerRequest: number; } // 应用的核心状态 export interface AppData { rawRecords: CursorUsageRecord[]; dailySummaries: DailySummary[]; modelSummaries: ModelSummary[]; overallStats: { totalCost: number; totalTokens: number; totalRequests: number; uniqueModels: number; }; }4.3 核心解析函数实现
utils/parser.ts是这个项目的大脑。我们来实现核心的parseCSV函数。这里假设使用papaparse进行解析。
import Papa from 'papaparse'; import { CursorUsageRecord, AppData, DailySummary, ModelSummary } from '../types'; export function parseCSV(csvText: string): AppData { // 1. 解析CSV文本 const parseResult = Papa.parse<CursorUsageRecord>(csvText, { header: true, // 第一行作为表头 dynamicTyping: true, // 自动尝试转换数字等类型 skipEmptyLines: true, }); const rawRecords: CursorUsageRecord[] = parseResult.data; // 2. 数据清洗与转换(例如,将Cost从字符串"$0.12"转为数字0.12) const cleanedRecords = rawRecords.map(record => ({ ...record, Cost: typeof record.Cost === 'string' ? parseFloat(record.Cost.replace(/[^0-9.-]+/g, '')) : record.Cost, Date: new Date(record.Date).toISOString().split('T')[0], // 标准化为YYYY-MM-DD })).filter(record => !isNaN(record.Cost)); // 过滤掉无效记录 // 3. 按日聚合 const dailyMap = new Map<string, DailySummary>(); cleanedRecords.forEach(record => { const date = record.Date; if (!dailyMap.has(date)) { dailyMap.set(date, { date, totalCost: 0, totalTokens: 0, requestCount: 0, modelsUsed: new Set<string>(), } as any); // 临时使用any,后续填充 } const daySummary = dailyMap.get(date)!; daySummary.totalCost += record.Cost; daySummary.totalTokens += record['Total Tokens']; daySummary.requestCount += 1; (daySummary.modelsUsed as Set<string>).add(record.Model); }); // 将Set转为数组 const dailySummaries: DailySummary[] = Array.from(dailyMap.values()).map(d => ({ ...d, modelsUsed: Array.from(d.modelsUsed as Set<string>), })); // 4. 按模型聚合 const modelMap = new Map<string, ModelSummary>(); cleanedRecords.forEach(record => { const model = record.Model; if (!modelMap.has(model)) { modelMap.set(model, { modelName: model, totalCost: 0, totalTokens: 0, requestCount: 0, avgTokensPerRequest: 0, }); } const modelSummary = modelMap.get(model)!; modelSummary.totalCost += record.Cost; modelSummary.totalTokens += record['Total Tokens']; modelSummary.requestCount += 1; }); // 计算每个模型的平均token数 const modelSummaries: ModelSummary[] = Array.from(modelMap.values()).map(m => ({ ...m, avgTokensPerRequest: m.totalTokens / m.requestCount, })); // 5. 计算总体统计 const overallStats = { totalCost: cleanedRecords.reduce((sum, r) => sum + r.Cost, 0), totalTokens: cleanedRecords.reduce((sum, r) => sum + r['Total Tokens'], 0), totalRequests: cleanedRecords.length, uniqueModels: new Set(cleanedRecords.map(r => r.Model)).size, }; return { rawRecords: cleanedRecords, dailySummaries: dailySummaries.sort((a, b) => a.date.localeCompare(b.date)), // 按日期排序 modelSummaries: modelSummaries.sort((a, b) => b.totalCost - a.totalCost), // 按成本降序 overallStats, }; }4.4 构建日历热力图组件
这是最具挑战性的可视化组件。我们需要一个CalendarHeatmap.tsx组件。思路是计算给定年份每一天的“值”(如token数),然后映射到网格上。
// src/components/charts/CalendarHeatmap.tsx import { useMemo } from 'react'; import { ResponsiveContainer, Tooltip, Rectangle, XAxis, YAxis } from 'recharts'; import { DailySummary } from '../../types'; interface CalendarHeatmapProps { data: DailySummary[]; // 按日聚合的数据 year: number; // 要展示的年份 } export const CalendarHeatmap: React.FC<CalendarHeatmapProps> = ({ data, year }) => { // 将每日数据转换为以日期字符串为键的Map,便于查找 const dataMap = useMemo(() => { const map = new Map<string, number>(); data.forEach(d => { if (d.date.startsWith(`${year}-`)) { map.set(d.date, d.totalTokens); // 这里用token数作为热度值,也可用totalCost } }); return map; }, [data, year]); // 生成一年中所有日期的数据点,用于绘图 const chartData = useMemo(() => { const weeks: { week: number; days: { dayOfWeek: number; value: number; date: string }[] }[] = []; const startDate = new Date(year, 0, 1); const endDate = new Date(year, 11, 31); let currentDate = new Date(startDate); let currentWeek = 0; // 初始化第一周 weeks.push({ week: currentWeek, days: Array(7).fill(null) }); while (currentDate <= endDate) { const dayOfWeek = currentDate.getDay(); // 0 (周日) 到 6 (周六) const dateStr = currentDate.toISOString().split('T')[0]; const value = dataMap.get(dateStr) || 0; // 确保weeks数组有足够的周数 if (!weeks[currentWeek]) { weeks.push({ week: currentWeek, days: Array(7).fill(null) }); } // 将数据放入对应周和星期几的位置 // 这里一个常见的调整是:将周一作为每周的第一天(dayOfWeek 1) // 但Recharts的XAxis通常期望0-6对应周日到周六。需要根据你的网格定义调整映射关系。 // 以下是一种映射方式:假设X轴0=周一,6=周日 const adjustedDayOfWeek = (dayOfWeek + 6) % 7; // 将周日(0)转换为6,周一(1)转换为0,以此类推 weeks[currentWeek].days[adjustedDayOfWeek] = { dayOfWeek: adjustedDayOfWeek, value, date: dateStr }; // 如果是周六,下周 if (dayOfWeek === 6) { currentWeek++; } currentDate.setDate(currentDate.getDate() + 1); } // 转换格式,便于Recharts渲染 const plotData = weeks.map((w, weekIndex) => ({ week: `W${weekIndex + 1}`, ...w.days.reduce((acc, day, idx) => { if (day) { acc[`day${idx}`] = day.value; acc[`date${idx}`] = day.date; // 存储日期用于Tooltip } return acc; }, {} as any), })); return plotData; }, [year, dataMap]); // 定义颜色梯度 const getColor = (value: number) => { if (value === 0) return '#ebedf0'; // 无活动 if (value < 1000) return '#c6e48b'; // 低 if (value < 5000) return '#7bc96f'; // 中低 if (value < 20000) return '#239a3b'; // 中高 return '#196127'; // 高 }; // 由于Recharts没有原生日历图,我们用一系列Rectangle手动绘制 // 这里简化展示,实际实现需要更复杂的布局计算 return ( <div className="w-full h-64"> <ResponsiveContainer width="100%" height="100%"> <RechartsSurface> {/* 需要一个基础的绘图容器,这里用伪代码表示 */} <XAxis dataKey="week" type="category" /> <YAxis dataKey="dayOfWeek" type="category" /> <Tooltip formatter={(value, name, props) => [`${value} tokens`, props.payload.date]} /> {chartData.map((weekData, weekIdx) => ( weekData.days?.map((day, dayIdx) => ( day && ( <Rectangle key={`${weekIdx}-${dayIdx}`} x={weekIdx * 12} // 每个单元格的宽度偏移 y={dayIdx * 12} // 每个单元格的高度偏移 width={10} height={10} fill={getColor(day.value)} stroke="#fff" strokeWidth={0.5} /> ) )) ))} </RechartsSurface> </ResponsiveContainer> </div> ); };注意:上面的日历热力图代码是一个高度简化的概念性实现。在实际项目中,你需要精细计算每个矩形的位置(
x,y)、宽度和高度,并处理月份标签、星期标签的显示。一个更常见的做法是使用D3.js进行底层的日期计算和布局,然后用Recharts或纯SVG渲染。对于新手,我建议可以先使用一个成熟的React日历热力图库(如react-calendar-heatmap)来快速实现功能,后期再考虑自定义。
5. 部署、优化与常见问题
5.1 构建与部署
开发完成后,使用Vite进行生产构建非常简单:
npm run build这个命令会在dist目录下生成优化后的静态文件(HTML, JS, CSS)。你可以将这些文件部署到任何静态网站托管服务上,比如:
- Vercel/Netlify:通过关联Git仓库,可以实现自动部署。
- GitHub Pages:适合开源项目展示。
- 你自己的服务器:只需一个Nginx或Apache配置,指向
dist目录即可。
由于是纯前端应用,无需任何后端API,部署成本极低,且全球访问速度都很快(得益于CDN)。
5.2 性能优化考量
虽然数据量不大,但良好的性能习惯很重要:
- 虚拟化长列表:如果某天使用记录极多,导致原始记录列表很长,在展示原始数据的表格中应考虑使用虚拟滚动(如
react-window库)。 - Memoization:在React组件中,对于耗时的计算(如上面的
chartData计算),使用useMemo进行缓存,避免每次渲染都重复计算。 - 代码分割:使用React.lazy和Suspense对非首屏必需的组件(如“关于”页面、详细设置面板)进行懒加载,减少初始包体积。
- 图表渲染优化:Recharts在数据点过多时(如超过1000个点)可能会变慢。可以考虑对时间序列数据进行采样或聚合,例如将非常密集的日数据聚合成周数据或月数据来展示长期趋势。
5.3 常见问题与排查
问题1:上传CSV文件后,图表没有显示数据或显示错误。
- 排查步骤:
- 检查控制台:打开浏览器开发者工具(F12)的Console选项卡,查看是否有JavaScript报错。最常见的错误是CSV解析失败。
- 检查CSV格式:确认你导出的CSV文件确实来自Cursor,并且包含项目所期望的表头(
Date,Model,Cost等)。用文本编辑器打开CSV文件,查看前几行。 - 检查数据预览:在
parseCSV函数中临时添加console.log,打印解析后的前几条数据,确认字段映射和类型转换是否正确(特别是Cost字段是否成功转为数字)。 - 日期格式:Cursor导出的日期格式可能与
Date构造函数预期的不符。你可能需要在解析器中调整日期解析逻辑,例如使用moment.js或date-fns库来灵活处理多种日期格式。
问题2:日历热力图显示错位或颜色不对。
- 排查步骤:
- 确认年份:检查传递给
CalendarHeatmap组件的year属性是否正确。 - 调试数据映射:在
getColor函数和计算chartData的地方添加日志,检查每一天的值是否正确获取并映射到了颜色。 - 验证坐标计算:日历热力图的坐标计算(第几周、星期几)很容易出偏差。建议先用一个简单的数据集(比如只生成一个月的数据)进行调试,在页面上打印出计算出的
weekIndex和dayOfWeek,看它们是否符合预期。
- 确认年份:检查传递给
问题3:在手机上布局混乱。
- 排查步骤:
- 使用响应式单位:确保图表容器的宽度使用百分比(
width: 100%)或vw单位,而不是固定像素。Recharts的ResponsiveContainer组件已经帮我们处理了大部分工作。 - Tailwind响应式类:利用Tailwind的响应式前缀(如
md:,lg:)为不同屏幕尺寸调整布局。例如,在小屏幕上让图表堆叠(flex-col),在大屏幕上并排(flex-row)。 - 测试:始终在真实的手机浏览器或开发者工具的移动设备模拟模式下进行测试。
- 使用响应式单位:确保图表容器的宽度使用百分比(
问题4:自定义导出链接生成器生成的链接无效。
- 排查步骤:
- 检查URL模板:这是最可能的原因。Cursor的导出接口URL可能已经改变。你需要重新在官网的导出流程中,通过浏览器开发者工具的“网络”选项卡,捕获最新的请求URL。
- 检查日期格式:确认链接中
start_date和end_date参数的格式是否与Cursor API期望的完全一致(通常是YYYY-MM-DD)。 - 检查认证:有些导出链接可能需要包含会话Cookie或Token才能工作。如果是这样,这个功能可能就无法在第三方网页中直接实现了,因为它涉及用户隐私和安全。这种情况下,这个功能可能只能指导用户手动拼接URL。
这个工具的本质,是把散落在CSV文件里的数字,变成能一眼看懂的“故事”。开发它的过程,也是不断理解自己如何使用AI编程助手的过程。你会发现一些有趣的模式,比如每周一下午是使用高峰,或者重构代码时特别依赖GPT-4。这些洞察不仅能帮你控制成本,更能让你反思和优化自己的工作流。代码已经开源,你可以直接拿去用,更欢迎你根据自己的需求进行修改和扩展,比如增加对更多AI工具数据格式的支持,或者开发更深入的分析维度。
