尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

自律学习小程序开发实战:从功能设计到番茄钟与数据统计

自律学习小程序开发实战:从功能设计到番茄钟与数据统计 任何一个做过自律学习类小程序的人都会有一个共同感受功能看着不难真正做完才发现坑都在细节里。用户的登录态怎么维护番茄钟退到后台再回来还剩多少秒统计数据按天聚合时“时区 1”会不会算错演示现场手机连不上本地服务怎么办。这些问题单独看都不大但它们恰好是面试官和答辩老师最喜欢追问的地方。自律学习小程序听起来像是一道“友好”的毕设题目注册登录、每日打卡、记录学习时长、看一眼统计图表功能似乎都能在两天内堆出来。但真正把这一套从头到尾做成一个可演示、可解释、可扩展的项目工作量并不小。这个题目的价值不在于UI有多炫而在于它覆盖了小程序开发中最常用的一批核心能力页面路由、本地缓存、定时器、数据持久化、数据聚合、图表展示以及真机调试和上线配置。这篇文章会从零开始拆解一个“自律学习小程序”从设计到落地的完整过程。我们会先梳理业务闭环再确定技术选型和项目结构然后依次实现计划管理、番茄专注、打卡记录、统计图表这几个核心功能。最后我会把毕设演示和答辩时最容易踩的坑整理成一份排查清单方便你在最后阶段快速定位问题。我的核心判断是毕设级别的自律学习小程序真正拉开差距的不是特效而是数据是否一致、异常是否可控、功能是否形成闭环、以及答辩时你能不能把每个设计的取舍讲清楚。1. 为什么说自律学习小程序是“比想象中更值得做”的毕设题目先说结论自律学习小程序的业务复杂度不高工程复杂度适中非常适合作为计算机相关专业的毕业设计、课程设计或个人简历项目。为什么因为一个完整的自律学习小程序需要同时处理好几类典型问题。第一是交互流程问题。用户新增一个学习目标设置每日学习时长然后进入番茄钟开始专注专注完成之后自动生成一条打卡记录最后在统计页看到自己这几天的学习趋势。这条链路涉及多个页面之间的数据传递也涉及“目标 - 执行 - 反馈”的完整闭环。第二是数据一致性问题。番茄钟完成时写入一条记录统计页要用这条记录计算今日时长、累计时长、连续打卡天数。如果用户手动删除目标历史记录怎么处理如果用户重复点击“完成”按钮会不会产生重复记录如果日期切换发生在深夜统计用的是哪一天。这些都是真实项目里会遇到的边界问题。第三是工程化问题。很多同学一上来就想把功能做“全”结果把登录、云开发、订阅消息、分享、支付全部堆进去最后发现每个功能都没做深。自律学习小程序更合适的做法是把MVP做扎实再逐步加加分项先有目标和打卡再有番茄钟和统计最后再考虑订阅消息提醒、云同步、多端适配。第四是答辩展示问题。毕设项目最怕“能跑但讲不清楚”。自律学习小程序的功能逻辑非常直观答辩时可以很自然地讲出数据流用户在计划页创建目标目标写入本地缓存用户进入番茄钟页面计时结束后写入一条学习记录统计页读取所有记录按日期聚合后绘制图表。这条链路清晰、完整又有技术含量非常适合用来展示你的设计能力和编码能力。所以如果你正在纠结毕设题目自律学习小程序是一个性价比很高的选择。它不是一个“三天能抄完”的登录注册项目也不是一个需要深厚算法背景的科研课题而是一个“需要你把细节做好”的综合工程练习。2. 核心功能与业务闭环设计做项目之前先别急着写代码。先把功能模块和优先级梳理清楚能避免后面反复返工。2.1 功能模块一个自律学习小程序通常包含下面这些模块学习计划管理新增学习目标比如“英语六级”“考研数学”“每天读30页书”。为每个目标设置每日学习时长。查看目标列表删除不再执行的目标。番茄专注选择一个学习目标进入专注倒计时。支持开始、暂停、继续、重置。专注完成后自动写入一条学习记录。打卡记录每日学习时长达到目标时长后视为当天完成打卡。记录按日期保存同一目标同一天可以有多条记录。学习统计今日学习时长。累计学习时长。连续打卡天数。最近7天学习趋势图。个人中心展示用户基本信息。查看历史学习记录。提供数据导出或清空缓存的入口。提醒与分享加分项订阅消息提醒。分享学习卡片到微信群。2.2 功能优先级毕设开发要区分“必须做”和“有余力再做”。优先级功能模块说明P0学习计划管理没有目标后面的番茄钟和统计都失去意义P0番茄专注核心计时功能决定项目是否成立P0打卡记录专注完成后的数据落库P1学习统计展示成果的页面答辩展示的重要加分项P1个人中心完善项目结构P2订阅消息需要用户授权且一次性订阅限制较多P2云同步如果本地缓存方案够用可以不做P2分享与邀请锦上添花不建议前期投入优先级定清楚之后你会发现一个很有趣的现象P0 和 P1 功能加起来其实就是一个可以独立运行的完整项目。P2 功能并不会改变核心架构它们只是让项目看起来更“完整”而已。2.3 业务闭环用一句话概括这个项目的核心闭环用户创建学习目标进入番茄钟完成一次专注系统自动生成一条学习记录统计页基于所有记录展示学习成果。这个闭环的好处是每一个功能模块都依赖前一个模块产生的数据所以你在答辩时不需要背稿只要沿着数据流讲一遍整个项目的逻辑就自然说清楚了。3. 技术选型与项目结构设计3.1 原生小程序、uni-app、Taro 怎么选自律学习小程序这类业务技术选型主要看你的已有基础。原生微信小程序没有额外框架依赖调试最直接文档和社区资料最多。适合没接触过 Vue/React 的开发者。uni-app基于 Vue 语法一套代码可以编译到微信小程序、H5、App。如果你已经在用 Vueuni-app 的学习成本更低。Taro基于 React 语法和 uni-app 类似适合 React 技术栈的同学。我的建议是如果只是为了毕设而且之前没有接触过任何跨端框架直接用原生小程序最稳妥。原因很简单原生 API 和微信开发者工具的配合最好排错时你能看到最底层的报错信息。如果跨端需求不是刚需引入 uni-app 或 Taro 只是增加了一层抽象。项目目录结构self-discipline-miniprogram/ ├── app.js ├── app.json ├── app.wxss ├── project.config.json ├── sitemap.json ├── pages/ │ ├── index/ // 今日计划 打卡入口 │ ├── plan/ // 学习目标管理 │ ├── timer/ // 番茄专注 │ ├── stats/ // 学习统计 │ └── mine/ // 个人中心 ├── components/ │ ├── goal-card/ // 目标卡片组件 │ └── empty/ // 空状态组件 └── utils/ ├── storage.js // 本地存储封装 └── format.js // 日期、时间格式化工具这个结构的关键点在于把数据读写统一收敛到utils/storage.js里。页面不直接操作wx.setStorageSync而是调用封装好的方法。这样如果后期要从本地缓存切换到云开发只需要改工具类不需要改动每个页面。3.2 状态管理小程序项目小的时候不需要引入 Redux、Vuex 之类的状态管理库。自律学习小程序的数据可以分为两类页面级数据当前输入框内容、当前番茄钟状态通过setData维护即可。全局共享数据目标列表、学习记录列表保存到本地缓存页面在onShow的时候重新读取。页面每次显示时重新读取缓存看似多了一点开销但实际上保证了多个页面之间的数据一致性。你在计划页新增了一个目标回到首页时onShow会重新拉到最新数据不需要手动通知其他页面刷新。这个设计背后的核心原则是数据只有一个真实来源本地缓存就是真实来源页面只是它的视图。4. 环境准备与开发前配置自律学习小程序是微信小程序项目开发前需要准备账号和工具。下面的步骤是通用流程版本细节以你实际安装的版本为准。4.1 准备小程序账号如果你希望真机预览、上传代码、发布上线需要在微信公众平台注册一个小程序账号。个人主体可以注册个人小程序支持大多数学习类功能但有一些能力受限。如果不方便注册也可以先在微信开发者工具里使用“测试号”体验但测试号不能用于正式发布。毕设演示时如果需要真机预览建议使用真实的小程序 AppID。为什么强调这一点因为很多同学在开发阶段用的是测试号等到答辩前一天才发现真机预览需要真实 AppID结果临时换 AppID导致数据缺失、体验中断。提前准备好 AppID能省掉很多麻烦。4.2 安装微信开发者工具在微信官方渠道下载微信开发者工具安装后用小程序账号扫描登录。新建项目时选择“小程序”填入 AppID 或选择测试号模板建议选择“不使用模板”从空项目开始。新建项目后第一件事是打开project.config.json确认appid字段已经是你自己的 AppID。如果这里填错了后面真机预览时会出现“登录失败”“数据拉取失败”等问题。4.3 配置 app.jsonapp.json是小程序的全局配置文件里面声明了页面路径、窗口样式、底部 TabBar 等信息。{ pages: [ pages/index/index, pages/plan/plan, pages/timer/timer, pages/stats/stats, pages/mine/mine ], window: { navigationBarTitleText: 自律学习, navigationBarBackgroundColor: #4F6EF7, navigationBarTextStyle: white, backgroundColor: #F6F7FB }, tabBar: { color: #999999, selectedColor: #4F6EF7, backgroundColor: #FFFFFF, list: [ { pagePath: pages/index/index, text: 今日 }, { pagePath: pages/plan/plan, text: 计划 }, { pagePath: pages/timer/timer, text: 专注 }, { pagePath: pages/stats/stats, text: 统计 }, { pagePath: pages/mine/mine, text: 我的 } ] }, style: v2, sitemapLocation: sitemap.json }注意事项TabBar 的pagePath必须存在于pages字段中否则编译会报错。如果配置了 TabBar 但不想用图标可以省略iconPath和selectedIconPath只显示文字。如果希望显示图标需要准备建议尺寸为 81px × 81px 的 PNG 图片。navigationBarTextStyle只支持black和white两个值。4.4 网络请求域名说明如果你的项目使用wx.request请求后端接口开发时可以临时勾选“不校验合法域名”但真机和上线时必须在小程序后台配置 HTTPS 合法域名。一个常见的坑是模拟器里请求正常真机上wx.request失败报错信息是“无法连接到服务器”或者ERR_CONNECTION_RESET。出现这种情况优先检查后台是否配置了合法域名、域名证书是否有效、TLS 版本是否符合要求。如果只是毕设演示更稳妥的方案是不依赖远端服务器。把数据全部保存在本地缓存演示时即使断网也能正常展示。等到需要展示云开发能力时再单独接入微信云开发。5. 数据模型与本地持久化设计5.1 本地缓存还是云开发自律学习小程序的数据量很小特点是个性强、写入频繁、关联简单。如果做本地缓存方案代码简单、演示稳定也不会因为服务器过期导致项目无法运行。云开发则适合你需要展示“前后端完整能力”的场景。微信云开发提供了云函数、云数据库、云存储不需要自己购买服务器个人小程序也能接入。但要注意云开发同样需要在小程序后台开通并遵循数据库权限规则。对于毕设我建议先以本地缓存作为主方案跑通整个业务闭环后再考虑把“目标列表”和“学习记录”同步到云端。这个顺序能保证你始终有一个可运行的版本。5.2 数据结构设计本地缓存里主要维护三个集合。目标对象{ id: lw2x9k0f8a, title: 英语六级, dailyMinutes: 30, createdAt: 1700000000000 }学习记录对象{ id: lw2x9k1f8b, goalId: lw2x9k0f8a, date: 2025-06-01, duration: 25, type: pomodoro, createdAt: 1700000000000 }番茄钟记录对象{ id: lw2x9k2f8c, goalId: lw2x9k0f8a, startTime: 1700000000000, endTime: 1700001500000, duration: 25, status: finished }这三个对象的关系是goal是学习目标record是每天完成学习的凭证pomodoro是每一次专注的执行流水。record和pomodoro的区别在于record用于统计打卡和时长趋势pomodoro用于查看专注历史明细。如果项目规模不大也可以只用record把专注明细合并进去。5.3 封装本地存储工具类直接在每个页面里写wx.getStorageSync不是不行但后期维护成本高。更推荐把读写逻辑封装到一个工具文件里。// 文件路径utils/storage.js const GOALS_KEY goals const RECORDS_KEY records const POMODOROS_KEY pomodoro_records function read(key) { try { const value wx.getStorageSync(key) return value ? value : [] } catch (e) { console.error(读取本地缓存失败, key, e) return [] } } function write(key, value) { try { wx.setStorageSync(key, value) return true } catch (e) { console.error(写入本地缓存失败, key, e) return false } } function genId() { return Date.now().toString(36) Math.random().toString(36).slice(2, 8) } function formatDate(date) { const d date ? new Date(date) : new Date() const y d.getFullYear() const m String(d.getMonth() 1).padStart(2, 0) const day String(d.getDate()).padStart(2, 0) return ${y}-${m}-${day} } module.exports { GOALS_KEY, RECORDS_KEY, POMODOROS_KEY, read, write, genId, formatDate }这里的genId用时间戳加随机数生成主键避免多个目标或记录出现 id 冲突。formatDate统一了日期格式所有统计逻辑都依赖这个 YYYY-MM-DD 格式来判断“今天”和“昨天”。封装好之后页面里不需要知道缓存 key 是什么只需要调用storage.read(storage.GOALS_KEY)和storage.write(storage.GOALS_KEY, goals)。以后如果要把存储层换成云数据库只需要修改这个文件里的方法体页面代码不用大改。6. 核心页面实现学习计划与打卡6.1 计划页面的功能拆解计划页是用户新增和查看学习目标的入口。它的功能非常简单输入目标名称。输入每日目标时长。点击“新增目标”后写入缓存并刷新列表。点击“去专注”跳转到番茄钟页面并带上目标 id。删除目标时需要二次确认。页面代码不复杂但有几个细节值得注意。目标名称不能为空每日时长必须是正整数。如果不做校验用户输入0或者负数后续统计会出现荒谬的结果。删除目标时建议保留历史记录因为统计数据依赖这些记录删除目标不应该把历史学习成果一起删掉。6.2 计划页核心代码!-- 文件路径pages/plan/plan.wxml -- view classplan-page view classform-card input classtitle-input placeholder目标名称例如英语六级 value{{title}} bindinputonTitleInput / view classrow text每日目标分钟/text input typenumber value{{dailyMinutes}} bindinputonMinutesInput / /view button classsubmit-btn bindtaponAddGoal新增目标/button /view view classgoal-list view classgoal-card wx:for{{goals}} wx:keyid view classgoal-info text classgoal-title{{item.title}}/text text classgoal-desc每日 {{item.dailyMinutes}} 分钟/text /view view classgoal-actions button sizemini bindtaponStartTimer>// 文件路径pages/plan/plan.js const storage require(../../utils/storage) Page({ data: { title: , dailyMinutes: , goals: [] }, onShow() { this.loadGoals() }, loadGoals() { const goals storage.read(storage.GOALS_KEY) this.setData({ goals }) }, onTitleInput(e) { this.setData({ title: e.detail.value }) }, onMinutesInput(e) { this.setData({ dailyMinutes: e.detail.value }) }, onAddGoal() { const title this.data.title.trim() const dailyMinutes Number(this.data.dailyMinutes) if (!title) { wx.showToast({ title: 请输入目标名称, icon: none }) return } if (!dailyMinutes || dailyMinutes 0) { wx.showToast({ title: 每日时长必须是正整数, icon: none }) return } const goals storage.read(storage.GOALS_KEY) goals.unshift({ id: storage.genId(), title, dailyMinutes, createdAt: Date.now() }) storage.write(storage.GOALS_KEY, goals) this.setData({ title: , dailyMinutes: , goals }) }, onStartTimer(e) { const id e.currentTarget.dataset.id wx.navigateTo({ url: /pages/timer/timer?goalId${id} }) }, onDeleteGoal(e) { const id e.currentTarget.dataset.id wx.showModal({ title: 确认删除, content: 删除后对应的历史记录仍在但不再显示该目标。, success: (res) { if (!res.confirm) return const goals storage.read(storage.GOALS_KEY).filter(g g.id ! id) storage.write(storage.GOALS_KEY, goals) this.loadGoals() } }) } })这段代码的关键逻辑是所有数据变动都经过storage.read-storage.write两步。新增目标时先读取数组往头部插入新对象再写回缓存。删除目标时先读取数组用filter过滤掉指定 id再写回缓存。使用wx:keyid是为了让列表渲染更高效同时避免因为数组索引变化导致组件状态错乱。6.3 今日打卡状态的判断“今日是否完成打卡”是在首页展示的状态。判断逻辑很简单取今天的日期字符串把所有record.date today的时长相加如果大于等于目标时长就视为完成。这个逻辑可以放在首页的onShow中const records storage.read(storage.RECORDS_KEY) const today storage.formatDate(new Date()) const todayMinutes records .filter(r r.date today) .reduce((sum, r) sum (Number(r.duration) || 0), 0)这里要注意的一点是duration从缓存里取出来是数字但如果你曾经在写入时把它存成了字符串累加时就要用Number()做一次兜底转换。很多统计对不上的问题根源都是数据类型不一致。7. 核心功能实现番茄钟与学习计时番茄钟是整个项目里最容易“看起来简单但做错”的功能。表面上只需要一个倒计时实际上涉及状态管理、定时器生命周期、页面隐藏恢复、数据落库等多个问题。7.1 番茄钟的状态设计我把番茄钟设计成四种状态空闲初始状态显示 25:00。进行中计时器运行中。已暂停计时暂停保留已过去的秒数。已完成计时结束记录已写入。这个状态设计的价值在于页面的每一个操作都能被清晰定义开始只能从空闲或暂停进入暂停只能从进行中进入重置可以从任意状态回到空闲。7.2 计时逻辑的常见误区很多初学者会这样写倒计时let seconds 1500 const timer setInterval(() { seconds - 1 this.setData({ remainSeconds: seconds }) if (seconds 0) { clearInterval(timer) } }, 1000)这种写法在小程序里有一个隐患setInterval的触发时间并不精确尤其是页面切到后台之后定时器可能被系统挂起甚至延迟执行。后果是用户从后台返回时剩余时间可能比预期少很多。更稳妥的方案是记录“时间戳”而不是每秒钟递减一个变量。每次计算剩余时间时用当前时间戳减去开始时间戳再加上暂停前已经累计的秒数。这样即使定时器被挂起回到页面后也能用真实时间差算出准确剩余时间。7.3 番茄钟核心代码// 文件路径pages/timer/timer.js const storage require(../../utils/storage) const TOTAL_SECONDS 25 * 60 const STATUS { IDLE: idle, RUNNING: running, PAUSED: paused, FINISHED: finished } Page({ data: { goalId: , status: STATUS.IDLE, remainSeconds: TOTAL_SECONDS, displayTime: 25:00 }, onLoad(options) { this.goalId options.goalId || this.accumulatedSeconds 0 this.startTimestamp 0 this.timer null this.setData({ goalId: this.goalId }) }, onUnload() { this.clearTimer() }, onHide() { // 页面隐藏时自动暂停避免计时器在后台造成不可预期行为 if (this.data.status STATUS.RUNNING) { this.pauseTimer(true) } }, onShow() { this.clearTimer() }, clearTimer() { if (this.timer) { clearInterval(this.timer) this.timer null } }, startTimer() { if (this.timer) return this.timer setInterval(() { const remain this.calcRemain() this.setData({ remainSeconds: remain, displayTime: this.formatTime(remain) }) if (remain 0) { this.finishTimer() } }, 1000) }, calcRemain() { let elapsed this.accumulatedSeconds if (this.data.status STATUS.RUNNING this.startTimestamp) { elapsed Math.floor((Date.now() - this.startTimestamp) / 1000) } return Math.max(TOTAL_SECONDS - elapsed, 0) }, formatTime(seconds) { const m String(Math.floor(seconds / 60)).padStart(2, 0) const s String(seconds % 60).padStart(2, 0) return ${m}:${s} }, onStart() { if (this.data.status STATUS.FINISHED) { this.accumulatedSeconds 0 } if (this.data.status STATUS.IDLE || this.data.status STATUS.PAUSED || this.data.status STATUS.FINISHED) { if (this.data.status STATUS.IDLE || this.data.status STATUS.FINISHED) { this.accumulatedSeconds 0 } this.setData({ status: STATUS.RUNNING }) this.startTimestamp Date.now() this.startTimer() } }, onPause() { if (this.data.status ! STATUS.RUNNING) return this.pauseTimer(false) }, pauseTimer(isAuto) { this.clearTimer() if (this.data.status STATUS.RUNNING this.startTimestamp) { this.accumulatedSeconds Math.floor((Date.now() - this.startTimestamp) / 1000) } const remain Math.max(TOTAL_SECONDS - this.accumulatedSeconds, 0) this.setData({ status: STATUS.PAUSED, remainSeconds: remain, displayTime: this.formatTime(remain) }) if (isAuto) { wx.showToast({ title: 专注已暂停, icon: none }) } }, onReset() { this.clearTimer() this.accumulatedSeconds 0 this.startTimestamp 0 this.setData({ status: STATUS.IDLE, remainSeconds: TOTAL_SECONDS, displayTime: this.formatTime(TOTAL_SECONDS) }) }, finishTimer() { this.clearTimer() this.accumulatedSeconds 0 this.startTimestamp 0 this.setData({ status: STATUS.FINISHED, remainSeconds: 0, displayTime: 00:00 }) this.saveRecord() wx.vibrateShort({ type: medium }) wx.showToast({ title: 专注完成, icon: success }) }, saveRecord() { const records storage.read(storage.RECORDS_KEY) records.push({ id: storage.genId(), goalId: this.goalId, date: storage.formatDate(new Date()), duration: TOTAL_SECONDS / 60, type: pomodoro, createdAt: Date.now() }) storage.write(storage.RECORDS_KEY, records) } })这段代码里有几个非常关键的细节。accumulatedSeconds保存的是暂停前已经过去的有效秒数。startTimestamp保存的是当前段开始的时间戳。calcRemain把两者相加再用总时长减去得到剩余秒数。这样做之后即使setInterval被系统延迟执行剩余时间也不会漂移。onHide里自动暂停是为了避免用户切到微信聊天或锁屏之后番茄钟在后台继续计时产生歧义。答辩时如果老师问你“小程序切到后台会不会继续计时”你可以明确回答这里的设计是页面隐藏时自动暂停保证专注记录的准确性。finishTimer里只做两件事写入学习记录、提醒用户。duration存的是分钟数也就是 25。如果你后面增加了自定义时长功能这里要改成读取配置而不是写死常量。7.4 一个值得思考的扩展点目前的番茄钟只支持固定 25 分钟。如果你想在答辩时展示设计能力可以增加“自定义专注时长”和“专注详情列表”。自定义时长只需要把TOTAL_SECONDS改为从配置读取专注详情列表可以从pomodoro_records里
返回列表