
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 最终技术栈组件选型说明后端框架FlaskPython Web 框架轻量简洁数据库SQLitePython 内置支持无需额外安装前端图表EChartsApache 开源可视化库支持日历热力图前端模板Jinja2Flask 默认模板引擎页面样式原生 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表字段名类型说明idINTEGER PRIMARY KEY AUTOINCREMENT自增主键read_dateTEXT UNIQUE NOT NULL阅读日期格式 YYYY-MM-DDduration_minutesINTEGER NOT NULL当天阅读总时长分钟book_nameTEXT当天在读的书籍名称pagesINTEGER当天阅读页数noteTEXT当天阅读备注或摘录这里有一个关键设计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, typeint, defaultdatetime.now().year) return render_template(index.html, current_yearyear) 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(debugTrue, port5000)5.2 接口说明上面这段代码一共实现了三个接口。GET /是主页入口渲染index.html模板支持通过 URL 参数?year2025查看指定年份的热力图。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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title阅读热力图 - Reading Heatmap/title script srchttps://cdn.jsdelivr.net/npm/echarts5.5.0/dist/echarts.min.js/script link relstylesheet href{{ url_for(static, filenamecss/style.css) }} /head body div classcontainer h1 我的阅读热力图/h1 p classsubtitle记录每一天的阅读时光/p !-- 年份选择 -- div classtoolbar label foryear-select选择年份/label select idyear-select !-- 由 JS 动态填充 -- /select span idtoday-tip/span /div !-- 统计卡片 -- div classstats-row div classstat-card div classstat-value idstat-days0/div div classstat-label累计阅读天数/div /div div classstat-card div classstat-value idstat-minutes0/div div classstat-label阅读总时长分钟/div /div div classstat-card div classstat-value idstat-avg0/div div classstat-label日均阅读分钟/div /div div classstat-card div classstat-value idstat-streak0/div div classstat-label最长连续天数/div /div /div !-- 热力图容器 -- div idheatmap stylewidth: 100%; height: 220px;/div !-- 添加记录表单 -- div classrecord-form h2添加阅读记录/h2 form idrecord-form div classform-row div label forrecord-date日期/label input typedate idrecord-date required /div div label forrecord-minutes阅读时长分钟/label input typenumber idrecord-minutes min1 max1440 required /div div label forrecord-book书名选填/label input typetext idrecord-book placeholder《置身事内》 /div button typesubmit保存记录/button /div /form div idform-message/div /div /div script src{{ url_for(static, filenamejs/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(daysi) # 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 srchttps://cdn.jsdelivr.net/npm/echarts5.5.0/dist/echarts.min.js/script script srchttps://unpkg.com/echarts5.5.0/dist/echarts.min.js/script8.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 批量导入能力以及增加多用户支持。前者让你能快速把历史阅读数据倒入系统后者让这个工具可以分享给身边的人一起使用。动手在自己电脑上跑一遍这个项目然后把阅读记录保存到数据库里坚持两周后再看热力图的变化。当数据逐渐填满格子时你会更直观感受到持续积累的力量。