1. 项目背景:为什么需要 GitHub 风格热力图
1.1 从 GitHub 绿点矩阵说起
如果你经常逛 GitHub,一定对个人主页上那一块绿绿黄黄的贡献热力图不陌生。GitHub 用一个个色块展示你过去一年每天的代码提交次数,颜色越深代表当天提交越活跃,空白则代表当天没有贡献记录。
这块热力图之所以受欢迎,主要有几个原因:
- 视觉反馈非常直观,一眼就能看出“最近有没有在持续输出”。
- 有轻微的社交属性,好友之间能互相看到活跃度。
- 对个人而言,是一种很好的自我监督和激励工具。
很多开发者都试着在自己的博客或 GitHub README 里复刻这种热力图效果。但热力图的应用场景远不止代码提交统计,它可以迁移到很多领域,比如阅读记录、运动打卡、背单词记录、饮食管理、健身计划,甚至睡眠质量分析。
1.2 阅读记录和热力图如何结合
回到本文的标题:GitHub Heatmap for Reading。
阅读这件事有一个特点:它的成果是隐性的。你读完一本书、一篇文章,很难像写完一段代码那样立刻看到产出。时间一长,会出现几个常见问题:
- 上周读了多少书?记不清了。
- 过去一个月大概保持每周读几次?没有数据。
- 哪些天读得特别多,哪些天完全没碰书?说不出来。
如果能把阅读行为以“天”为单位记录下来,再用热力图展示一年 365 天的分布情况,阅读状态就变得一目了然。比如 3 月连续 25 天有阅读记录,热力图会呈现一整片连续的深色块;如果十一假期完全没看书,那块区域就是空白的。
这就是本文要实现的个人阅读热力图系统。它既有图形化的展示效果,又具备实际的数据统计功能,适合用来做个人阅读记录、习惯养成和数据复盘。
同时也需要提前说明一个问题:GitHub 热力图本身有一套非常成熟的实现方案,我们不需要从零开始造轮子,但考虑到不同用户的技术栈和使用习惯不同,本文会走两条路线:一条是纯前端轻量方案,适合只想快速看到效果、不做数据持久化的场景;另一条是Flask + SQLite + ECharts 方案,适合希望做成一个长期可用的个人阅读统计工具。
1.3 项目最终效果预览
先来看一下项目完成后的大致效果:
- 页面是一个标准的 53 列 × 7 行热力图矩阵,每个格子代表某年某月某日。
- 鼠标悬停在某个格子上,弹出提示框,显示“X月X日 阅读 XX 分钟”。
- 格子的颜色随阅读时长变化,短时间为浅色,长时间为深色。
- 页面上方附带统计信息:累计阅读天数、今年阅读总时长、连续阅读最长天数。
- 可选:支持按年份切换视图。
- 可选:配合 GitHub 风格深色/浅色主题。
技术方案选择的是Python Flask 作为后端 + SQLite 存储数据和 ECharts 前端图表库。为什么选这个组合?后面我会详细说明。
2. 项目需求分析与技术选型
2.1 功能需求拆分
在开始编码之前,先明确这个项目需要实现哪些功能。我把需求分为核心功能和扩展功能两部分。
核心功能(本文必须实现):
- 记录每天的阅读时长数据。至少包含日期和时长两个字段,可以扩展记录书名、页数、笔记数。
- 以一年为周期展示热力图。按月份分布,按周排列,与 GitHub 贡献图的布局一致。
- 图表提供交互能力。鼠标悬停显示详细数据,点击可跳转到当天的记录详情。
- 页面显示基础统计指标。阅读总天数、总时长、日均时长、最长连续阅读天数。
- 数据能持久化保存。即关闭浏览器后数据不丢失,下次打开还能看到。
扩展功能(作为进阶内容):
- 支持从 CSV/JSON 导入历史阅读数据。
- 支持多用户隔离,每人有独立的阅读记录。
- 支持按年份切换热力图。
- 支持自定义颜色主题。
- 部署到服务器或 Docker 容器中运行。
2.2 技术选型对比
在项目设计阶段,我对比了几种常见的技术方案:
方案一:纯静态页面 + JavaScript 数据模拟
适合场景:快速演示效果,不需要真实数据。优点是没有后端,不需要安装依赖,双击 HTML 文件就能打开。
缺点:数据写死在 JS 里,每次刷新恢复原始状态。如果只是用来做个 Demo 或博客个人页装饰,这种方式够用。
方案二:前端 + LocalStorage 本地存储
适合场景:个人单机使用,无后端需求。优点仍然是部署简单,数据保存在浏览器本地。
缺点:换浏览器或清缓存数据就没了,无法在多设备间同步,也不方便做复杂的统计查询。
方案三:Flask + SQLite + ECharts(本文采用)
适合场景:作为长期可用的个人工具,数据需要持久化、可备份、可扩展。
优点:
- Flask 足够轻量,适合快速搭建个人工具。
- SQLite 零配置文件、零服务,数据就是一个文件,备份和迁移都方便。
- ECharts 的 calendar 日历图原生支持热力图,配置简单,交互效果好。
- 后续如果要加后端定时任务、邮件提醒、数据导出等功能,扩展空间很大。
对于阅读热力图这种数据量级(一年最多 365 条,每条数据很小)的需求,SQLite 完全够用,不需要上 MySQL 或 PostgreSQL。
方案四:直接用现成的开源热力图工具
GitHub 上有一些现成的 contribution graph 库,比如 ghchart、github-calendar 等。但这类库大多是为展示 GitHub API 数据设计的,要改造成阅读记录工具,反而要修改的数据源和配置较多,不如自己搭建来得直接。
综合对比后,决定采用方案三。
2.3 最终技术栈
| 组件 | 选型 | 说明 |
|---|---|---|
| 后端框架 | Flask | Python Web 框架,轻量简洁 |
| 数据库 | SQLite | Python 内置支持,无需额外安装 |
| 前端图表 | ECharts | Apache 开源可视化库,支持日历热力图 |
| 前端模板 | Jinja2 | Flask 默认模板引擎 |
| 页面样式 | 原生 CSS 或 Bootstrap | 简单实现 GitHub 风格配色 |
| Python 版本 | 3.8+ | 没有强制依赖新版本特性 |
版本说明:Flask 本文以 3.x 版本为例,ECharts 使用 5.x 版本。具体版本号在你自己环境中可能略有不同,但本文涉及的核心 API 在这些版本中是稳定的。
3. 环境准备与项目初始化
3.1 安装 Python 与虚拟环境
先确认本机已安装 Python 3.8 或更高版本。在终端执行:
python --version如果输出类似Python 3.10.12的信息,说明环境就绪。
建议为项目创建独立的虚拟环境,避免污染全局 Python 包:
# 创建项目目录 mkdir reading-heatmap cd reading-heatmap # 创建虚拟环境 python -m venv venv # 激活虚拟环境(Windows) venv\Scripts\activate # 激活虚拟环境(macOS / Linux) source venv/bin/activate激活后,终端提示符前面会出现(venv)字样。
3.2 安装 Flask
安装 Flask 和后续会用到的依赖:
pip install flask验证安装是否成功:
python -c "import flask; print(flask.__version__)"能正常输出版本号即安装成功。
3.3 项目目录结构设计
为了让代码整洁且易于扩展,我按下面的结构组织项目:
reading-heatmap/ ├── app.py # Flask 主应用 ├── init_db.py # 数据库初始化脚本 ├── requirements.txt # 依赖清单 ├── static/ │ └── css/ │ └── style.css # 自定义样式 └── templates/ └── index.html # 热力图主页这个结构虽然简单,但已经遵循了 Flask 工程的常规组织方式:静态资源放static目录,模板放templates目录,主逻辑集中在app.py。
4. 数据库设计与初始化
4.1 表结构设计
阅读记录的核心需求很简单,就是“哪一天阅读了多长时间”。在此基础上,我加了一些字段方便后续扩展。
设计一张reading_records表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PRIMARY KEY AUTOINCREMENT | 自增主键 |
| read_date | TEXT UNIQUE NOT NULL | 阅读日期,格式 YYYY-MM-DD |
| duration_minutes | INTEGER NOT NULL | 当天阅读总时长(分钟) |
| book_name | TEXT | 当天在读的书籍名称 |
| pages | INTEGER | 当天阅读页数 |
| note | TEXT | 当天阅读备注或摘录 |
这里有一个关键设计:read_date加了 UNIQUE 约束。因为热力图是“一天一个格子”,所以一天最多只能有一条汇总数据。如果当天多次阅读,后端应该做累加更新而不是插入新记录。
duration_minutes是核心数据字段,热力图颜色深浅全靠它驱动。
4.2 编写数据库初始化脚本
新建init_db.py:
# init_db.py import sqlite3 import os DB_PATH = os.path.join(os.path.dirname(__file__), "reading.db") def init_db(): """初始化数据库,创建表结构""" conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() cursor.execute( """ CREATE TABLE IF NOT EXISTS reading_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, read_date TEXT UNIQUE NOT NULL, duration_minutes INTEGER NOT NULL DEFAULT 0, book_name TEXT DEFAULT '', pages INTEGER DEFAULT 0, note TEXT DEFAULT '' ) """ ) # 为日期字段建立索引,加快按年份查询 cursor.execute( "CREATE INDEX IF NOT EXISTS idx_reading_date ON reading_records(read_date)" ) conn.commit() conn.close() print(f"数据库初始化完成: {DB_PATH}") if __name__ == "__main__": init_db()执行初始化脚本:
python init_db.py运行后,项目目录下会出现一个reading.db文件。以后每天的数据都会追加到这个文件中。
4.3 为什么选择 SQLite
这里回答一个很多人会问的问题:为什么不直接用 MySQL?
我认为对于个人阅读记录这类轻量工具,SQLite 的优势非常明显:
- 零配置:不需要安装数据库服务,不需要配置用户名密码。
- 单文件存储:整个数据库就是一个
.db文件,备份就是复制文件,迁移就是把文件拷走。 - Python 原生支持:
sqlite3是 Python 标准库的一部分,不需要额外安装驱动。 - 性能足够:每天一条数据,一年 365 条,SQLite 完全可以轻松支撑。
当然,SQLite 也有限制:不适合高并发写入场景、不适合多进程同时大量写入。但在个人工具这个场景下,完全不是问题。
5. Flask 后端开发
5.1 创建 Flask 主应用
新建app.py,这是整个项目的核心后端逻辑。
# app.py import sqlite3 import os from datetime import datetime, timedelta from flask import Flask, render_template, request, jsonify app = Flask(__name__) DB_PATH = os.path.join(os.path.dirname(__file__), "reading.db") def get_db_connection(): """获取数据库连接""" conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn @app.route("/") def index(): """主页,展示热力图""" year = request.args.get("year", type=int, default=datetime.now().year) return render_template("index.html", current_year=year) @app.route("/api/heatmap/<int:year>") def heatmap_data(year): """ 获取指定年份的阅读热力图数据 返回结构:{"date": "2025-03-15", "value": 30} """ conn = get_db_connection() cursor = conn.cursor() cursor.execute( """ SELECT read_date, duration_minutes FROM reading_records WHERE read_date >= ? AND read_date <= ? ORDER BY read_date """, ( f"{year}-01-01", f"{year}-12-31", ), ) rows = cursor.fetchall() conn.close() data = [ { "date": row["read_date"], "value": row["duration_minutes"], } for row in rows ] return jsonify(data) @app.route("/api/stats/<int:year>") def stats_data(year): """获取统计指标:总阅读天数、总时长、日均时长、最长连续天数""" conn = get_db_connection() cursor = conn.cursor() # 查询该年份内所有有阅读记录的日期 cursor.execute( """ SELECT read_date, duration_minutes FROM reading_records WHERE read_date >= ? AND read_date <= ? ORDER BY read_date """, ( f"{year}-01-01", f"{year}-12-31", ), ) rows = cursor.fetchall() conn.close() if not rows: return jsonify( { "total_days": 0, "total_minutes": 0, "avg_minutes": 0, "max_streak": 0, } ) total_days = len(rows) total_minutes = sum(row["duration_minutes"] for row in rows) avg_minutes = round(total_minutes / total_days, 1) # 计算最长连续阅读天数 dates = [datetime.strptime(row["read_date"], "%Y-%m-%d").date() for row in rows] max_streak = calculate_max_streak(dates) return jsonify( { "total_days": total_days, "total_minutes": total_minutes, "avg_minutes": avg_minutes, "max_streak": max_streak, } ) @app.route("/api/record", methods=["POST"]) def add_record(): """ 新增或更新一条阅读记录 请求体 JSON:{"date": "2025-03-15", "duration_minutes": 30, "book_name": "书名", "pages": 20, "note": "备注"} """ data = request.get_json() read_date = data.get("date") duration_minutes = data.get("duration_minutes", 0) if not read_date: return jsonify({"error": "date 字段不能为空"}), 400 # 日期格式校验 try: datetime.strptime(read_date, "%Y-%m-%d") except ValueError: return jsonify({"error": "date 格式必须为 YYYY-MM-DD"}), 400 if not isinstance(duration_minutes, int) or duration_minutes <= 0: return jsonify({"error": "duration_minutes 必须为正整数"}), 400 book_name = data.get("book_name", "") pages = data.get("pages", 0) note = data.get("note", "") conn = get_db_connection() cursor = conn.cursor() # 检查当天是否已有记录 cursor.execute( "SELECT id FROM reading_records WHERE read_date = ?", (read_date,) ) existing = cursor.fetchone() if existing: # 已有记录则累加时长(还可以选择是否覆盖其他字段) cursor.execute( """ UPDATE reading_records SET duration_minutes = duration_minutes + ?, book_name = CASE WHEN ? != '' THEN ? ELSE book_name END, pages = pages + ?, note = CASE WHEN ? != '' THEN ? ELSE note END WHERE read_date = ? """, ( duration_minutes, book_name, book_name, pages, note, note, read_date, ), ) else: # 无记录则插入新行 cursor.execute( """ INSERT INTO reading_records (read_date, duration_minutes, book_name, pages, note) VALUES (?, ?, ?, ?, ?) """, (read_date, duration_minutes, book_name, pages, note), ) conn.commit() conn.close() return jsonify({"success": True, "message": "记录已保存"}) def calculate_max_streak(dates): """ 计算最长连续阅读天数 参数 dates 必须是由 datetime.date 对象组成的升序列表,且已去重 """ if not dates: return 0 max_streak = 1 current_streak = 1 for i in range(1, len(dates)): if (dates[i] - dates[i - 1]).days == 1: current_streak += 1 else: max_streak = max(max_streak, current_streak) current_streak = 1 max_streak = max(max_streak, current_streak) return max_streak if __name__ == "__main__": app.run(debug=True, port=5000)5.2 接口说明
上面这段代码一共实现了三个接口。
GET /是主页入口,渲染index.html模板,支持通过 URL 参数?year=2025查看指定年份的热力图。
GET /api/heatmap/<year>是核心数据接口。前端会按年份发送请求,后端从 SQLite 查询该年 1 月 1 日到 12 月 31 日的所有记录,把日期和阅读时长以 JSON 数组返回。
GET /api/stats/<year>负责计算四个统计指标:
total_days:全年有阅读记录的天数。total_minutes:全年累计阅读总时长(分钟)。avg_minutes:有记录天数下的日均阅读时长。max_streak:最长连续阅读天数。
POST /api/record用于新增或更新数据。这里做了一个重要的容错设计:如果同一天已经存在记录,则把新的阅读时长累加到原有记录上,而不是覆盖。这样符合实际使用场景:你上午读了一小时,晚上又读了半小时,最终当天的数据应该是 90 分钟。
5.3 幂等更新还是累加更新
关于数据更新策略,这里需要解释一下设计选择。
如果采用幂等更新:同一天多次提交,后面的值覆盖前面的值。好处是逻辑简单,但问题是容易误操作覆盖真实数据。
如果采用累加更新:同一天多次提交,时长自动相加。好处是符合“一天多次阅读”的真实场景,缺点是如果用户想要“修改”某天的时长,只能通过删除记录再重录的方式。
本文选择了累加更新。同时预留了book_name和note字段的“非空才更新”逻辑,即只有提交了新的书名或备注,才覆盖原有值,否则保留原值。
6. 前端页面与 ECharts 热力图
6.1 编写前端页面模板
新建templates/index.html:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>阅读热力图 - Reading Heatmap</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> </head> <body> <div class="container"> <h1>📚 我的阅读热力图</h1> <p class="subtitle">记录每一天的阅读时光</p> <!-- 年份选择 --> <div class="toolbar"> <label for="year-select">选择年份:</label> <select id="year-select"> <!-- 由 JS 动态填充 --> </select> <span id="today-tip"></span> </div> <!-- 统计卡片 --> <div class="stats-row"> <div class="stat-card"> <div class="stat-value" id="stat-days">0</div> <div class="stat-label">累计阅读天数</div> </div> <div class="stat-card"> <div class="stat-value" id="stat-minutes">0</div> <div class="stat-label">阅读总时长(分钟)</div> </div> <div class="stat-card"> <div class="stat-value" id="stat-avg">0</div> <div class="stat-label">日均阅读(分钟)</div> </div> <div class="stat-card"> <div class="stat-value" id="stat-streak">0</div> <div class="stat-label">最长连续天数</div> </div> </div> <!-- 热力图容器 --> <div id="heatmap" style="width: 100%; height: 220px;"></div> <!-- 添加记录表单 --> <div class="record-form"> <h2>添加阅读记录</h2> <form id="record-form"> <div class="form-row"> <div> <label for="record-date">日期</label> <input type="date" id="record-date" required> </div> <div> <label for="record-minutes">阅读时长(分钟)</label> <input type="number" id="record-minutes" min="1" max="1440" required> </div> <div> <label for="record-book">书名(选填)</label> <input type="text" id="record-book" placeholder="《置身事内》"> </div> <button type="submit">保存记录</button> </div> </form> <div id="form-message"></div> </div> </div> <script src="{{ url_for('static', filename='js/main.js') }}"></script> </body> </html>页面布局分为四个区块:年份选择工具栏、统计卡片、热力图展示区和记录录入表单。
6.2 编写 ECharts 热力图渲染脚本
新建static/js/main.js:
// static/js/main.js let heatmapChart = null; let currentYear = Number( document.querySelector(".toolbar select") ? new Date().getFullYear() : new Date().getFullYear() ); // 初始化年份下拉框 function initYearSelect() { const select = document.getElementById("year-select"); const currentYear = new Date().getFullYear(); // 生成最近 5 年的选项 for (let y = currentYear; y >= currentYear - 4; y--) { const option = document.createElement("option"); option.value = y; option.textContent = `${y} 年`; if (y === currentYear) { option.selected = true; } select.appendChild(option); } select.addEventListener("change", function () { currentYear = Number(this.value); loadData(); }); } // 加载热力图数据和统计信息 async function loadData() { const [heatmapRes, statsRes] = await Promise.all([ fetch(`/api/heatmap/${currentYear}`), fetch(`/api/stats/${currentYear}`), ]); const heatmapData = await heatmapRes.json(); const stats = await statsRes.json(); renderHeatmap(heatmapData); renderStats(stats); } // 渲染热力图 function renderHeatmap(data) { const dom = document.getElementById("heatmap"); if (!heatmapChart) { heatmapChart = echarts.init(dom); } const option = { tooltip: { formatter: function (params) { if (!params.data) { return "暂无阅读记录"; } const value = params.data.value; if (value === 0) { return `${params.data[0]}:无阅读记录`; } return `${params.data[0]}:阅读 ${value} 分钟`; }, }, visualMap: { min: 0, max: 120, calculable: true, orient: "horizontal", left: "center", bottom: 0, inRange: { color: ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"], }, text: ["多", "少"], }, calendar: { top: 20, left: 40, right: 20, cellSize: ["auto", 16], range: currentYear, itemStyle: { borderWidth: 3, borderColor: "#ffffff", }, splitLine: { show: true, }, yearLabel: { show: true, fontSize: 16, }, dayLabel: { firstDay: 1, nameMap: ["一", "二", "三", "四", "五", "六", "日"], }, monthLabel: { nameMap: "ZH", fontSize: 12, }, }, series: { type: "heatmap", coordinateSystem: "calendar", data: data.map((item) => [item.date, item.value]), }, }; heatmapChart.setOption(option, true); } // 渲染统计信息 function renderStats(stats) { document.getElementById("stat-days").textContent = stats.total_days; document.getElementById("stat-minutes").textContent = stats.total_minutes; document.getElementById("stat-avg").textContent = stats.avg_minutes; document.getElementById("stat-streak").textContent = stats.max_streak; } // 提交表单 async function submitRecord(event) { event.preventDefault(); const date = document.getElementById("record-date").value; const minutes = Number(document.getElementById("record-minutes").value); const bookName = document.getElementById("record-book").value.trim(); if (!date || !minutes || minutes <= 0) { document.getElementById("form-message").textContent = "请填写完整信息"; return; } const response = await fetch("/api/record", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ date: date, duration_minutes: minutes, book_name: bookName, }), }); const result = await response.json(); const msgEl = document.getElementById("form-message"); if (response.ok) { msgEl.textContent = "✅ 保存成功!"; msgEl.style.color = "#216e39"; document.getElementById("record-minutes").value = ""; document.getElementById("record-book").value = ""; // 立即刷新页面数据 loadData(); } else { msgEl.textContent = "❌ " + (result.error || "保存失败"); msgEl.style.color = "#d73a49"; } } // 初始化页面 function init() { initYearSelect(); loadData(); document .getElementById("record-form") .addEventListener("submit", submitRecord); } document.addEventListener("DOMContentLoaded", init);6.3 ECharts 日历热力图配置详解
这段 JS 代码有几个关键点需要逐一说明。
visualMap是热力图的“颜色标尺”。min和max定义了颜色映射的范围。这里的max: 120表示阅读时长超过 120 分钟的部分,颜色都按最大值来显示。如果你觉得 120 分钟太容易达到或者太难达到,可以按自己的阅读习惯调整。inRange.color定义了 5 个颜色,从浅到深。这里选了 GitHub 贡献图经典的绿白配色,你可以替换成任何主题色。
calendar是 ECharts 日历坐标系的核心配置。cellSize: ["auto", 16]表示格子宽度自适应,高度固定 16 像素。range: currentYear指定展示的年份。dayLabel设置每周显示的列索引,firstDay: 1表示一周从星期一开始,这样更符合国内使用习惯。monthLabel用nameMap: "ZH"显示中文月份。
series部分最关键的是data格式。ECharts 日历热力图要求数据格式为二维数组:[日期字符串, 数值]。在后端我们已经把数据转换成了{date: "2025-03-15", value: 30}的对象数组,在前端再用.map()转换成 ECharts 需要的格式。
6.4 编写自定义样式
新建static/css/style.css:
/* static/css/style.css */ * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; background-color: #f6f8fa; color: #24292f; min-height: 100vh; } .container { max-width: 1200px; margin: 0 auto; padding: 30px 20px; } h1 { font-size: 28px; color: #24292f; } .subtitle { color: #57606a; margin: 8px 0 20px; } .toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; } .toolbar select { padding: 6px 12px; font-size: 14px; border: 1px solid #d0d7de; border-radius: 6px; background-color: #ffffff; } .stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; } .stat-card { background: #ffffff; border: 1px solid #d0d7de; border-radius: 8px; padding: 16px 20px; text-align: center; } .stat-value { font-size: 32px; font-weight: 700; color: #24292f; } .stat-label { font-size: 14px; color: #57606a; margin-top: 4px; } #heatmap { background: #ffffff; border: 1px solid #d0d7de; border-radius: 8px; padding: 12px; margin-bottom: 24px; } .record-form { background: #ffffff; border: 1px solid #d0d7de; border-radius: 8px; padding: 20px; } .record-form h2 { font-size: 18px; margin-bottom: 16px; } .form-row { display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-end; } .form-row > div { display: flex; flex-direction: column; gap: 6px; } .form-row label { font-size: 13px; color: #57606a; } .form-row input { padding: 8px 10px; border: 1px solid #d0d7de; border-radius: 6px; font-size: 14px; } .form-row button { padding: 9px 20px; background-color: #2da44e; color: white; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; } .form-row button:hover { background-color: #2c974b; } #form-message { margin-top: 12px; font-size: 14px; }7. 运行与验证
7.1 启动项目
确认当前目录结构完整,然后启动 Flask 开发服务器:
python app.py看到输出类似下面的日志就说明启动成功:
* Running on http://127.0.0.1:5000浏览器访问http://127.0.0.1:5000,应该能看到一个完整的热力图页面。
7.2 录入测试数据
由于刚初始化的数据库是空的,页面上不会显示任何热力数据。为了验证效果,可以通过表单添加几条记录。
也可以直接调用后端接口插入批量测试数据:
curl -X POST http://127.0.0.1:5000/api/record \ -H "Content-Type: application/json" \ -d '{"date": "2025-03-01", "duration_minutes": 30}'想快速生成连续一个月的数据,可以写一个简单的 Python 脚本:
# seed_demo.py import sqlite3 import random from datetime import datetime, timedelta DB_PATH = "reading.db" conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() start_date = datetime(2025, 1, 1) for i in range(120): current = start_date + timedelta(days=i) # 80% 的概率当天有阅读记录 if random.random() < 0.8: minutes = random.choice([15, 20, 30, 45, 60, 90, 120]) cursor.execute( "INSERT OR REPLACE INTO reading_records (read_date, duration_minutes) VALUES (?, ?)", (current.strftime("%Y-%m-%d"), minutes), ) conn.commit() conn.close() print("演示数据生成完成")运行脚本后刷新页面,热力图就会显示出密密麻麻的色块。
7.3 预期效果
正确的渲染效果如下:
- 热力图按周排列,从上到下 7 行,从左到右 53 列。
- 格子颜色随阅读时长变化。
- 鼠标悬停时显示“某年某月某日:阅读多少分钟”的浮层。
- 页面顶部四个统计卡片数值随数据自动更新。
- 通过表单添加新记录后,页面自动刷新,新数据立即出现在对应日期上。
8. 常见问题与排查思路
8.1 热力图不显示数据
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 页面加载完全空白 | ECharts CDN 无法访问 | 使用本地 echarts.min.js 文件,或换用其他 CDN 源 |
| 页面能打开但没有任何色块 | 数据库里没有对应年份的数据 | 检查reading.db是否存在,访问/api/heatmap/2025看返回数据 |
| 只有部分月份有色块 | 当年数据本身不完整 | 确认录入日期是否在查询年份范围内 |
| 颜色全部一样深或一样浅 | visualMap.max设置不合理 | 根据个人阅读时长分布调整max值 |
常见的 CDN 备用地址包括:
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script> <script src="https://unpkg.com/echarts@5.5.0/dist/echarts.min.js"></script>8.2 表单提交后数据没有更新
最常见的原因是后端返回了 400 错误。在浏览器开发者工具的 Network 面板中查看提交接口的响应信息,根据提示修正。常见错误:
- 日期格式不对,后端只接受
YYYY-MM-DD格式。 duration_minutes不是正整数。- 提交时网络中断,数据没有写入数据库。
8.3 数据库写入失败
如果 SQLite 文件被另一进程锁定,或者没有写入权限,会出现database is locked或permission denied错误。解决方法是:
- 确认没有其他程序占用
reading.db。 - 确认运行 Flask 的用户对项目目录有写权限。
- 定期执行
VACUUM操作压缩和修复数据库。
9. 扩展优化
9.1 加入阅读日历 CSV 导入
很多深度阅读者已经在用微信读书、Kindle、Notion 或 Excel 记录阅读数据。如果项目支持 CSV 导入,就能省去手动录入的麻烦。
CSV 文件格式约定如下:
date,duration_minutes,book_name 2025-01-01,45,《置身事内》 2025-01-02,30,《置身事内》 2025-01-03,60,《人类简史》后端添加一个导入接口,读取 CSV 后逐行写入数据库,支持跳过已有记录或累加更新。
9.2 支持多用户
如果想把项目分享给家人或朋友使用,可以加一个简单的用户系统。最轻量的方式是在reading_records表中增加username字段,所有查询按用户名过滤。更正式的做法是引入 Flask-Login 做登录认证。
9.3 自定义主题色
GitHub 深色模式下的热力图是暗底亮绿配色,浅色模式是白底绿配色。可以在前端通过 CSS 变量和 EChartsinRange.color配置实现主题切换。
9.4 定时提醒
结合apscheduler库,设置每天晚上 10 点检查当天是否有阅读记录,如果没有则调用钉钉/企业微信/Server酱发送提醒。这是激励持续阅读的一个有效手段。
9.5 Docker 部署
写一个简单的Dockerfile,配合docker-compose.yml做端口映射和数据卷挂载:
FROM python:3.10-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 5000 CMD ["python", "app.py"]注意挂载数据卷,避免容器重建后 SQLite 文件丢失:
version: "3" services: reading-heatmap: build: . ports: - "5000:5000" volumes: - ./reading.db:/app/reading.db10. 最佳实践与工程建议
10.1 数据安全与备份
阅读记录虽然不像业务数据那样敏感,但仍然是长期积累的个人资产。建议:
- 每周手动复制一次
reading.db文件,存放至云端网盘或私有仓库。 - 不要轻易修改
reading.db的路径,避免程序找不到数据库。 - 在
init_db.py已经实现了幂等建表逻辑,重复执行不会破坏已有数据。
10.2 接口防御性设计
在实际开发中,POST /api/record这样的写入接口必须做严格校验。本文已经实现了两个校验:日期格式校验、时长正整数校验。但还有两个方向值得加强:
第一个是数据上限校验。一天的阅读时长不可能超过 1440 分钟,如果数值大于这个值,应该直接拒绝。
第二个是重复提交保护。如果前端在短时间内连续提交两次相同请求,后端可以考虑增加幂等键(如记录请求 ID),避免生成重复数据。不过在累加更新策略下,重复提交会直接翻倍时长,因此建议前端在提交按钮上做“提交中禁用”处理。
10.3 代码可维护性
当项目体量变大后,把所有路由和数据库逻辑堆在app.py里会变得不好维护。建议按下面方式拆分:
app.py:只保留 Flask 初始化、蓝本注册、启动逻辑。db.py:封装数据库连接和所有 SQL 操作。models.py:定义数据类。api/records.py:阅读记录相关接口。api/stats.py:统计相关接口。services/streak.py:连续天数计算等业务逻辑。
这种分层方式虽然对一个个人工具来说略显“重”,但如果之后有继续扩展的计划,提前做好模块化能节省大量重构时间。
10.4 性能考量
阅读记录项目的性能瓶颈几乎可以忽略,但有几个习惯值得保持:
- 给
read_date字段建索引。本文在初始化脚本中已经做了。 - 查询某个年份数据时,使用
BETWEEN条件限定范围,而不是全表扫描。 - 如果未来数据量增长到几十万条,可以考虑按月分表或迁移到 MySQL,但这是后话。
10.5 隐私与安全
阅读记录本质是个人数据,如果部署到公网服务器,应至少注意这两点:
- 在反向代理层(如 Nginx)配置 Basic Auth 或 IP 白名单,避免陌生人访问你的阅读记录页面。
- 如果后续添加多用户功能,重要接口要引入登录态校验,不要在 URL 中暴露未授权数据和操作接口。
11. 总结
本文完整实现了一个“GitHub 风格阅读热力图”项目。
核心内容包括:
- 用 SQLite 设计阅读记录表,支持日期唯一约束和阅读时长累加更新。
- 用 Flask 提供主页渲染、热力图数据接口、统计指标接口、记录写入接口。
- 用 ECharts Calendar 日历图实现按年展示的阅读热力图。
- 用原生 CSS 还原了 GitHub 贡献图的视觉风格。
- 提供常见问题排查清单和数据备份方案。
你可以在现有代码基础上继续扩展,最推荐的两个方向是:添加 CSV 批量导入能力,以及增加多用户支持。前者让你能快速把历史阅读数据倒入系统,后者让这个工具可以分享给身边的人一起使用。
动手在自己电脑上跑一遍这个项目,然后把阅读记录保存到数据库里,坚持两周后再看热力图的变化。当数据逐渐填满格子时,你会更直观感受到持续积累的力量。