30天技能追踪器:用Node.js+SQLite构建个人成长可视化工具
1. 项目概述:一个追踪技能成长的实用工具
最近在GitHub上看到一个挺有意思的项目,叫mvanhorn/last30days-skill。光看名字,你大概能猜到它和“技能”以及“最近30天”有关。简单来说,这是一个帮助你追踪和可视化过去30天内,你在特定技能上投入时间与精力的小工具。它不是那种庞大的学习管理系统,更像是一个轻量级的、开发者为自己打造的“技能仪表盘”。
我自己尝试部署和使用了一段时间,发现它解决了一个很实际的问题:我们常常高估自己短期的努力,却低估了长期的坚持。你可能觉得自己最近在学Python,但具体花了多少时间?是均匀分布还是集中在某几天?last30days-skill通过一个简洁的界面,用日历热力图(类似GitHub Contributions图)的方式,直观地告诉你过去30天里,你每天在某个技能上投入的“强度”。这个“强度”可以是学习时长、练习次数,或者任何你自定义的度量单位。
它适合谁呢?我觉得任何有自我提升需求、喜欢量化自己成长过程的人都会觉得有用。特别是程序员、学生、自由职业者,或者任何在进行系统性技能学习(比如学一门新语言、练琴、健身)的朋友。你不用再靠模糊的感觉,而是有清晰的数据告诉你:“看,过去一周我在这件事上持续投入了,或者,我最近三天松懈了。” 这种即时、可视化的反馈,对于保持动力和调整学习节奏非常有帮助。
2. 核心设计思路与技术选型
2.1 为什么是“30天”与“技能”的组合?
这个项目的核心设计哲学非常聚焦:短期、可量化、可视化。选择“30天”这个时间窗口,是心理学和习惯养成理论的一个巧妙应用。30天足够形成一个习惯的雏形,又不会长到让人失去追踪的耐心。它比“年度总结”更敏捷,比“每日记录”更有趋势性。你看到的不再是孤立的一天,而是一个逐渐变化的过程曲线。
“技能”在这里是一个抽象概念。项目并没有限定你必须追踪“Python编程”或“吉他弹奏”。你可以定义任何你想提升的领域,比如“阅读”、“冥想”、“算法练习”。这种灵活性是它的一个巨大优点。技术实现上,它通常将“技能”作为一个独立的实体,每个技能拥有自己的名称、颜色标识和一套独立的时间记录。
背后的技术选型也服务于这个轻量、专注的目标。从项目仓库名mvanhorn/last30days-skill来看,这很可能是一个个人全栈项目。为了实现快速开发和部署,作者大概率会选择以下技术栈:
- 后端/API层:Node.js with Express 或 Python with Flask/FastAPI。这两种方案都能快速搭建RESTful API,处理技能和日期记录的逻辑(增删改查)。考虑到轻量化和JSON处理的便利性,Node.js + Express 是常见选择。
- 数据存储:SQLite 或 PostgreSQL。对于个人使用或小范围应用,SQLite是完美的选择——无需单独数据库服务,一个文件搞定所有数据,部署极其简单。如果考虑未来多用户扩展,PostgreSQL更健壮。
- 前端界面:纯HTML/CSS/JavaScript 或 轻量级框架如Vue.js/React。为了极致轻量和避免构建复杂度,很可能采用前者。核心的日历热力图渲染,会用到SVG或Canvas,配合JavaScript动态计算和填充颜色。
- 部署:由于是静态前端+简单API的结构,可以轻松部署在Vercel、Netlify(前端) + Railway、Render(后端)等现代PaaS平台上,甚至通过Docker容器化后部署到任何云主机。
注意:以上技术栈是基于同类项目常见实践的合理推测。具体到
mvanhorn/last30days-skill,需要查看其源码确认。但其设计目标决定了技术栈必定是简洁、易部署的。
2.2 数据模型与核心逻辑拆解
要理解这个工具如何工作,我们需要拆解其核心的数据模型和业务逻辑。虽然我们看不到源码,但可以推断出它至少包含以下几个核心实体和逻辑:
- 用户(User):最简单的模型可能只支持单用户,数据存储在本地。复杂一点会支持多用户认证。
- 技能(Skill):这是核心实体。每个技能可能有以下字段:
id: 唯一标识name: 技能名称(如“Python学习”)color: 在热力图上显示的颜色(如“#2ecc71”)target_daily_minutes: (可选)每日目标分钟数,用于计算完成度。
- 活动记录(ActivityLog):这是连接“日期”和“技能”的纽带。关键字段:
id: 记录IDskill_id: 关联的技能IDdate: 记录的日期(格式如‘2023-10-27’)value: 当天的“值”。这可以是分钟数(如“45”),也可以是次数(如“3”),甚至是简单的“1”(代表今天做了)。这个设计的灵活性很高。
- 核心计算逻辑:
- 热力图颜色计算:这是前端展示的核心。对于某个技能在特定日期的格子,颜色深浅由
value决定。通常是一个简单的线性或分段函数映射。例如,0分钟为最浅色(#ebedf0),达到或超过目标值(如60分钟)为最深色(#216e39),中间值按比例插值。 - 30天窗口计算:每次加载页面,后端API会根据当前日期,计算出过去30天(或包括今天在内的31天)的日期列表,然后查询每个日期对应技能的活动记录,组装成前端需要的数据结构,通常是
{ date: ‘2023-10-27’, value: 45 }这样的对象数组。
- 热力图颜色计算:这是前端展示的核心。对于某个技能在特定日期的格子,颜色深浅由
这个模型看似简单,但足以支撑起核心的追踪和可视化功能。它的美在于“约定大于配置”——你只需要记录日期和数值,系统自动为你生成有意义的视图。
3. 从零开始实现一个类似的技能追踪器
理解了设计思路后,我们完全可以动手实现一个自己的“last30days-skill”。下面我将以一个Node.js (Express) + SQLite + 纯前端的技术栈为例,带你走一遍核心实现流程。你可以把这个当作一个完整的周末项目。
3.1 环境准备与项目初始化
首先,确保你的系统安装了Node.js(建议版本16+)。然后创建一个新的项目目录并初始化。
mkdir my-skill-tracker && cd my-skill-tracker npm init -y接下来,安装我们需要的依赖。后端我们需要Express框架、SQLite驱动、以及处理日期的库。前端我们暂时用静态文件,稍后直接写HTML/JS。
npm install express sqlite3 better-sqlite3 date-fns cors npm install --save-dev nodemonexpress: Web框架。sqlite3&better-sqlite3: 这里我推荐使用better-sqlite3,它同步API更简单直观,性能也不错。sqlite3作为备用。date-fns: 强大的日期处理库,比原生Date对象好用得多。cors: 方便处理前端跨域请求(如果前后端分离部署)。nodemon: 开发工具,代码变动自动重启服务器。
在package.json的scripts里添加启动命令:
"scripts": { "start": "node server.js", "dev": "nodemon server.js" }3.2 数据库设计与初始化
我们在项目根目录创建一个db.js文件来初始化数据库和表。
// db.js const Database = require(‘better-sqlite3’); // 连接数据库,如果不存在则会创建 const db = new Database(‘./skills.db’, { verbose: console.log }); // verbose模式便于调试 // 创建技能表 db.exec(` CREATE TABLE IF NOT EXISTS skills ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, color TEXT DEFAULT ‘#2ecc71‘, target_value INTEGER DEFAULT 60 -- 假设默认目标值是60分钟 ) `); // 创建活动记录表 db.exec(` CREATE TABLE IF NOT EXISTS activity_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, skill_id INTEGER NOT NULL, date TEXT NOT NULL, -- 存储为 ‘YYYY-MM-DD‘ 格式 value INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (skill_id) REFERENCES skills (id) ON DELETE CASCADE, UNIQUE(skill_id, date) -- 确保同一天同一技能只有一条记录 ) `); // 创建索引以提高查询速度 db.exec(‘CREATE INDEX IF NOT EXISTS idx_log_skill_date ON activity_logs(skill_id, date)‘); console.log(‘数据库表初始化完成!‘); module.exports = db;这里有几个关键设计点:
- 唯一约束:
activity_logs表的UNIQUE(skill_id, date)约束非常重要。它保证了对于同一个技能,同一天你只能有一条记录,避免数据重复。当你更新某天的投入时,执行的是INSERT OR REPLACE或先删除后插入的操作。 - 日期格式:使用
TEXT类型存储 ‘YYYY-MM-DD‘ 格式的日期,便于排序和比较,也方便与JavaScript的Date对象互转。 - 外键约束:
FOREIGN KEY确保了数据完整性,删除一个技能时,其对应的所有活动记录也会被自动删除(ON DELETE CASCADE)。
3.3 后端API接口实现
接下来创建server.js,实现核心的RESTful API。
// server.js const express = require(‘express‘); const cors = require(‘cors‘); const { format, subDays, eachDayOfInterval, parseISO } = require(‘date-fns‘); const db = require(‘./db‘); const app = express(); const PORT = process.env.PORT || 3000; // 中间件 app.use(cors()); // 允许跨域 app.use(express.json()); // 解析JSON请求体 app.use(express.static(‘public‘)); // 托管前端静态文件 // 1. 获取所有技能列表 app.get(‘/api/skills‘, (req, res) => { try { const skills = db.prepare(‘SELECT * FROM skills ORDER BY id‘).all(); res.json(skills); } catch (err) { res.status(500).json({ error: err.message }); } }); // 2. 创建新技能 app.post(‘/api/skills‘, (req, res) => { const { name, color, target_value } = req.body; if (!name) { return res.status(400).json({ error: ‘技能名称是必填项‘ }); } try { const stmt = db.prepare(‘INSERT INTO skills (name, color, target_value) VALUES (?, ?, ?)‘); const info = stmt.run(name, color || ‘#2ecc71‘, target_value || 60); res.json({ id: info.lastInsertRowid, name, color, target_value }); } catch (err) { // 如果是唯一约束冲突(重复技能名) if (err.code === ‘SQLITE_CONSTRAINT‘) { return res.status(409).json({ error: ‘技能名称已存在‘ }); } res.status(500).json({ error: err.message }); } }); // 3. 获取某个技能过去30天的活动数据(核心接口) app.get(‘/api/skills/:skillId/last30days‘, (req, res) => { const { skillId } = req.params; const today = new Date(); const thirtyDaysAgo = subDays(today, 29); // 过去30天包含今天,所以是29天前 // 生成过去30天的日期数组 const dateRange = eachDayOfInterval({ start: thirtyDaysAgo, end: today }).map(d => format(d, ‘yyyy-MM-dd‘)); try { // 查询这个技能在这段时间内已有的记录 const stmt = db.prepare(` SELECT date, value FROM activity_logs WHERE skill_id = ? AND date BETWEEN ? AND ? ORDER BY date `); const existingLogs = stmt.all(skillId, format(thirtyDaysAgo, ‘yyyy-MM-dd‘), format(today, ‘yyyy-MM-dd‘)); // 将查询结果转换为按日期索引的对象,便于快速查找 const logMap = {}; existingLogs.forEach(log => { logMap[log.date] = log.value; }); // 构建返回数据:为日期范围内的每一天都提供一个值,没有记录的为0 const result = dateRange.map(date => ({ date, value: logMap[date] || 0 // 如果当天有记录则用记录值,否则为0 })); // 同时返回技能信息,用于前端显示颜色和目标值 const skill = db.prepare(‘SELECT * FROM skills WHERE id = ?‘).get(skillId); if (!skill) { return res.status(404).json({ error: ‘技能未找到‘ }); } res.json({ skill, activityData: result }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 4. 更新或创建某一天的活动记录 app.post(‘/api/activity‘, (req, res) => { const { skill_id, date, value } = req.body; if (!skill_id || !date || value === undefined) { return res.status(400).json({ error: ‘skill_id, date 和 value 是必填项‘ }); } // 简单验证日期格式 (YYYY-MM-DD) const dateRegex = /^\d{4}-\d{2}-\d{2}$/; if (!dateRegex.test(date)) { return res.status(400).json({ error: ‘日期格式必须为 YYYY-MM-DD‘ }); } try { // 使用 INSERT OR REPLACE 语法,简化“有则更新,无则插入”的逻辑 const stmt = db.prepare(` INSERT OR REPLACE INTO activity_logs (skill_id, date, value) VALUES (?, ?, ?) `); const info = stmt.run(skill_id, date, parseInt(value, 10) || 0); res.json({ success: true, changes: info.changes }); } catch (err) { // 如果外键约束失败(skill_id不存在) if (err.code === ‘SQLITE_CONSTRAINT‘ && err.message.includes(‘FOREIGN KEY‘)) { return res.status(404).json({ error: ‘对应的技能不存在‘ }); } res.status(500).json({ error: err.message }); } }); // 启动服务器 app.listen(PORT, () => { console.log(`技能追踪器后端服务运行在 http://localhost:${PORT}`); });这个后端提供了四个核心接口,足够支撑起基本功能。最复杂的是第三个接口/api/skills/:skillId/last30days,它完成了核心的数据组装逻辑:生成日期范围,合并数据库中的已有记录,返回一个完整的、每天都有值(0或实际值)的数组。这极大简化了前端的处理逻辑。
3.4 前端界面与热力图渲染
现在,我们在项目根目录创建一个public文件夹,并在里面放置前端文件。首先是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>我的30天技能追踪器</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, ‘Segoe UI‘, sans-serif; line-height: 1.6; padding: 20px; background: #f6f8fa; color: #24292e; } .container { max-width: 1000px; margin: 0 auto; } header { margin-bottom: 2rem; } h1 { margin-bottom: 0.5rem; } .subtitle { color: #57606a; } .skill-selector { margin-bottom: 2rem; } #skillList { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 1rem; } .skill-btn { padding: 8px 16px; border: 1px solid #d0d7de; border-radius: 6px; background: white; cursor: pointer; transition: all 0.2s; } .skill-btn:hover { background: #f6f8fa; border-color: #0969da; } .skill-btn.active { background: #0969da; color: white; border-color: #0969da; } #newSkillForm { display: flex; gap: 10px; margin-top: 1rem; } #newSkillForm input { padding: 8px 12px; border: 1px solid #d0d7de; border-radius: 6px; flex-grow: 1; } #newSkillForm button { padding: 8px 16px; background: #2da44e; color: white; border: none; border-radius: 6px; cursor: pointer; } .dashboard { display: grid; grid-template-columns: 2fr 1fr; gap: 2rem; } @media (max-width: 768px) { .dashboard { grid-template-columns: 1fr; } } .heatmap-container { background: white; padding: 1.5rem; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .controls { background: white; padding: 1.5rem; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } #heatmap { margin-top: 1rem; } .day-cell { width: 20px; height: 20px; border-radius: 3px; margin: 2px; display: inline-block; background-color: #ebedf0; } .month-label { margin-top: 10px; color: #57606a; font-size: 0.9em; } .legend { display: flex; align-items: center; gap: 5px; margin-top: 1rem; font-size: 0.8em; color: #57606a; } .legend-item { display: flex; align-items: center; gap: 3px; } .value-input-section { margin-top: 2rem; } #selectedDate { font-weight: bold; margin-bottom: 0.5rem; } #valueInput { width: 100%; padding: 10px; margin-bottom: 1rem; border: 1px solid #d0d7de; border-radius: 6px; } #saveBtn { width: 100%; padding: 10px; background: #0969da; color: white; border: none; border-radius: 6px; cursor: pointer; } #saveBtn:disabled { background: #8c959f; cursor: not-allowed; } </style> </head> <body> <div class="container"> <header> <h1>📈 30天技能追踪器</h1> <p class="subtitle">量化你的每一次进步,让坚持看得见。</p> </header> <main> <div class="skill-selector"> <h3>选择或创建技能</h3> <div id="skillList"><!-- 技能按钮将通过JS动态加载 --></div> <form id="newSkillForm"> <input type="text" id="skillNameInput" placeholder="输入新技能名称,如‘Python学习‘" required> <input type="color" id="skillColorInput" value="#2ecc71" title="选择技能颜色"> <button type="submit">创建技能</button> </form> </div> <div class="dashboard"> <div class="heatmap-container"> <h3 id="currentSkillTitle">请选择一个技能</h3> <div id="heatmap"><!-- 热力图将通过JS动态生成 --></div> <div class="legend" id="legend"><!-- 图例动态生成 --></div> </div> <div class="controls"> <h3>记录今日投入</h3> <p>点击热力图上的任意一天,可以记录或修改那天的投入值。</p> <div class="value-input-section"> <div id="selectedDate">未选择日期</div> <input type="number" id="valueInput" placeholder="输入数值(如分钟数)" min="0" step="1"> <button id="saveBtn" disabled>保存记录</button> </div> <div style="margin-top: 2rem; font-size: 0.9em; color: #57606a;"> <p><strong>使用提示:</strong></p> <ul style="padding-left: 1.2rem;"> <li>颜色越深,代表当天投入越多。</li> <li>数值可以是分钟、次数或任何你自定义的单位。</li> <li>数据保存在你的浏览器本地,清空缓存会丢失数据。(注:我们当前实现的后端会将数据存入数据库)</li> </ul> </div> </div> </div> </main> </div> <script src="app.js"></script> </body> </html>然后是核心的public/app.js文件,负责所有交互逻辑。
// public/app.js document.addEventListener(‘DOMContentLoaded‘, function() { const API_BASE_URL = ‘http://localhost:3000/api‘; // 根据你的后端地址调整 let currentSkillId = null; let currentSelectedDate = null; let currentSkillData = null; // 存储当前技能的信息和活动数据 // DOM 元素 const skillListEl = document.getElementById(‘skillList‘); const newSkillForm = document.getElementById(‘newSkillForm‘); const skillNameInput = document.getElementById(‘skillNameInput‘); const skillColorInput = document.getElementById(‘skillColorInput‘); const currentSkillTitleEl = document.getElementById(‘currentSkillTitle‘); const heatmapEl = document.getElementById(‘heatmap‘); const legendEl = document.getElementById(‘legend‘); const selectedDateEl = document.getElementById(‘selectedDate‘); const valueInput = document.getElementById(‘valueInput‘); const saveBtn = document.getElementById(‘saveBtn‘); // 初始化:加载所有技能 fetchSkills(); // 1. 获取并渲染技能列表 async function fetchSkills() { try { const response = await fetch(`${API_BASE_URL}/skills`); if (!response.ok) throw new Error(‘获取技能列表失败‘); const skills = await response.json(); renderSkillButtons(skills); // 默认选中第一个技能(如果有) if (skills.length > 0 && !currentSkillId) { selectSkill(skills[0].id); } } catch (error) { console.error(‘Error:‘, error); skillListEl.innerHTML = ‘<p style="color: #cf222e;">加载技能列表失败,请检查后端服务。</p>‘; } } function renderSkillButtons(skills) { skillListEl.innerHTML = ‘‘; skills.forEach(skill => { const btn = document.createElement(‘button‘); btn.className = ‘skill-btn‘; if (skill.id === currentSkillId) btn.classList.add(‘active‘); btn.style.borderLeftColor = skill.color; btn.style.borderLeftWidth = ‘4px‘; btn.textContent = skill.name; btn.onclick = () => selectSkill(skill.id); skillListEl.appendChild(btn); }); } // 2. 创建新技能 newSkillForm.addEventListener(‘submit‘, async function(e) { e.preventDefault(); const name = skillNameInput.value.trim(); const color = skillColorInput.value; if (!name) return; try { const response = await fetch(`${API_BASE_URL}/skills`, { method: ‘POST‘, headers: { ‘Content-Type‘: ‘application/json‘ }, body: JSON.stringify({ name, color, target_value: 60 }) }); if (!response.ok) { const err = await response.json(); alert(err.error || ‘创建失败‘); return; } const newSkill = await response.json(); skillNameInput.value = ‘‘; // 清空输入框 fetchSkills(); // 重新加载技能列表 selectSkill(newSkill.id); // 自动选中新创建的技能 } catch (error) { console.error(‘Error:‘, error); alert(‘创建技能时发生网络错误‘); } }); // 3. 选择技能并加载其30天数据(核心函数) async function selectSkill(skillId) { currentSkillId = skillId; currentSelectedDate = null; // 重置选中日期 valueInput.value = ‘‘; saveBtn.disabled = true; selectedDateEl.textContent = ‘未选择日期‘; // 更新按钮激活状态 document.querySelectorAll(‘.skill-btn‘).forEach(btn => btn.classList.remove(‘active‘)); const activeBtn = Array.from(document.querySelectorAll(‘.skill-btn‘)).find(btn => btn.onclick && btn.onclick.name === ‘selectSkill‘ || btn.textContent.includes(‘...‘)); // 简化查找 // 更可靠的方式:在创建按钮时存储skillId在data属性中 document.querySelectorAll(‘.skill-btn‘).forEach(btn => { if (btn.getAttribute(‘data-skill-id‘) == skillId) btn.classList.add(‘active‘); }); try { const response = await fetch(`${API_BASE_URL}/skills/${skillId}/last30days`); if (!response.ok) throw new Error(‘获取技能数据失败‘); currentSkillData = await response.json(); renderSkillTitle(currentSkillData.skill); renderHeatmap(currentSkillData.activityData, currentSkillData.skill); renderLegend(currentSkillData.skill.target_value); } catch (error) { console.error(‘Error:‘, error); heatmapEl.innerHTML = ‘<p style="color: #cf222e;">加载数据失败。</p>‘; } } function renderSkillTitle(skill) { currentSkillTitleEl.innerHTML = `<span style="color: ${skill.color}">●</span> ${skill.name} - 过去30天记录`; } // 4. 渲染热力图(核心可视化) function renderHeatmap(activityData, skill) { heatmapEl.innerHTML = ‘‘; if (!activityData || activityData.length === 0) { heatmapEl.innerHTML = ‘<p>暂无数据</p>‘; return; } // 按周分组,便于布局(类似GitHub贡献图) const weeks = []; for (let i = 0; i < activityData.length; i += 7) { weeks.push(activityData.slice(i, i + 7)); } // 计算颜色强度函数 const getColorIntensity = (value, maxTarget) => { if (value <= 0) return 0; const normalized = Math.min(value / maxTarget, 1); // 值超过目标值也按1算 // 将强度分为5个等级 (0-4) return Math.floor(normalized * 4); }; const colorLevels = [ ‘#ebedf0‘, // 等级0: 无数据 ‘#9be9a8‘, // 等级1 ‘#40c463‘, // 等级2 ‘#30a14e‘, // 等级3 ‘#216e39‘ // 等级4 ]; weeks.forEach(weekData => { const weekContainer = document.createElement(‘div‘); weekData.forEach(day => { const dayCell = document.createElement(‘div‘); dayCell.className = ‘day-cell‘; dayCell.title = `${day.date}: ${day.value} 单位`; // 鼠标悬停提示 const intensity = getColorIntensity(day.value, skill.target_value); dayCell.style.backgroundColor = colorLevels[intensity]; // 添加点击事件 dayCell.onclick = () => { // 更新选中状态 document.querySelectorAll(‘.day-cell‘).forEach(cell => cell.style.outline = ‘‘); dayCell.style.outline = ‘2px solid #0969da‘; currentSelectedDate = day.date; selectedDateEl.textContent = `选中日期: ${day.date}`; valueInput.value = day.value; saveBtn.disabled = false; valueInput.focus(); }; weekContainer.appendChild(dayCell); }); heatmapEl.appendChild(weekContainer); }); } // 5. 渲染图例 function renderLegend(targetValue) { legendEl.innerHTML = ‘<span>图例: </span>‘; const colorLevels = [‘#ebedf0‘, ‘#9be9a8‘, ‘#40c463‘, ‘#30a14e‘, ‘#216e39‘]; const labels = [‘无记录‘, `少 (<${Math.floor(targetValue/4)})`, `中 (<${Math.floor(targetValue/2)})`, `多 (<${Math.floor(targetValue*3/4)})`, `极多 (>=${Math.floor(targetValue*3/4)})`]; colorLevels.forEach((color, idx) => { const item = document.createElement(‘div‘); item.className = ‘legend-item‘; item.innerHTML = ` <div style="width: 15px; height: 15px; background: ${color}; border-radius: 2px;"></div> <span>${labels[idx]}</span> `; legendEl.appendChild(item); }); } // 6. 保存/更新活动记录 saveBtn.addEventListener(‘click‘, async function() { if (!currentSkillId || !currentSelectedDate) { alert(‘请先选择技能和日期‘); return; } const value = parseInt(valueInput.value, 10); if (isNaN(value) || value < 0) { alert(‘请输入有效的非负数‘); return; } try { const response = await fetch(`${API_BASE_URL}/activity`, { method: ‘POST‘, headers: { ‘Content-Type‘: ‘application/json‘ }, body: JSON.stringify({ skill_id: currentSkillId, date: currentSelectedDate, value: value }) }); if (!response.ok) { const err = await response.json(); alert(err.error || ‘保存失败‘); return; } alert(‘保存成功!‘); // 重新加载当前技能的数据,更新热力图 selectSkill(currentSkillId); } catch (error) { console.error(‘Error:‘, error); alert(‘网络错误,保存失败‘); } }); // 为输入框添加回车保存支持 valueInput.addEventListener(‘keyup‘, function(e) { if (e.key === ‘Enter‘ && !saveBtn.disabled) { saveBtn.click(); } }); });至此,一个完整可用的“Last 30 Days Skill Tracker”就实现了。你可以运行npm run dev启动后端,然后在浏览器打开http://localhost:3000访问前端页面。创建技能,点击热力图上的日期格子,输入数值并保存,就能看到颜色实时变化。
4. 部署、优化与扩展思路
4.1 本地运行与简易部署
要让这个项目真正用起来,你需要部署它。这里提供几个从易到难的方案:
全栈一体化部署(最简单):由于我们使用了
app.use(express.static(‘public‘)),后端同时服务于前端静态文件。你可以直接将整个项目文件夹上传到任何支持Node.js的PaaS平台,如Render、Railway或Fly.io。- 在这些平台创建新Web Service,关联你的Git仓库。
- 构建命令填
npm install,启动命令填npm start。 - 平台会自动分配一个公网URL,你就能在任何地方访问了。
注意:SQLite数据库文件 (
skills.db) 会被写入到服务器的文件系统。在PaaS平台上,这个文件系统可能是临时的(ephemeral),意味着重启服务后数据可能会丢失。对于个人轻度使用,这或许可以接受。若要持久化,需使用平台提供的持久化存储卷,或改用云数据库。
前后端分离部署(更灵活):
- 前端:将
public目录下的文件部署到Vercel、Netlify或GitHub Pages。需要修改app.js中的API_BASE_URL为你的后端公网地址。 - 后端:将后端代码(排除
public文件夹)部署到Railway、Render或任何云服务器。需要配置环境变量(如端口)并确保安装了依赖。 - 这种部署需要处理跨域(CORS)。我们的代码中已经使用了
cors中间件,默认允许所有来源,在生产环境中建议配置具体的来源以增强安全。
- 前端:将
Docker容器化(最规范): 创建一个
Dockerfile,可以确保环境一致性。# Dockerfile FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . EXPOSE 3000 CMD ["node", "server.js"]然后构建镜像并推送到Docker Hub,即可在任何支持Docker的环境(如自有VPS、AWS ECS等)中运行。
4.2 常见问题与排查技巧
在实际使用和部署中,你可能会遇到以下问题:
前端无法连接到后端(跨域错误):
- 现象:浏览器控制台报错
Access-Control-Allow-Origin。 - 排查:确保后端
cors中间件已启用,且前端API_BASE_URL配置正确。如果前后端分离部署,后端需要明确配置允许前端的域名。 - 解决:在后端
server.js中,将app.use(cors())替换为更精确的配置,例如app.use(cors({ origin: ‘https://你的前端域名.com‘ }))。
- 现象:浏览器控制台报错
数据库操作失败或数据不更新:
- 现象:点击保存后,提示成功但热力图没变化,或直接报错。
- 排查:
- 检查后端控制台是否有SQL错误输出。
- 检查
activity_logs表的UNIQUE(skill_id, date)约束。如果你尝试插入重复数据,INSERT OR REPLACE应该能正常工作,但需确保传入的skill_id和date格式正确。 - 确认数据库文件 (
skills.db) 有写入权限。在Linux服务器上,可能需要chmod命令修改权限。
- 解决:可以手动用SQLite命令行工具连接数据库,执行
SELECT * FROM activity_logs;查看数据是否真的写入。
热力图颜色不变化或显示不正确:
- 现象:所有格子一个颜色,或者颜色与数值不匹配。
- 排查:
- 打开浏览器开发者工具(F12)的“网络(Network)”标签,查看
/api/skills/:id/last30days这个接口的返回数据。确认activityData数组里每个对象的value字段是否正确。 - 检查
renderHeatmap函数中的getColorIntensity函数,确认targetValue(技能目标值)是否正确传入。目标值太小会导致所有非零值都显示为最深色。 - 检查
colorLevels数组定义是否正确。
- 打开浏览器开发者工具(F12)的“网络(Network)”标签,查看
- 解决:在
renderHeatmap函数里添加console.log(‘Target Value:‘, skill.target_value, ‘Data:‘, activityData)进行调试。
部署后访问空白页或404:
- 现象:能打开页面,但技能列表加载不出来,或者页面是空的。
- 排查:
- 首先检查后端服务是否真的在运行。在服务器上执行
curl http://localhost:3000/api/skills(将端口换成你的实际端口)。 - 如果后端运行正常,检查前端静态文件是否被正确托管。我们的代码中,
app.use(express.static(‘public‘))意味着index.html应该在根路径。直接访问你的域名,应该能显示页面。 - 查看浏览器控制台和服务器日志,寻找具体的错误信息。
- 首先检查后端服务是否真的在运行。在服务器上执行
- 解决:确保在部署平台正确设置了启动命令和工作目录。
4.3 功能扩展与优化建议
基础版本跑通后,你可以根据自己的需求进行扩展,让它变得更强大:
- 用户系统:目前是单用户。可以添加用户注册/登录(使用JWT或Session),让
skills和activity_logs表关联user_id,实现多用户数据隔离。 - 数据持久化与备份:如果使用SQLite,定期将
skills.db文件备份到云存储(如AWS S3、Backblaze B2)或另一台服务器。也可以直接迁移到云数据库(如PlanetScale、Supabase的PostgreSQL)。 - 更丰富的可视化:
- 趋势折线图:在热力图下方增加一个折线图,显示过去30天投入值的趋势。
- 周/月统计:显示“本周总投入”、“本月平均每日投入”等统计卡片。
- 多技能对比:在一个视图里并排显示多个技能的热力图,对比时间分配。
- 数据输入方式多样化:
- 快捷记录:在首页提供“为当前技能记录今天”的快速输入框,省去点击热力图的步骤。
- 批量导入:支持通过CSV文件导入历史数据。
- 移动端优化:开发响应式设计,或封装成PWA(渐进式Web应用),方便手机端快速记录。
- 提醒与激励功能:
- 每日提醒:集成邮件或消息推送(如Telegram Bot),在固定时间提醒你记录。
- 成就系统:定义一些规则,如“连续7天投入”、“单日投入破纪录”,达成后给予虚拟徽章。
- 数据分析与导出:
- 生成报告:支持生成过去30天、90天的PDF或图片总结报告。
- 数据导出:将所有记录以JSON或CSV格式导出,用于在其他工具中分析。
这个项目的魅力在于它从一个极简的痛点(“我最近在某个技能上投入了多少?”)出发,用不复杂的技术实现了一个有价值的工具。你可以把它当作一个练手项目,也可以在此基础上不断添加功能,打造一个完全符合你自己习惯的个性化成长追踪系统。最重要的是,通过亲手构建和使用它,你本身就在践行“持续学习与构建”这项宝贵的技能。
