学习时,你可能同时需要三个东西:
一个待办清单,记录今天要完成的事。
一个番茄钟,提醒自己集中注意力。
一个便签,随手记下问题、命令和想法。
前几课,我们已经逐步实现了这些功能。这一课,把它们整理到同一个页面,加入模块切换、统一样式和独立的数据保存。
做完之后,你可以这样使用它:
打开待办清单,写下今天的任务 ↓ 切换到番茄钟,开始一轮专注 ↓ 中途有想法,切换到便签记录 ↓ 回到待办清单,勾选完成的任务切换模块时,番茄钟继续计时;待办和便签分别保存,重新打开时恢复。
本篇提供完整的单文件源码,没有看过前面的课程,也可以直接运行。
先明确这个版本的功能范围:
| 模块 | 已实现的功能 |
|---|---|
| 番茄钟 | 自定义时长、开始、暂停、继续、重置、结束提示 |
| 待办清单 | 新增、完成、取消完成、删除、统计、本地保存 |
| 随手便签 | 多行输入、字符计数、自动保存、手动重试 |
| 页面导航 | 三个模块切换,顶部持续显示番茄钟状态 |
待办和便签会保存到当前浏览器。番茄钟的运行状态暂时只保留在当前页面,刷新后重置。
先看完整代码,后面再讲整合时最容易出问题的地方。
在项目文件夹中新建toolbox.html,将下面内容完整复制进去。使用记事本保存时,文件类型选择“所有文件”,编码选择 UTF-8。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0" > <title>我的效率工具箱</title> <style> * { box-sizing: border-box; } [hidden] { display: none !important; } body { margin: 0; background: #f5f7fb; color: #1f2937; font-family: "Microsoft YaHei", Arial, sans-serif; } .app { max-width: 900px; margin: 0 auto; padding: 32px 16px; } header h1 { margin: 0 0 12px; font-size: 28px; } .description, .tip { color: #6b7280; line-height: 1.8; } #timer-overview { padding: 12px 16px; border-radius: 10px; background: #ede9fe; color: #5b21b6; } nav, .actions { display: flex; flex-wrap: wrap; gap: 10px; } nav { margin: 24px 0 16px; } button { padding: 10px 16px; border: 0; border-radius: 8px; background: #7c3aed; color: white; font: inherit; cursor: pointer; } button:disabled { opacity: 0.5; cursor: not-allowed; } button:focus-visible { outline: 3px solid #c4b5fd; outline-offset: 3px; } nav button { background: #e5e7eb; color: #374151; } nav button[aria-pressed="true"] { background: #7c3aed; color: white; } .panel { padding: 24px; border: 1px solid #e5e7eb; border-radius: 16px; background: white; box-shadow: 0 8px 24px rgba(31, 41, 55, 0.05); } .panel h2 { margin-top: 0; } input, textarea { padding: 10px; border: 1px solid #d1d5db; border-radius: 8px; font: inherit; } input:focus, textarea:focus { outline: 2px solid #ddd6fe; border-color: #7c3aed; } #duration { width: 90px; } #clock { margin: 30px 0; text-align: center; font-size: 64px; font-weight: bold; font-variant-numeric: tabular-nums; } #task-form { display: flex; flex-wrap: wrap; gap: 10px; } #task-input { flex: 1 1 180px; min-width: 0; } #task-list { padding: 0; list-style: none; } .task { display: flex; align-items: flex-start; gap: 12px; padding: 14px 0; border-bottom: 1px solid #e5e7eb; } .task input { flex-shrink: 0; width: 18px; height: 18px; margin-top: 10px; } .task span { flex: 1; min-width: 0; padding-top: 8px; line-height: 1.6; overflow-wrap: anywhere; } .task.done span { color: #9ca3af; text-decoration: line-through; } .danger { flex-shrink: 0; background: #fef2f2; color: #b91c1c; } #note-editor { display: block; width: 100%; min-height: 260px; margin: 12px 0; padding: 14px; line-height: 1.8; resize: vertical; } .note-actions { align-items: center; justify-content: space-between; } .status { min-height: 24px; color: #5b21b6; line-height: 1.7; overflow-wrap: anywhere; } </style> </head> <body> <div class="app"> <header> <h1>我的效率工具箱</h1> <p class="description"> 先写下要做的事,再给自己一段专注时间。 </p> <p id="timer-overview">准备开始 · 25:00</p> </header> <nav id="tool-nav" aria-label="工具切换"> <button type="button" data-view="focus-panel" aria-controls="focus-panel" aria-pressed="true" >🍅 番茄钟</button> <button type="button" data-view="tasks-panel" aria-controls="tasks-panel" aria-pressed="false" >✅ 待办清单</button> <button type="button" data-view="note-panel" aria-controls="note-panel" aria-pressed="false" >📝 随手便签</button> </nav> <main> <section id="focus-panel" class="panel" data-panel aria-labelledby="focus-title" > <h2 id="focus-title">番茄钟</h2> <label for="duration">专注时长:</label> <input id="duration" type="number" min="1" max="120" step="1" value="25" > <span>分钟</span> <div id="clock">25:00</div> <div class="actions"> <button id="start" type="button">开始 / 继续</button> <button id="pause" type="button">暂停</button> <button id="reset" type="button">重置</button> </div> <p id="timer-status" class="status" role="status"> 准备开始 </p> <p class="tip"> 切换工具可以继续计时,刷新页面会重置本轮计时。 </p> </section> <section id="tasks-panel" class="panel" data-panel aria-labelledby="tasks-title" hidden > <h2 id="tasks-title">待办清单</h2> <form id="task-form"> <input id="task-input" type="text" maxlength="80" placeholder="例如:完成今天的代码练习" aria-label="任务内容" autocomplete="off" > <button id="add-task" type="submit">添加</button> </form> <p id="task-summary" class="tip"></p> <ul id="task-list"></ul> <p id="task-status" class="status" role="status"></p> </section> <section id="note-panel" class="panel" data-panel aria-labelledby="note-title" hidden > <h2 id="note-title">随手便签</h2> <label for="note-editor">记录想法、命令或学习问题</label> <textarea id="note-editor" placeholder="在这里写点什么……" ></textarea> <div class="actions note-actions"> <span id="note-count" class="tip">0 个字符</span> <button id="save-note" type="button">立即保存</button> </div> <p id="note-status" class="status" role="status"></p> </section> </main> <p class="tip"> 任务和便签保存在当前浏览器,重要内容请另行备份。 </p> </div> <script> // 模块一:工具切换 function initNavigation() { const buttons = document.querySelectorAll("[data-view]"); const panels = document.querySelectorAll("[data-panel]"); buttons.forEach(button => { button.addEventListener("click", () => { const name = button.dataset.view; buttons.forEach(item => { item.setAttribute( "aria-pressed", String(item === button) ); }); panels.forEach(panel => { panel.hidden = panel.id !== name; }); }); }); } // 模块二:番茄钟 function initTimer() { const duration = document.querySelector("#duration"); const clock = document.querySelector("#clock"); const status = document.querySelector("#timer-status"); const overview = document.querySelector("#timer-overview"); let remaining = 25 * 60000; let endAt = 0; let interval = null; let started = false; function render() { const seconds = Math.ceil(remaining / 1000); const time = String(Math.floor(seconds / 60)).padStart(2, "0") + ":" + String(seconds % 60).padStart(2, "0"); clock.textContent = time; overview.textContent = status.textContent + " · " + time; document.title = started ? time + "|效率工具箱" : "我的效率工具箱"; } function stop() { clearInterval(interval); interval = null; } function tick() { remaining = Math.max(0, endAt - Date.now()); if (remaining === 0) { stop(); started = false; duration.disabled = false; status.textContent = "本轮专注结束"; } render(); if (remaining === 0) { document.title = "时间到!|效率工具箱"; } } function readDuration() { const value = Number(duration.value); if ( !Number.isInteger(value) || value < 1 || value > 120 ) { status.textContent = "请输入 1~120 的整数分钟"; render(); return null; } return value * 60000; } document.querySelector("#start") .addEventListener("click", () => { if (interval !== null) return; if (!started) { const value = readDuration(); if (value === null) return; remaining = value; } started = true; duration.disabled = true; endAt = Date.now() + remaining; status.textContent = "专注中"; interval = setInterval(tick, 250); tick(); }); document.querySelector("#pause") .addEventListener("click", () => { if (interval === null) return; tick(); if (interval === null) return; stop(); status.textContent = "已暂停"; render(); }); function reset() { const value = readDuration(); if (value === null) return; stop(); started = false; duration.disabled = false; remaining = value; status.textContent = "准备开始"; render(); } document.querySelector("#reset") .addEventListener("click", reset); duration.addEventListener("change", () => { if (!started) reset(); }); document.addEventListener("visibilitychange", () => { if ( document.visibilityState === "visible" && interval !== null ) { tick(); } }); render(); } // 模块三:待办清单 function initTasks() { const form = document.querySelector("#task-form"); const input = document.querySelector("#task-input"); const list = document.querySelector("#task-list"); const summary = document.querySelector("#task-summary"); const status = document.querySelector("#task-status"); const key = "efficiency-toolbox.tasks.v1"; let tasks = []; let nextId = 1; let blocked = false; try { const raw = localStorage.getItem(key); if (raw !== null) { const data = JSON.parse(raw); if ( !data || data.version !== 1 || !Array.isArray(data.tasks) ) { throw new Error("任务格式不正确"); } const ids = new Set(); for (const task of data.tasks) { if ( !task || !Number.isSafeInteger(task.id) || task.id < 1 || ids.has(task.id) || typeof task.text !== "string" || !task.text.trim() || task.text.length > 80 || typeof task.done !== "boolean" ) { throw new Error("任务字段不正确"); } ids.add(task.id); } tasks = data.tasks.map(task => ({ id: task.id, text: task.text, done: task.done })); nextId = tasks.reduce( (max, task) => Math.max(max, task.id), 0 ) + 1; } status.textContent = "任务修改后自动保存。"; } catch (error) { blocked = true; input.disabled = true; document.querySelector("#add-task").disabled = true; status.textContent = "读取任务失败,已暂停编辑并保留原数据。"; console.error(error); } function render() { list.replaceChildren(); const done = tasks.filter(task => task.done).length; summary.textContent = tasks.length ? `共 ${tasks.length} 项,已完成 ${done} 项,` + `待完成 ${tasks.length - done} 项` : "还没有任务,先添加一件事吧。"; tasks.forEach(task => { const item = document.createElement("li"); item.className = task.done ? "task done" : "task"; const check = document.createElement("input"); check.type = "checkbox"; check.checked = task.done; check.dataset.id = String(task.id); check.setAttribute( "aria-label", "切换完成状态:" + task.text ); const text = document.createElement("span"); text.textContent = task.text; const remove = document.createElement("button"); remove.type = "button"; remove.className = "danger"; remove.textContent = "删除"; remove.dataset.id = String(task.id); remove.setAttribute( "aria-label", "删除任务:" + task.text ); item.append(check, text, remove); list.append(item); }); } function commit() { render(); try { localStorage.setItem( key, JSON.stringify({ version: 1, tasks }) ); status.textContent = "当前任务已保存到本浏览器。"; } catch (error) { status.textContent = "保存失败,最新修改尚未保存," + "请先复制重要任务。"; console.error(error); } } form.addEventListener("submit", event => { event.preventDefault(); if (blocked) return; const text = input.value.trim(); if (!text || text.length > 80) { status.textContent = "请输入 1~80 个字符的任务。"; return; } if (!Number.isSafeInteger(nextId)) { status.textContent = "任务编号超出范围,无法添加。"; return; } tasks.push({ id: nextId++, text, done: false }); commit(); input.value = ""; input.focus(); }); list.addEventListener("change", event => { if ( blocked || !event.target.matches('input[type="checkbox"]') ) { return; } const task = tasks.find( item => item.id === Number(event.target.dataset.id) ); if (!task) return; task.done = event.target.checked; commit(); }); list.addEventListener("click", event => { if ( blocked || !event.target.matches("button[data-id]") ) { return; } tasks = tasks.filter( task => task.id !== Number(event.target.dataset.id) ); commit(); }); render(); } // 模块四:自动保存便签 function initNote() { const editor = document.querySelector("#note-editor"); const count = document.querySelector("#note-count"); const status = document.querySelector("#note-status"); const button = document.querySelector("#save-note"); const key = "efficiency-toolbox.note.v1"; let timeout = null; let dirty = false; let composing = false; let blocked = false; function updateCount() { count.textContent = Array.from(editor.value).length + " 个字符"; } function save() { clearTimeout(timeout); timeout = null; if (blocked || !dirty) return; try { localStorage.setItem(key, editor.value); dirty = false; status.textContent = "已保存 · " + new Date().toLocaleTimeString(); } catch (error) { status.textContent = "保存失败,请先复制备份," + "再点击立即保存重试。"; console.error(error); } } function schedule() { dirty = true; updateCount(); clearTimeout(timeout); timeout = null; status.textContent = "有修改尚未保存……"; if (!composing) { timeout = setTimeout(save, 500); } } try { editor.value = localStorage.getItem(key) ?? ""; status.textContent = "便签已就绪,停止输入后自动保存。"; } catch (error) { blocked = true; editor.readOnly = true; button.disabled = true; status.textContent = "无法读取便签,已暂停编辑,请检查存储权限。"; console.error(error); } editor.addEventListener("input", schedule); editor.addEventListener("compositionstart", () => { composing = true; clearTimeout(timeout); timeout = null; }); editor.addEventListener("compositionend", () => { composing = false; schedule(); }); editor.addEventListener("blur", save); button.addEventListener("click", save); document.querySelector("#tool-nav") .addEventListener("click", save); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { save(); } }); window.addEventListener("pagehide", save); updateCount(); } // 页面打开时,每个模块初始化一次 initNavigation(); initTimer(); initTasks(); initNote(); </script> </body> </html>如果前面已经启动了本地服务器,直接访问同一服务下的toolbox.html。
例如,之前使用的是 8000 端口,就打开:
本地效率工具箱
如果电脑已经安装 Python 3,也可以在该文件所在文件夹打开终端,运行前几课使用的命令:
python -m http.server 8000 --bind 127.0.0.1保持终端运行,然后访问上面的地址。
不要只检查页面能不能打开。先添加一个任务,再启动番茄钟,接着切换到便签写两行文字,最后切回来。
这条流程能帮助你检查:三个功能在同一个页面里,是否仍然正常工作。
接下来解释本课最重要的几个整合思路。
第一个是:切换页面时,只改变显示状态。
导航按钮上有一个属性:
data-view="tasks-panel"它指向待办清单区域的 ID:
<section id="tasks-panel">点击按钮后,代码将对应区域显示出来,其余区域隐藏:
panels.forEach(panel => { panel.hidden = panel.id !== name; });hidden用来控制区域是否展示。本例还添加了对应 CSS,避免其他显示样式覆盖隐藏效果。参考:MDN hidden 属性
这里没有重新创建整个页面,也没有重新初始化三个工具。
因此,你切换到便签时,番茄钟的变量和定时器依然存在;切回待办时,刚才添加的任务也仍在列表中。
如果把初始化函数放进导航点击事件里,例如:
// 不要在每次切换时重新初始化番茄钟 button.addEventListener("click", () => { initTimer(); });就可能重复绑定按钮事件。点击一次“开始”,多个事件处理函数同时执行,后续行为会越来越难排查。
本例只在脚本末尾初始化一次:
initNavigation(); initTimer(); initTasks(); initNote();第二个是:每个模块管理自己的变量。
待办清单和便签都需要状态提示,也都需要保存功能。
如果直接把不同页面的脚本拼在一起,很容易出现多个同名声明:
const status = ...; const status = ...;在同一个作用域里重复声明,会导致报错。
这次把逻辑分别放进函数:
function initTasks() { const status = document.querySelector("#task-status"); }function initNote() { const status = document.querySelector("#note-status"); }虽然变量名称相同,但它们分别属于自己的函数,不会互相冲突。
HTML 的 ID 仍然需要保持唯一,所以页面使用的是:
timer-status task-status note-status以后让 AI 整合多个功能时,可以把“变量分模块管理、HTML ID 不重复、事件只绑定一次”写进需求。
第三个是:保持已有数据格式,避免升级后读不到旧内容。
本课继续使用第四、五课的存储名称:
"efficiency-toolbox.tasks.v1""efficiency-toolbox.note.v1"待办清单仍保存为:
{ version: 1, tasks: [ { id: 1, text: "完成今天的练习", done: false } ] }便签仍然直接保存字符串。
在同一个浏览器配置、同一个来源地址下,如果前几课的数据仍然存在,新工具箱就可以继续读取它们。
例如:
http://127.0.0.1:8000/index.html http://127.0.0.1:8000/note.html http://127.0.0.1:8000/toolbox.html它们的协议、主机名和端口一致,可以访问同一来源下的 localStorage。更换端口、换成localhost或换浏览器,则可能进入不同的存储空间。直接双击本地 HTML 的存储行为没有统一保证。参考:MDN localStorage
不要为了“重新开始”而随手调用:
localStorage.clear();它会清空当前来源下的全部 localStorage 项目,待办和便签都可能受到影响。本课整合过程不需要清空存储。
第四个是:模块隐藏后,用户仍需要知道计时状态。
假设番茄钟正在运行,你切换到便签记录问题。如果计时状态只存在于番茄钟区域,就需要不断切回去查看。
因此,本课把一条计时概览放在导航上方:
<p id="timer-overview">准备开始 · 25:00</p>每次刷新计时显示时,同时更新它:
overview.textContent = status.textContent + " · " + time;这样,三个模块都能看到当前计时状态。
倒计时根据结束时间与当前时间的差值计算:
remaining = Math.max(0, endAt - Date.now());这可以避免简单按“回调执行一次就减一秒”造成的累计偏差。不过,浏览器定时回调仍可能延迟,设备休眠、后台限制和系统时间调整都会影响实际表现。本例提供页面文字和标题提示,不保证后台准时提醒。参考:MDN setInterval
第五个是:切换工具前,处理便签尚未保存的修改。
上一课采用停止输入后等待约半秒再保存的方式。
但用户可能刚输入完,就马上点击“待办清单”。因此,本课让导航点击也触发一次保存尝试:
document.querySelector("#tool-nav") .addEventListener("click", save);如果没有修改,dirty为false,函数直接返回。
如果有尚未保存的修改,就尝试保存当前内容,并取消之前安排的延迟保存。
保存失败时,页面会留下提示。不要把输入框中能看见内容,直接当成已经保存成功。
现在可以对整合后的工具箱做一次完整验收:
| 操作 | 预期结果 |
|---|---|
| 连续点击三次“开始” | 只运行一个计时器 |
| 开始计时后切换到待办 | 顶部倒计时继续更新 |
| 暂停后等待,再点击继续 | 从暂停时的剩余时间继续 |
| 添加两条文字相同的任务 | 两条任务可以分别操作 |
| 勾选任务后刷新 | 完成状态能够恢复 |
| 删除全部任务后刷新 | 列表保持为空 |
| 输入便签后立即切换模块 | 导航点击触发保存尝试 |
| 保存空便签后刷新 | 不会恢复已经删掉的旧文字 |
| 刷新整个页面 | 任务和便签恢复,番茄钟重置 |
如果某个功能单独运行正常,整合后却出问题,可以优先检查这四处:
- 页面里是否存在重复 ID。
- 脚本里是否存在同一作用域下的重复声明。
- 模块切换时是否重复执行初始化。
- 不同功能是否误用了同一个存储名称。
这些问题比“代码总行数太多”更具体,也更容易定位。
需要让 AI 帮忙检查时,可以直接使用下面的描述:
我把番茄钟、待办清单和便签整合进了一个 HTML 页面。
请检查重复 ID、变量作用域、重复事件绑定和 localStorage 名称冲突。
导航切换只控制显示,不应重置计时器或清空输入。
请保留现有存储名称和数据格式,并根据我提供的报错指出修改位置。
这个版本先按单个页面使用设计,尚未处理多个标签页同时修改数据的冲突。重要记录也应单独备份。
下一课,我们给待办清单增加关键词搜索和状态筛选:任务多起来以后,可以快速找到某一项,也可以只查看“待完成”或“已完成”的任务。