简介:面向微信小程序学习者与课程设计开发者,提供一套基于云开发的日常学习打卡系统完整方案。系统实现登录、自选任务、设定倒计时专注、在线打卡(文字+图片)、查看打卡记录与点赞等功能,覆盖小程序前端与云开发后端的典型交互逻辑,可支撑论文写作与功能扩展。压缩包共68个文件,约721KB,含js、json、wxss、wxml等前后端核心代码,png界面与数据库截图,另附mp4演示视频、doc需求说明和md说明文档,便于快速理解项目结构。已有250人学习下载,适合正在做课程设计或毕业设计、需要可运行参考项目的小程序开发者。资源目录清晰,pages目录分登录、首页、记录、设置等多个页面,cloud目录为云函数模块,读者可结合演示视频与需求文档快速上手,并针对自己的业务场景扩展计时规则、积分排行等功能。
1. 一个带倒计时的日常学习打卡系统,为什么值得拆
很多人做课程设计或毕设时选"打卡"类题目,最后做成一个签到按钮加记录列表,答辩时没东西可讲。这套基于微信小程序的日常学习打卡系统不一样的地方在于,它把专注计时和打卡记录强绑在一起:登录后自选任务、设定时长,倒计时结束才能填文字、传图片完成打卡,之后还能看历史记录、点赞互动。每个环节都有数据可查,论文里能展开的技术点就多了——状态流转、云开发、数据库权限、并发点赞,都能单独成节。
工程包里带了源码、需求文档、数据库截图和演示视频,工程内部代号 focus-clock,整体走云开发架构,不需要自建服务器。适合两类人:一是要交课程设计或毕业设计的学生,二是想快速起一个云开发小程序、需要参考完整工程结构的开发者。这篇就按代码组织、倒计时状态流转、数据读写、部署排错四个层次把源码拆开讲,照着能跑通,也能讲清楚。
2. 云开发架构与工程骨架:先搞清楚代码是怎么组织的
拿到工程文件夹,一眼扫过去有 app.js、app.json、project.config.json、sitemap.json,还有 cloud 目录,pages 下面按功能拆了若干页面。这是微信小程序的标准工程布局,但它没有用自建服务器,而是走了云开发——数据库、存储、云函数都在微信侧托管,免去备案和服务器运维,对课程设计来说是最省事的选型,答辩时也能讲明白"无服务器后端"这个架构。
2.1 为什么选云开发而不是自建后端
云开发(TCB)的核心能力是三块:云数据库、云存储、云函数。客户端通过wx.cloud.database()直接读写数据库,图片走wx.cloud.uploadFile传到云存储,涉及 openid 鉴权的逻辑丢给云函数。我一般会跟学生说,除非你的项目需要回调外部 API、需要定时任务,否则云开发完全够用。这个工程的 cloud 目录里就有 getOpenID 云函数,说明作者至少用云函数解决了用户身份识别,这是云开发项目里最标准的切入点。
入口在 app.js,冷启动时要先初始化云环境:
App({ onLaunch() { if (!wx.cloud) { console.error('请使用 2.2.3 或以上的基础库以使用云能力'); } else { wx.cloud.init({ env: 'your-env-id', traceUser: true }); } } });env填的是云开发控制台里的环境 ID,不是环境名称;traceUser: true会在控制台的用户管理里记录访问来源,方便后面看用户列表。如果你本机调试一直报环境找不到,九成是这个 ID 复制错了。另外 require 云能力前先判断wx.cloud是否存在,是为了兼容旧版基础库,这个兜底判断建议保留。
2.2 pages 目录与路由注册
小程序的路由不像 Vue Router 那样集中管理,而是在 app.json 的 pages 数组里按顺序注册。这个工程 pages 下有 login、index、about、posts、register、setting、logs、version 等页面,分工大致如下:
| 页面 | 职责 | 关键交互 |
|---|---|---|
| login | 登录与身份获取 | 调用云函数 getOpenID |
| index | 任务列表与选择 | 读取任务,点击进入计时 |
| posts | 打卡记录流 | 查看记录,点赞 |
| logs | 个人打卡日志 | 按时间倒序展示 |
| register | 任务登记 | 新增自定义任务 |
| setting | 参数设置 | 默认专注时长等 |
| about / version | 项目说明页 | 课程设计说明、版本信息 |
以 login 开头的注册顺序,意味着小程序冷启动后先进登录页。如果你想让 index 做首页,就要把 index 移到数组第一项,或者在 index 的 onLoad 里做未登录跳转。很多新手页面文件建好了但忘记注册进数组,编译直接报 page is not found,问题就出在这里。
{ "pages": [ "pages/login/login", "pages/index/index", "pages/posts/posts", "pages/register/register", "pages/setting/setting", "pages/logs/logs", "pages/about/about", "pages/version/version" ], "window": { "navigationBarTitleText": "学习打卡", "navigationBarBackgroundColor": "#ffffff" } }window 配置里的 navigationBarTitleText 是顶栏标题,navigationBarBackgroundColor 是顶栏颜色。如果你觉得默认顶栏高度和胶囊按钮对不齐,可以在页面 json 里配"navigationStyle": "custom"自己画导航,但要注意自定义导航后要手动避开状态栏,否则会顶到最上面。
2.3 sitemap.json、样式与静态资源
sitemap.json 决定哪些页面可以被微信搜索索引到,默认规则是全部允许:
{ "desc": "关于本文件的更多信息,请参考文档", "rules": [ { "action": "allow", "page": "*" } ] }如果你不想让 register 这类内部页面被索引,把 action 改成 disallow、page 指向具体路径即可。这个文件不影响编译运行,开发者工具控制台偶尔会提示 sitemap 警告,不用管。工程里的 weui-cell.wxss 是从 WeUI 组件库抽出来的单元格样式,列表和表单复用它,比自己手写 cell 的边框、间距要稳,这是一个值得保留的做法——小程序里手写 1px 分割线经常出现对不齐的问题。image 目录下那些 shezhi、jilu、wechatHL 命名的 png 是设置、记录、登录等入口的图标资源,替换时保持同名覆盖即可。
3. 专注倒计时的状态流转:从选任务到打卡成功
这个系统的核心不在打卡按钮,而在"计时"和"打卡"之间的强绑定:不完成倒计时,就没有打卡入口。拆代码时最值得看的也是这段逻辑——用户选完任务后页面怎么进入倒计时,倒计时结束前为什么不开放打卡,中途退出后状态怎么恢复。理解了状态流转,整条主流程就通了。
3.1 任务与计时状态设计
先把任务和打卡记录拆成两个数据维度。任务只描述"做什么",打卡记录描述"某次专注的结果"。一次完整的专注过程有四个状态:
- idle:空闲,用户还没选任务
- focusing:倒计时进行中
- finished:计时结束,可以填写打卡内容
- checked:打卡已提交,记录落库
实现上用数字表示状态,比如 status 取值 0/1/2/3,比多个布尔变量干净得多。后续如果要加"暂停"功能,只需在 focusing 里再拆一个 pause 子状态,改动面很小。这是答辩时能讲清楚的第一个设计点:用状态机而不是散落的 if 来控制页面行为。
3.2 倒计时页面的实现
核心倒计时逻辑在页面 js 里,常见做法是 setInterval 每秒递减剩余秒数:
const TOTAL_SECONDS = 25 * 60; // 25 分钟专注 let remain = TOTAL_SECONDS; let timer = null; startCountdown() { this.setData({ status: 1 }); timer = setInterval(() => { remain--; if (remain <= 0) { clearInterval(timer); this.setData({ status: 2 }); // 进入 finished,开放打卡表单 return; } this.setData({ remain }); }, 1000); },setInterval 的间隔是 1000ms,但必须清楚它并不精确:小程序切后台后定时器会被挂起,回到前台时 remain 会明显偏大,倒计时就"变慢"了。常见做法是记录开始时间戳,每次渲染时用当前时间反推剩余秒数:
const endAt = Date.now() + TOTAL_SECONDS * 1000; function refreshRemain() { const remain = Math.max(0, Math.round((endAt - Date.now()) / 1000)); if (remain === 0) { clearInterval(timer); this.setData({ status: 2 }); } else { this.setData({ remain }); } } timer = setInterval(refreshRemain, 1000);这样即使定时器被系统延迟执行,显示值也不会偏移,因为始终以endAt - Date.now()为准。时长参数可以放到 setting 页让用户配置 15/25/45 分钟三档,用wx.setStorageSync('focusMinutes', 25)存本地,页面加载时读取并给默认值兜底:
const minutes = wx.getStorageSync('focusMinutes') || 25;3.3 计时结束后的打卡表单
计时进入 finished 状态后,页面出现表单区域,包含文本输入和图片选择。图片选择用wx.chooseMedia,这是基础库 2.10.0 以后推荐替代wx.chooseImage的接口:
chooseImage() { wx.chooseMedia({ count: 1, mediaType: ['image'], sourceType: ['album', 'camera'], success: (res) => { this.setData({ imagePath: res.tempFiles[0].tempFilePath }); } }); }count 限制可选数量为 1,mediaType 只允许图片,sourceType 同时开放相册和相机。拿到临时路径后要上传到云存储,才能得到可长期访问的 fileID:
async uploadImage() { const ext = this.data.imagePath.match(/\.(\w+)$/)[1]; const cloudPath = `checkin/${this.data.openid}_${Date.now()}.${ext}`; const res = await wx.cloud.uploadFile({ cloudPath, filePath: this.data.imagePath }); return res.fileID; }cloudPath 必须保证全局唯一,否则不同用户上传同名文件会互相覆盖。我的习惯是把 openid 加时间戳拼进去。上传成功后返回的 fileID 可以直接塞给<image>组件的 src,不需要自己拼域名。注意wx.chooseMedia需要真机调试才能完整测相机路径,开发者工具里只能走相册。
3.4 打卡记录落库
表单校验通过后,向云数据库的 checkins 集合写入一条记录:
const db = wx.cloud.database(); await db.collection('checkins').add({ data: { taskName: this.data.taskName, content: this.data.content, imageFileID: this.data.imageFileID, duration: TOTAL_SECONDS, openid: this.data.openid, createTime: db.serverDate(), likeCount: 0 } });db.serverDate()生成的是数据库服务端时间,比客户端本地时间可靠,避免用户手机时间不准导致排序错乱。duration 存的是本次专注秒数,后面统计每日学习时长、算日均活跃都会用到。到这一步,选任务 → 倒计时 → 填内容传图 → 落库的主链路就完整跑通了。
4. 打卡记录流与点赞:云数据库的读写设计
上一章把写数据的链路打通了,这一章说读:打卡记录怎么展示成信息流,点赞这种高频小写操作怎么做才不踩坑。posts 页面就是记录流,logs 是个人维度,两者本质都是对 checkins 集合的查询,区别只在筛选条件:一个查全部,一个按 openid 过滤。
4.1 集合结构与字段约定
结合数据库截图和源码推断,这个项目至少有 users、tasks、checkins 三个集合。一次打卡会产生一条 checkins 记录,字段设计如下:
| 集合 | 关键字段 | 说明 |
|---|---|---|
| users | openid, nickname, avatar, totalMinutes | 用户维度聚合数据 |
| tasks | name, creatorOpenid, defaultMinutes | 可自定义的学习任务 |
| checkins | taskName, content, imageFileID, duration, openid, createTime, likeCount | 打卡主记录 |
一个关键设计是 checkins 里冗余了 taskName 而不是只存 taskId。原因是打卡记录一旦生成就不该被任务改名影响,而且云数据库没有 join,冗余字段在展示时少一次关联查询。这是云开发项目的标准做法:读时冗余,写入时一次落全。
4.2 getOpenID 云函数与身份识别
客户端直接读写数据库,必须知道当前用户是谁。openid 是用户在小程序内的唯一标识,但出于安全考虑,openid 不能由客户端伪造,必须通过云函数从微信上下文里取:
// cloud/getOpenID/index.js const cloud = require('wx-server-sdk'); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); exports.main = async () => { const wxContext = cloud.getWXContext(); return { openid: wxContext.OPENID, appid: wxContext.APPID, unionid: wxContext.UNIONID }; };客户端调用:
const res = await wx.cloud.callFunction({ name: 'getOpenID' }); this.setData({ openid: res.result.openid });这里必须用DYNAMIC_CURRENT_ENV而不是写死环境 ID,否则换一个云环境部署云函数时就要改代码。云函数是冷启动的,getOpenID 这种轻量逻辑每次调用在几十毫秒内,不需要担心性能。拿到 openid 后,所有"是不是我发的记录""我能不能改这条"的判断都以它为准。
4.3 记录流查询与分页
posts 页面按时间倒序展示记录。云数据库单次get默认最多返回 20 条,所以分页是必须的。不要用 skip 分页——数据量上去后会越来越慢。我一般用游标分页,以最后一条记录的 createTime 作为下次查询的起点:
const db = wx.cloud.database(); const pageSize = 10; async loadNextPage() { const query = db.collection('checkins') .orderBy('createTime', 'desc') .limit(pageSize); const res = this.data.pageCursor ? await query .where({ createTime: db.command.lt(this.data.pageCursor) }) .get() : await query.get(); this.setData({ records: this.data.records.concat(res.data), pageCursor: res.data.length ? res.data[res.data.length - 1].createTime : null }); }游标字段必须和排序字段一致,否则会出现重复或漏数据。db.command.lt是小于操作符,配合 createTime 倒序正好组成"取更早的记录"这个语义。records 拼接而不是覆盖,保证下拉加载时前面已渲染的数据不丢失。下拉触底时调用一次loadNextPage,pageCursor 为 null 说明没有更多数据,可以停止请求。
4.4 点赞的并发安全
点赞是典型的"读-改-写"操作。如果写成先 get 再 set 把 likeCount 加一,两个人同时点赞时会把其中一次自增吞掉。云数据库支持原子操作符,正确写法是:
const _ = db.command; await db.collection('checkins') .doc(recordId) .update({ data: { likeCount: _.inc(1) } });_.inc(1)是数据库服务端原子自增,不需要先读后写,天然避免并发丢失更新。如果还要防止同一个用户重复点赞,在 checkins 里维护一个 likeUsers 数组:
await db.collection('checkins').doc(recordId).update({ data: { likeUsers: _.addToSet(openid), likeCount: _.inc(1) } });_.addToSet保证同一 openid 在数组里只出现一次,配合前端按钮的点赞状态判断,可以做到幂等。需要注意单个数组元素超过 1000 后会有性能问题,课程设计场景完全够用;如果要产品化,应该把点赞拆成独立的 likes 集合。前端判断当前用户是否点过赞,用this.data.record.likeUsers.includes(openid)即可。
提示:如果按 createTime 倒序分页时提示"查询需要索引",去云开发控制台 → 数据库 → checkins 集合 → 索引管理,给 createTime 字段单独建一个降序索引即可。这是云数据库和自建 SQL 的最大区别:隐式索引不够用,得显式建。
5. 上手指南:部署、权限与三个必然遇到的坑
拿到源码先别急着改业务,把环境跑起来是第一步。用微信开发者工具导入工程目录,在 app.js 里把env换成你自己的云环境 ID。没开云开发的话,先点工具顶部的"云开发"按钮创建一个环境——免费额度对课程设计数据量绰绰有余。演示视频里从头到尾过了一遍,第一次跑可以对照着看。
5.1 数据库权限设置
云开发数据库默认权限是"仅创建者可读写",这意味着 posts 页面读不到别人创建的打卡记录,记录流会是一片空白。这个系统需要所有人可读、仅创建者可写:在控制台把 checkins 集合权限改为"所有用户可读,仅创建者可读写"。users 集合建议保持"仅创建者可读写",避免 openid 被遍历。用自定义规则可以写成:
{ "read": true, "write": "doc.openid == auth.openid" }read 为 true 表示所有登录用户可读,write 限定了只能改自己创建的文档。注意:云函数操作数据库不受这个权限限制,所以管理端逻辑都走云函数是更安全的姿势,但课程设计里客户端直连够用。
5.2 三个高频报错
第一个是env not found:app.js 里wx.cloud.init的环境 ID 和控制台实际环境 ID 不一致,或者本地调试时选择了"不使用云开发"。去控制台复制环境 ID 再检查一遍。
第二个是wx.chooseMedia is not a function:基础库版本低于 2.10.0。在开发者工具"详情 → 本地设置"里把调试基础库调高即可,真机预览时也要确认手机微信版本够新。
第三个是图片显示空白:上传成功但<image>渲染不出内容。检查是不是把 fileID 当成临时路径用了,fileID 直接给 image 的 src 是可以的,但如果云存储跨环境访问,需要确认两个环境是同一个账号下的,否则会鉴权失败。另外开发者工具里偶尔有 fileID 缓存不刷新的情况,清缓存重编译就好。
5.3 一个值得试的性能优化与验证技巧
新手最容易忽视的是 setData 的调用频率。倒计时页面每秒 setData 一次,如果页面切到后台定时器还在跑,会白白消耗电量。可以用页面生命周期把定时器暂停:onHide里 clearInterval,onShow里重新用 endAt 计算剩余并恢复。这个优化点写进论文,是能讲清楚"渲染开销与生命周期"关系的具体案例。
验证时有个小技巧:把手机系统时间改乱几分钟,再去看打卡记录的排序是否仍然正确。如果时间被改动了排序还能保持稳定,说明 serverDate 的使用是到位的;如果排序乱了,说明某处用了客户端时间——这个排查方法比翻代码找 Date 调用点快得多。
本文还有配套的精品资源,点击获取