1. 项目概述:一个纯粹、轻量的任务看板
最近在整理个人工作流时,我一直在寻找一个足够简单、但又足够强大的任务管理工具。市面上的 Trello、Notion 或者一些国产软件功能固然强大,但要么太重,要么需要联网,要么充斥着我不需要的复杂功能。我的核心需求很简单:一个能快速启动、数据完全由我掌控、界面清爽、并且能和我已有的命令行工具(比如我常用的 OpenClaw)打通的看板。于是,我动手用 Express 和 SQLite 搭建了这个名为 TodoBoard 的轻量级任务看板。
它本质上是一个零前端框架的 Web 应用,后端是 Node.js + Express,数据库是单文件的 SQLite,前端就是最朴素的 HTML、CSS 和 JavaScript。没有 Webpack,没有 React/Vue,没有复杂的构建步骤,git clone下来npm install && npm start就能跑起来。整个项目的哲学就是“够用就好”,专注于任务管理的核心功能:看板拖拽、列表视图、任务详情、搜索过滤、以及优先级和标签。特别值得一提的是,它原生集成了 OpenClaw 技能,这意味着你可以直接在聊天窗口里添加待办、查询进度,甚至设置定时提醒,把任务管理无缝嵌入到你的命令行工作流中。如果你也厌倦了臃肿的 SaaS 工具,想要一个完全受控、可随意定制、部署简单的私人看板,那么这个项目或许正是你需要的。
2. 核心设计思路与技术选型
2.1 为什么选择“无框架”前端与极简技术栈?
在决定技术栈时,我首要考虑的是维护复杂度和启动成本。这个工具主要是我自用,也可能分享给几个同样追求效率的同事,因此它不需要考虑千人级别的并发,也不需要支持 IE 浏览器。基于此,我排除了引入 React 或 Vue 等现代前端框架的方案。原因有三:
第一,依赖与构建的简化。一旦引入前端框架,就意味着需要引入对应的构建工具(如 Webpack、Vite),管理依赖更新,处理可能存在的版本冲突。对于这样一个轻量级工具,这些开销是不必要的。纯原生技术(Vanilla JS)意味着任何懂前端基础的开发者都能毫无障碍地理解和修改代码。
第二,极致的性能与响应速度。没有虚拟 DOM 的 Diff 过程,所有 DOM 操作都是直接的。在看板拖拽、任务状态频繁更新的场景下,直接操作 DOM 反而更简单、更可预测。配合现代浏览器良好的 CSS Grid 和 Flexbox 支持,实现一个响应式、流畅的拖拽界面并不困难。
第三,部署与迁移的便捷性。整个应用就是一堆静态文件(HTML, CSS, JS)加一个 Node.js 服务端。你可以把它扔在任何能运行 Node.js 的环境里,甚至用pm2托管后,用 Nginx 简单配个反向代理就能对外服务。数据库是单个 SQLite 文件,备份和迁移就是复制一个文件的事。
后端选择 Express + better-sqlite3 也是出于类似考量。Express 是 Node.js 生态最成熟、最轻量的 Web 框架,足以应对 RESTful API 的开发。而 better-sqlite3 相比默认的sqlite3包,提供了同步 API(在 Web 服务器这种 I/O 密集场景下,配合 Express 的异步处理并无阻塞风险),并且性能更高,语法更友好。
注意:虽然 better-sqlite3 使用同步 API,但在 Express 的异步路由处理函数中直接调用是安全的,因为 SQLite 操作本质上是磁盘 I/O,better-sqlite3 底层使用了 Node.js 的 worker 线程来避免阻塞主事件循环。但对于超高并发的生产场景,仍需进行压力测试。
2.2 数据模型设计:如何用一张表支撑看板?
很多看板工具会为“列表”(List)和“卡片”(Card)分别建表。但为了极致简单,TodoBoard 的核心数据模型只用了一张todos表。这听起来可能有些激进,但通过合理的字段设计,完全能够满足需求。
-- 简化的表结构核心字段 CREATE TABLE todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, -- 对应详情面板的“需求” notes TEXT, -- 对应详情面板的“解决方案/笔记” status TEXT NOT NULL DEFAULT 'todo' CHECK(status IN ('todo', 'doing', 'done')), category TEXT, -- 任务分类/标签 priority INTEGER DEFAULT 0, -- 优先级,数字越大越优先 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, started_at DATETIME, completed_at DATETIME, due_date DATETIME -- 截止日期,用于计算是否逾期 );关键设计点解析:
status字段替代多表关联:todo,doing,done三个状态直接对应看板的三个列。查询某个列的任务只需WHERE status = ?。这避免了多表 JOIN 的复杂度,在数据量不大时(个人或小团队使用),性能完全不是问题。- 时间戳自动化:
created_at是固定的创建时间。started_at和completed_at则由业务逻辑更新。当任务从todo拖拽到doing时,如果started_at为空,则自动设置为当前时间。当任务从doing拖拽到done时,自动设置completed_at。这为后续的时间追踪和统计提供了数据基础。 priority与category:优先级用整数表示,方便排序。在前端,高优先级(例如priority >= 3)的任务会有视觉上的高亮(如红色边框或角标)。category字段存储标签文本,前端会将其渲染为一个个“药丸”状(Pill)的标签。- 评论功能:评论被设计为独立的
comments表,通过todo_id与todos表关联。这样保证了任务主体信息的简洁,也符合评论可能较多的场景。
这种单表核心的设计,使得 API 非常简洁。大部分操作都围绕/api/todos这个端点进行,通过查询参数来实现过滤(?status=doing、?category=开发、?q=关键词),后端逻辑清晰明了。
3. 前端实现详解:原生JS下的流畅看板
3.1 看板视图与拖拽交互的实现
看板(Kanban)是核心交互界面。我们用 CSS Grid 来布局三列,每一列是一个status的容器。
<div class="kanban-board"> <div class="column">.kanban-board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; padding: 1rem; } .column { background: var(--bg-secondary); border-radius: 8px; padding: 1rem; min-height: 60vh; }拖拽功能使用 HTML5 原生的 Drag and Drop API。虽然业界有 Sortable.js 这样的优秀库,但为了保持零依赖,我选择了原生实现。关键在于处理好以下几个事件:
dragstart: 在任务卡片被拖拽时触发。我们需要在event.dataTransfer.setData中存储被拖拽任务的id。dragover: 当拖拽元素经过某个列容器时触发。必须在这里preventDefault()才能允许放置。drop: 当在列容器中松开鼠标时触发。这里我们从event.dataTransfer.getData获取任务id,并向服务器发送PUT /api/todos/:id请求,更新该任务的status为当前列的>function handleDragOver(e) { e.preventDefault(); if (!e.currentTarget.classList.contains('drag-over')) { e.currentTarget.classList.add('drag-over'); } } function handleDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('drag-over'); const taskId = e.dataTransfer.getData('text/plain'); const newStatus = e.currentTarget.parentElement.dataset.status; // 调用API更新任务状态 updateTaskStatus(taskId, newStatus); }实操心得:原生拖拽 API 在桌面端体验不错,但在移动端支持有限。因此,TodoBoard 的移动端适配主要依赖于响应式布局和点击操作。在手机屏幕上,看板可能会变为垂直堆叠,或者通过下拉菜单来改变任务状态。这是“渐进增强”思想的体现:在高级浏览器上享受拖拽的便利,在基础设备上保证功能的可用性。
3.2 列表视图与详情面板的协同
除了看板视图,另一个高频使用的视图是“列表视图”。它以一个表格的形式展示所有任务,方便快速扫描和比较。
列表视图的 HTML 结构就是一个
<table>,通过 JavaScript 动态填充行(<tr>)。每一行包含任务的核心信息:ID、标题、优先级标签、分类标签、状态、创建时间和截止时间。点击某一行,会触发右侧详情面板的更新。详情面板是一个固定在右侧或下层的侧边栏,用于展示和编辑任务的完整信息。它被划分为几个区域:
- 需求描述:任务的详细描述,来自
description字段。 - 笔记/解决方案:记录任务执行过程中的笔记或最终解决方案,来自
notes字段。这是一个纯文本区域,但渲染时会保留换行符。 - 评论线程:一个按时间倒序排列的评论列表,每条评论显示作者(当前版本固定为“用户”)、时间和内容。底部有一个文本框用于添加新评论,提交后会
POST /api/todos/:id/comments。 - 时间线:直观展示任务的
created_at、started_at、completed_at三个关键时间点,让任务的生命周期一目了然。
列表视图和详情面板的联动,是通过共享状态实现的。前端维护一个当前选中任务 ID 的变量。当在列表视图中点击一行,或在看板中点击一个卡片,就会更新这个 ID,并触发一个函数去获取该任务的完整数据(包括评论),然后渲染到详情面板中。
let selectedTaskId = null; async function loadTaskDetail(taskId) { selectedTaskId = taskId; const response = await fetch(`/api/todos/${taskId}`); const taskDetail = await response.json(); renderDetailPanel(taskDetail); // 同时高亮列表视图或看板中的对应项 highlightActiveTask(taskId); }这种设计使得用户可以在看板上宏观把控,在列表上快速筛选,再通过详情面板进行深度操作(编辑、评论),三者形成了一个高效的工作闭环。
4. 后端API设计与数据持久化
4.1 RESTful API 的构建与错误处理
后端 API 遵循经典的 RESTful 设计风格,围绕
todos和comments两个资源展开。我使用 Express 的 Router 来组织路由,使代码结构清晰。// routes/todos.js const express = require('express'); const router = express.Router(); const db = require('../db'); // better-sqlite3 实例 // 获取任务列表(带过滤) router.get('/', (req, res) => { const { q, category, status } = req.query; let sql = `SELECT * FROM todos WHERE 1=1`; const params = []; if (q) { sql += ` AND (title LIKE ? OR description LIKE ?)`; params.push(`%${q}%`, `%${q}%`); } if (category) { sql += ` AND category = ?`; params.push(category); } if (status) { sql += ` AND status = ?`; params.push(status); } sql += ` ORDER BY priority DESC, created_at DESC`; try { const stmt = db.prepare(sql); const todos = stmt.all(...params); res.json(todos); } catch (error) { console.error('Database error:', error); res.status(500).json({ error: 'Internal server error' }); } }); // 创建新任务 router.post('/', (req, res) => { const { title, description, category, priority, due_date } = req.body; if (!title) { return res.status(400).json({ error: 'Title is required' }); } const sql = `INSERT INTO todos (title, description, category, priority, due_date) VALUES (?, ?, ?, ?, ?)`; try { const info = db.prepare(sql).run(title, description, category, priority, due_date); res.status(201).json({ id: info.lastInsertRowid, message: 'Task created' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ... 其他 PUT, DELETE 路由关键设计点:
- 过滤查询:
GET /api/todos支持q(关键词全文搜索)、category、status多个查询参数。它们被动态拼接到 SQL 语句的WHERE子句中。这里使用了WHERE 1=1的小技巧,可以方便地后续拼接AND条件而无需判断是否是第一个条件。 - 参数化查询:所有用户输入(查询参数、请求体)在拼接 SQL 时都使用
?占位符和参数数组,这是防止 SQL 注入攻击的必备措施。better-sqlite3 的.run()和.all()方法天然支持参数化查询。 - 统一的错误处理:在每个路由的
try...catch中捕获数据库错误,返回 500 状态码和错误信息。对于客户端输入错误(如缺少必填字段),返回 400 状态码和明确的错误提示。在生产环境中,你可能希望隐藏详细的数据库错误信息,只返回通用的错误提示。 - 时间戳的自动管理:对于
started_at和completed_at,逻辑放在更新状态的PUT接口中。当检测到状态从todo变为doing且started_at为空时,自动设置started_at = CURRENT_TIMESTAMP。从doing变为done时,设置completed_at。
4.2 统计接口与数据聚合
GET /api/stats接口提供了一些关键的聚合数据,用于仪表盘或 OpenClaw 的定时提醒。它通过一次查询返回多个统计值,比前端分别计算高效得多。-- 对应的 SQL 查询 SELECT COUNT(*) as total, SUM(CASE WHEN status = 'todo' THEN 1 ELSE 0 END) as todo, SUM(CASE WHEN status = 'doing' THEN 1 ELSE 0 END) as doing, SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done, SUM(CASE WHEN status != 'done' AND due_date IS NOT NULL AND due_date < DATE('now') THEN 1 ELSE 0 END) as overdue FROM todos;这个查询使用了
CASE WHEN条件聚合,在一行内统计了总数、各状态数量以及逾期任务数。逾期任务的逻辑是:状态不是“已完成”,且截止日期不为空,且截止日期早于今天。这个接口的输出是一个简单的 JSON 对象:
{ "total": 42, "todo": 10, "doing": 5, "done": 27, "overdue": 3 }前端可以将其展示在页眉或仪表盘上,而 OpenClaw 的定时任务则可以定期调用此接口,如果
overdue > 0,就通过飞书、钉钉等 Webhook 发送提醒消息,实现自动化催办。5. 与 OpenClaw 的深度集成
5.1 OpenClaw Skill 的工作原理
OpenClaw 是一个本地运行的、可扩展的 CLI 助手工具。它的技能(Skill)机制允许用户通过自然语言命令来操作外部系统。TodoBoard 提供的技能,本质上是一组预定义的命令模板和对应的 API 调用逻辑。
技能文件通常包含:
skill.yml: 技能元数据,如名称、描述、触发命令模式。index.js: 核心逻辑,解析用户输入,调用 TodoBoard 的 API,格式化返回结果。
例如,当你在 OpenClaw 中输入“帮我加个待办:明天之前把 README 写完”,OpenClaw 会匹配到“加个待办”这个模式,并提取出“明天之前把 README 写完”作为参数。技能逻辑会:
- 解析出任务标题(“把 README 写完”)和可能的截止日期(“明天之前”)。
- 将自然语言的日期“明天”转换为标准的日期格式(如
2023-10-27)。 - 向
http://localhost:3010/api/todos发送 POST 请求创建任务。 - 将创建成功的任务信息(如新任务的 ID)用友好的语言返回给用户。
5.2 部署技能与配置定时提醒
部署技能非常简单,只需要将
skills/todo-board目录复制到 OpenClaw 的技能目录下即可。OpenClaw 启动时会自动加载。# 假设你的 OpenClaw 工作空间在 ~/.openclaw cp -r /path/to/todo-board/skills/todo-board ~/.openclaw/workspace/skills/更强大的功能是定时提醒。OpenClaw 支持配置 cron 任务。你可以编辑 OpenClaw 的配置文件(通常是
~/.openclaw/config.json),添加一个定时任务:{ "cron": { "jobs": [ { "name": "todo-overdue-check", "schedule": "0 9 * * *", // 每天上午9点执行 "task": "Check http://localhost:3010/api/stats for overdue tasks. If overdue > 0, message me on Feishu with the list." } ] } }这个配置的意思是:每天上午9点,OpenClaw 会执行一个任务。这个任务会调用一个内置的 HTTP 客户端去访问
http://localhost:3010/api/stats,获取 JSON 数据。然后,它内置的 JavaScript 引擎会解析这个 JSON,判断overdue字段是否大于 0。如果大于 0,它会再调用飞书(或其他已配置的通讯工具)的 Webhook,发送一条包含逾期任务列表的消息给你。注意:这里的
task字段内容是一个简化的描述。实际 OpenClaw 的技能可能需要你编写一小段具体的 JavaScript 代码来精确实现“获取逾期任务列表并发送消息”的逻辑。具体语法请参考 OpenClaw 的文档。这个集成点展示了 TodoBoard 如何从一个被动工具,变成一个能主动触达你的智能助手。6. 部署、配置与维护指南
6.1 生产环境部署与进程守护
本地开发使用
npm start(即node server.js)没问题,但对于长期运行的服务,我们需要一个进程管理器来保证应用崩溃后能自动重启。PM2 是一个绝佳的选择。# 1. 全局安装 PM2 npm install -g pm2 # 2. 进入项目目录,用 PM2 启动应用 # --name 指定进程名称,方便管理 pm2 start server.js --name todo-board # 3. 保存当前进程列表,以便服务器重启后能自动恢复 pm2 save # 4. 生成开机自启动脚本(根据系统不同) pm2 startup # 执行上一条命令后,PM2会给出具体需要运行的命令,复制执行即可。PM2 还提供了丰富的监控功能:
pm2 list: 查看所有进程状态。pm2 logs todo-board: 查看该应用的实时日志。pm2 monit: 进入一个带图表监控的仪表盘。
为了让外部网络能够访问,我们通常会在前面加一个反向代理,比如 Nginx。
# Nginx 配置示例 (在 /etc/nginx/sites-available/ 或 conf.d 下) server { listen 80; server_name your-domain.com; # 或服务器IP location / { proxy_pass http://localhost:3010; # 指向 PM2 运行的端口 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }配置完成后,重启 Nginx (
sudo systemctl restart nginx),你就可以通过http://your-domain.com访问你的 TodoBoard 了。6.2 数据备份与迁移
由于使用 SQLite,数据备份变得极其简单。数据库就是一个名为
database.sqlite(或你在代码中指定的其他名字)的单一文件。备份:
# 直接复制数据库文件即可 cp /path/to/your/project/data/database.sqlite /path/to/backup/database_backup_$(date +%Y%m%d).sqlite你可以将上述命令加入 crontab,实现每日自动备份。
迁移: 如果你想将整个 TodoBoard 搬到另一台服务器:
- 在新服务器上安装 Node.js 环境。
- 将整个项目目录(包括
node_modules,或者只复制package.json然后在新环境npm install)复制过去。 - 将备份的
database.sqlite文件复制到项目的数据目录。 - 使用 PM2 启动应用。
整个过程几乎无需任何配置更改,因为 SQLite 是服务器无状态的,所有配置(如端口)都通过环境变量或代码常量管理,数据库路径也是相对的。
环境变量配置: 项目目前只定义了一个环境变量
TODO_PORT。在实际部署中,你可能还需要设置数据库路径、日志级别等。建议使用.env文件来管理,并通过dotenv包在应用启动时加载。# .env 文件示例 TODO_PORT=3010 NODE_ENV=production DB_PATH=./data/production.sqlite LOG_LEVEL=info在
server.js开头加入:if (process.env.NODE_ENV !== 'production') { require('dotenv').config(); }这样,在开发环境会从
.env文件读取配置,在生产环境则直接使用系统环境变量,更加安全和灵活。- 需求描述:任务的详细描述,来自