第一期 · 本地优先 MVP:不写一个后端,如何做出完整体验

关键词:微信小程序原生 · 离线可用 · 派生计算 · Canvas 自绘 · 包体积控制 目标读者:前端 / 产品


一、业务目标

用最短路径验证「用户是否愿意每天记录」。

一条铁律:验证留存不需要后端。如果用户在没有账号、没有云同步、没有推送的情况下都不愿意记,那么加了后端也不会记。所以一期把全部预算压在「记录这件事本身是否足够顺滑」:

业务指标 一期目标 衡量方式
首次记录转化 启动 → 完成首次打卡 ≥ 60% 埋点(二期补);一期用本地 totalRecords 抽样
单日记录耗时 ≤ 15 秒完成一次打卡 交互步数:首页点情绪 → 完成(1 步)
次日回访 有 streak 概念驱动 computeStreak 展示连续天数
功能完整度 记录-回顾-统计闭环 8 页面 14 组件

明确的非目标:账号、云同步、社交、内容社区、AI 解读。一期不做,是为了让「本地优先」成为产品的默认形态而不是过渡形态------一旦用户先习惯了「必须登录」,后面再退回离线就回不去了。


二、核心功能范围

功能 页面 说明
今日打卡 pages/index 5 档情绪一键打卡、连续天数徽章、本周色点、情绪预警文案、分享封面生成
情绪记录 pages/record 完整表单:情绪等级、标签、诱因、备注、补记
历史回顾 pages/history 时间线浏览、编辑、删除
数据统计 pages/stats 趋势折线、分布柱状、月度热力、标签 Top、周期指标、四期关联
生理期记录 pages/period 日历点选区间、进行中标记、预测展示
我的 / 设置 pages/profile pages/settings 主题、提醒、存储模式、导出导入
习惯转盘 pages/wheel 轻互动引导习惯养成
登录(占位) pages/login 一期不启用真实鉴权

三、涉及的技术模块

css 复制代码
emotion-monster/
├── app.js                 启动流程 / 全局状态 / 网络与生命周期
├── app.json               路由 + tabBar
├── pages/                 8 个页面
├── components/            14 个自定义组件
│   ├── chart-canvas       ★ Canvas 2D 自绘:line / bar / heatmap
│   ├── period-calendar    月历矩阵 + 六种格态
│   ├── mood-picker        5 档情绪选择
│   ├── mood-quick-bar     首页快速打卡条
│   ├── tag-picker         标签选择
│   ├── streak-badge       连续天数徽章
│   ├── stat-card / empty-state / skeleton / nav-bar / confirm-modal
│   ├── ui-icon / user-avatar / lucky-wheel
└── utils/
    ├── calc.js            ★ 全部统计与预测算法(纯函数)
    ├── date.js            日期工具(YYYY-MM-DD 字符串为唯一真值)
    ├── theme.js           主题解析与应用(含 tabBar 同步)
    ├── constants.js       常量(SCHEMA_VERSION、MOOD_LEVELS 等)
    └── storage.js         存储(二期详解)

四、关键技术选型与实现要点

4.1 日期:统一用 YYYY-MM-DD 字符串,而不是 Date 对象

这是整个项目最重要的一个决定。 情绪记录按「天」聚合,生理期按「天」计算,一切混乱都来自时区与毫秒精度。

js 复制代码
// utils/date.js 的核心约定
today()      -> '2026-09-16'
parse(str)   -> Date | null
diffDays(a,b)-> int
  • 所有存储、比较、索引都用字符串recordDatestartDate),字典序即时间序,Mongo 里也能直接 gte/lte 走索引;
  • 只在「计算」和「渲染」的瞬间转成 Date
  • 服务端 DateUtil.ZONE = Asia/Shanghai 与前端保持一致,避免跨零点时差导致「我明明今天打的卡,却算到了昨天」。

踩过的坑 :如果用 new Date().toISOString().slice(0,10) 取今天,UTC+8 的用户在 08:00 之前会拿到昨天

4.2 派生数据不落库:streak 是算出来的,不是记出来的

js 复制代码
// utils/calc.js:10-25
function computeStreak(records, lastCheckInDate, today) {
  const dates = records
    .map(r => r.recordDate).filter(Boolean)
    .filter(d => DateUtil.isDate(d))
    .reduce((acc, d) => acc.includes(d) ? acc : acc.concat(d), [])   // 去重
    .sort().reverse();                                               // 倒序

  if (!dates.length) return 0;
  // 关键:最近一次既非今天也非昨天 → 断签
  if (dates[0] !== today && dates[0] !== DateUtil.addDays(today, -1)) return 0;

  let streak = 1, cursor = dates[0];
  for (let i = 1; i < dates.length; i++) {
    if (dates[i] === DateUtil.addDays(cursor, -1)) { streak++; cursor = dates[i]; }
    else break;
  }
  return streak;
}

设计要点:

  • 容忍「今天还没打卡」:最近一次是昨天时 streak 不归零,否则用户每天早上打开 App 都会看到徽章清零,体验极差;
  • lastCheckInDate 参数不参与判定(保留只为兼容旧签名)。历史 bug 正是「用 lastCheckInDate 参与判定导致首日恒为 0」;
  • 与服务端 StreakCalculator.java:17-44 完全同构------这是「同构纪律」的第一条。

4.3 图表:Canvas 2D 自绘,不引第三方库

方案对比:

方案 包体 定制性 结论
Canvas 2D 自绘(本项目) 0 KB 依赖 完全可控,暗色主题/圆角/描边随心 ✅ 三种图表 type 就够用
ECharts for 小程序 +300~500 KB 强,但定制要绕 后台选它(第五期),端上不必
F2(AntV) +150 KB 移动端友好 图表需求变复杂时再迁

小程序主包限制 2MB,分包 20MB。一期只有 3 类图表(折线/柱状/热力),自绘 167 行即可,性价比最高。

js 复制代码
// components/chart-canvas/chart-canvas.js 关键实现点
observers: { 'data, type, dark': function () { this.draw(); } },

draw() {
  // ① 节点未就绪重试:Canvas 节点在 onReady 前可能取不到
  //    重试 10 次 × 100ms,仍失败 triggerEvent('fail') 让上层降级到空态
  // ② 按 dpr 缩放:ctx.scale(dpr, dpr),避免高分屏模糊
  // ③ 高度用「测量值」而非 props 传入的 rpx------否则坐标系不一致会把图压扁
  // ④ heatmap 按 1 号真实星期对齐列,今日格加描边
}

工程经验chart-canvas 的失败不是异常,而是降级 。统计页用 safe() 包裹每个请求,画布绘制失败也只是回退空态文案------任何单点失败都不能让整页变白

4.4 统计页:并发请求 + 单点兜底

js 复制代码
// pages/stats/stats.js:45-101
const safe = (p) => p.catch(() => null);          // 单接口失败不影响其他
Promise.all([
  safe(Api.request('stats', 'moodTrend', { range })),
  safe(Api.request('stats', 'moodDistribution', { range })),
  safe(Api.request('stats', 'tagFrequency', { range, top: 8 })),
  safe(Api.request('stats', 'periodOverview')),
  safe(Api.request('stats', 'periodCorrelation')),
  safe(Api.request('stats', 'moodHeatmap', { year, month })),
  safe(Api.request('mood', 'list', { pageSize: 1 }))   // 取全量 total
]).then(([...]) => { /* 渲染 */ });

前端二次计算(服务端不提供的派生指标):

js 复制代码
checkInRate = 范围内有记录的天数 / range
hasTrendData = trend.some(v => v > 0)    // trend 数组恒非空,必须看有效值

4.5 生理期日历:六种格态一次说清

js 复制代码
// components/period-calendar/period-calendar.js:39-46
isActual        已记录区间内
isStart / isEnd 区间起止
isPendingStart  用户已点第一个日期、等待第二个日期
isPredict       预测区间
isNext          预测的下次开始日

交互设计(pages/period/period.js:73-142):

  1. 未来日期 → 拒绝;
  2. 命中已有记录 → 弹出「删除该条」;
  3. 已有 pendingStart → 第二次点击作为结束日(endDate < startDate 拒绝);
  4. 空日期 → ActionSheet:选择区间 / 标记为结束(进行中)/ 标记为开始(进行中)/ 标记为一天。

⚠️ 现存缺陷:pages/period/period.jseditRecord 被定义两次(第 48 行与第 145 行),后者覆盖前者,导致「删除」单一选项分支实际不可达。属于一期遗留,建议在二期收尾时合并。


五、数据流转与接口设计

5.1 数据流(一期:全部在本地闭环)

scss 复制代码
用户操作
  │
  ▼
page.js  →  Api.request(module, action, payload)      ← 统一门面,页面层唯一入口
  │
  ▼
LocalAdapter.request()
  ├─ 校验 validateMood / validatePeriod
  ├─ 读写 storage 内存镜像(getDomain 返回引用 → 原地 mutate → setDomain 回写)
  ├─ 派生重算 recalcStreak() / recalcPredict()
  └─ storage.emit('mood:changed' | 'period:changed')
  │
  ▼
storage 防抖 500ms → wx.setStorageSync('em_mood' / 'em_period')
  │
  ▼
事件订阅者(如 pages/index 注册 mood:changed)→ 重新 load

5.2 内部契约(一期就定死,二三四期直接复用)

模块与动作矩阵:

module action payload 返回
mood list {date, tagId, page, pageSize} {list, total, hasMore}
mood get / create / update / remove {id, fields...} 记录 / {id}
period list / get / create / update / remove / predict 同上 记录 / PredictResult
stats moodTrend moodDistribution tagFrequency moodHeatmap periodOverview periodCorrelation moodStreak {range, year, month, top} 各统计结构
data export / import / clear {json, mode, scope} {imported, skipped}
setting get / update patch 设置对象

统一返回:

js 复制代码
class Result {
  constructor(ok, code, data, error) {}
  // code: 'OK' | 'VALIDATION' | 'NOT_FOUND' | 'STORAGE' | 'NETWORK'
  static ok(data) {}
  static fail(code, error) {}
}

为什么一期就要抽象出 ResultDataAdapter 因为四期加 RemoteAdapter 时,页面代码一行都不用改------这是本系列最重要的一次「提前设计」。

5.3 校验规则(前后端必须一致)

js 复制代码
// utils/adapters/local-adapter.js:12-27
validateMood:   moodLevel ∈ [1,5] · note ≤ 500 字 · 补记 ≤ 30 天(DAY_LIMIT)
validatePeriod: startDate 不得晚于今天 · endDate ≥ startDate

服务端 MoodService / PeriodService 注释明确写着「与前端 local-adapter.js 保持一致」,30 天上限由 app.data.mood-backfill-days: 30 配置驱动,两端同源。


六、技术难点分析

难点 1:无后端下的「数据可信」

问题:所有数据都在用户手机上,一旦 storage 被清、小程序被删,数据永久丢失;用户也没有「安全感」。

应对(一期做到,四期完善):

  • 立即提供 data/export 导出 JSON,settings 页可导出/导入;
  • 明确告知用户「数据在你手机上」,把隐私做成卖点而不是免责声明;
  • 二期补齐 em_guest_backup 备份机制,四期云端合并。

难点 2:派生指标的一致性与性能

问题:streak、预测、统计都是全量计算的。记录越多越慢。

应对

  • 一期数据量小(<1000 条),纯函数全量计算 <5ms,无需优化;
  • 预留缓存点LocalAdapter 把预测结果写入 period.cache,只在记录变更时 recalcPredict()
  • 服务端侧则是「读时计算,不落库」------第五期再加缓存。

难点 3:预测算法的可解释性

用户看到「预计 10 月 3 日来月经」会追问:凭什么?

js 复制代码
// utils/calc.js:37-92  predictPeriod 三步法
// ① 算周期序列 + 离群剔除(中位数 ±6 天)
// ② 加权移动平均(近 3 个周期,权重 3:2:1),钳制 avgCycleLen ∈ [20,45]
// ③ 规律性 CV = 标准差 / 均值
//    confidence: high(cv ≤ 0.08 且样本足) / medium / low / insufficient(<3 条)
//    区间 ±1 天(样本足)否则 ±2 天

设计要点 :样本 <3 时明确返回 insufficient 而不是硬算。宁可说「数据还不够」,也不要给一个看似精确的错误日期------健康类产品的信任一旦崩塌无法挽回。

⚠️ 已知口径分歧 :前端按「记录条数」判置信度(sorted.length>=6 / >=3),服务端 PeriodPredictor.java:79-88 已改为按「周期数」(valid.size()>=5 / >=2)。同一份数据在游客态和登录态可能显示不同的「规律性」文案。第五期统一。

难点 4:情绪-周期关联的归属歧义

js 复制代码
// utils/calc.js:153-205 与 PeriodCorrelationAnalyzer.java:28-97
// 阶段划分:月经期(0-4) / 卵泡期(5-13) / 排卵期(14-16) / 黄体期(17-)
// 归属规则:一条情绪记录只归属到「最近一次 startDate ≤ 记录日」的周期

旧实现对每个经期遍历 0~40 天,相邻周期重叠区会重复计入。修正后按「最近一次起点」归属,并在后端类注释中记录了这次修正。偏移 >40 天丢弃,等级越界数据剔除(否则 NaN 会污染均值)。

PMS 提示的触发条件(保守,避免误报):

复制代码
黄体期样本 ≥2 且 卵泡期样本 ≥2 且 黄体期均值 ≤ 卵泡期均值 - 0.5

七、性能与安全考量

性能

关注点 措施 指标
首屏 骨架屏 skeleton + 并发请求 + 本地同步读 首屏 <800ms
写入 防抖 500ms,避免连续打卡打爆 Storage 单次写入 <10ms
图表 dpr 缩放 + 重试机制 + 失败降级 绘制 <100ms
包体 不引第三方图表/UI 库,图标用 ui-icon 组件 主包 <1MB

安全

一期虽然没有后端,安全仍然要做:

  • 数据不落网:一期内所有数据不出设备,天然规避传输与存储风险;
  • 分享封面脱敏pages/index 的离屏 Canvas 生成 500×400 分享图,只画情绪与天数,不画备注原文
  • 本地仍是明文wx.setStorageSync 不加密。这是客观风险,应在设置页如实告知;如需加密,见第五期「端到端加密」展望。

八、风险与应对

风险 影响 概率 应对
Storage 10MB 上限 记录写满后无法写入 低(单条 <200B,5 万条才 10MB) 监控写入异常;二期加容量提示;远期归档
用户清缓存丢数据 记录全丢 导出功能前置到设置页首屏;三期登录后自动上云
Canvas 兼容差异 部分机型图表空白 重试 10×100ms + fail 事件降级空态
wx.getSystemInfo 废弃 状态栏高度取不到 已用 wx.getWindowInfo() 替代(app.js:27
双实现口径漂移 游客/登录显示不一致 建立算法对照测试;第五期统一

九、验收标准与交付物

验收标准

  • 冷启动 → 完成首次打卡,交互步数 ≤ 2;
  • 断网状态下全部功能可用,无一处 loading 卡死;
  • 连续打卡徽章在「今天未打卡但昨天打了」时不清零;
  • 周期样本 <3 条时展示「数据不足」而非预测日期;
  • 统计页 7 个请求中任意 1 个失败,页面仍可渲染其余模块;
  • 导出 JSON 可被导入还原,记录数与内容完全一致;
  • 主包 <1MB,首屏 <800ms;
  • 主题切换(含 tabBar)全页面生效,深色模式无对比度问题。

交付物

类型 内容
代码 pages/ 8 页面、components/ 14 组件、utils/calc.js date.js theme.js
文档 详细设计文档 v4.0(优化版)
资产 assets/ 图标与图片 17 个
契约 DataAdapter + Result 抽象(为二~四期铺路)

十、后续优化方向

  1. period.js 中重复定义的 editRecord,恢复删除分支;
  2. 算法对照测试 :把 calc.js 的 5 个函数与后端 calculator/ 用同一组 fixture 跑断言,防止口径继续漂移;
  3. 打卡提醒 :一期只做了设置项,未接订阅消息------二期结合 reminderTime 落地;
  4. 记录模板:高频用户希望「一键复用昨天的标签」,可做成快捷模板;
  5. 无障碍:为 5 档情绪补充语义化标签与更大点击热区。

相关推荐
EatFan2 小时前
Java接入微信支付保姆式教程(三):SpringBoot 接入微信支付并完成统一下单
微信小程序·微信支付·springboot
QQ_21696290964 小时前
基于SpringBoot+Vue的小生活平台的设计与实现
java·数据库·vue.js·spring boot·spring·微信小程序·生活
飞梦工作室4 小时前
H5页面能否直接播放视频号直播?实战方案与踩坑总结
微信小程序·小程序
StevenLdh20 小时前
情绪小恐龙:一个微信小程序从架构设计到部署上线的全记录
微信小程序·小程序·notepad++
Bs_MoneyMagnet1 天前
基于springboot+vue的旅游行程分享与推荐小程序的设计与实现 源码+文档
java·vue.js·spring boot·后端·微信小程序·毕业设计·计算机毕业设计
西木风落1 天前
业余发展——零后端微信小程序口算练习实战
微信小程序·vibe coding·口算小达人
xujuzheng1 天前
2026深圳小程序/App/AI智能体开发公司选型指南(附本地服务商盘点)
数据库·科技·微信小程序·小程序·uni-app
毕业设计7032 天前
(免费领源码) 基于微信小程序的预制菜商城的设计与实现25172-java、PHP、python、C#、小程序、大数据、单片机、网络工程等)
vue.js·python·mysql·微信小程序·pycharm·微信开发者工具·推荐算法
EatFan3 天前
Java接入支付宝 JSAPI 支付保姆教程(二):流程讲解与前后端代码讲解
前端·spring boot·后端·微信小程序·小程序·uni-app