Compare commits

..

8 Commits

Author SHA1 Message Date
lc 346d46cb27 feat(index): 新版本更新通知(What's New)弹窗
- 复用 ui-modal(position:center) + ui-btn,不新建组件,风格统一且自动跟随暗黑
- config.js 新增 releaseNotes 数组(每条一行短句),复用已有 version 字段
- 首页 _checkUpdateNote() 在 onShow 检测 storage 版本 vs config.version,
  不一致则居中弹通知并写回 storage 防重复;onCloseUpdateNote 关闭
- 绿色渐变徽章 + sparkleWhite 图标 + 版本标题 + 要点列表 + 知道了按钮
- 纯通知不重启应用;发版时改 version 同步更新 releaseNotes 即可
2026-08-14 10:40:03 +08:00
lc 9d2c752043 fix(share): 分享海报大数字压状态文案+删二维码与顶部品牌名
- 根因:状态文案 y=172(22px)、大数字基线 y=230(最大90px),多位数
  分钟数字顶部≈165 落入文案区(150~172),压住"完成"文案
- 重排垂直节奏:卡片放大,状态文案 y=184,大数字基线 y=286(上限84px,
  与文案下沿间距 34 安全),连续打卡 y=332
- 删顶部"平板的支撑"品牌名、删右下角二维码及"扫码一起打卡"引导
- 删除废弃资源 static/share-qr.jpg;导出改为绘制完直接导出无需等图
2026-08-14 09:22:04 +08:00
lc 8ca709b86b feat(timer): 训练完成分享海报(canvas+小程序码),解决截图被截断
根因:onShareAppMessage 未返回 imageUrl,微信默认截页面顶部,
完成弹窗垂直居中导致分享卡片只截到上半部分。

实现:
- 隐藏 canvas 2d 绘制 5:4(500x400)成绩海报,含绿色星星图标、状态文案、
  大数字时长、连续打卡、底部引导与右下角小程序码
- _saveAndShowCompletion 弹窗数据就绪后预生成,onShareAppMessage 直接复用
- onTrainAgain 清空 _shareImageUrl 防旧图复用
- 新增 static/share-qr.jpg(用户提供的小程序码)
2026-08-14 09:13:03 +08:00
lc 845a1bfd8d fix(circuit): 偶数 set 数休息语音被一半/进度提示吞掉
偶数组数 sets×hold/2 正好压在组练完→休息切换的同一 tick:
_circuitCue 先播 halfway 并占麦 2.6s,导致 _circuitPhase 的
restStart 被静音。修复:atRestBoundary 且 restStart 会播时,
跳过 halfway/last30/last10 让休息语音优先;奇数组数/短组
配置不动,无回归。
2026-08-14 08:52:07 +08:00
lc aa905b1d09 fix(tabBar): 切换暗黑模式后其他 tab 的底栏不跟随变暗
根因:自定义 tabBar 的 pageLifetimes.show() 不触发(微信框架
特殊行为),各 tab 页 onShow 只设了 selected 没同步主题;
onSelectDarkMode 里 getCurrentPages() 只返回当前 tab 页,
其他 tab 的 tabBar 实例未收到更新。

修复:4 个 tab 页 onShow 里 tb.setData({selected:X}) 后加
tb.updateTheme(),显式同步主题。updateTheme 内部有样式未变
则跳过的优化,不会多余渲染。
2026-08-14 08:45:19 +08:00
lc b6d94b21cd fix(timer): 分段弧光点居中置顶 + 拉大与进度弧间距
- 光点移到段中点(am=(a0+a1)/2),两端粗细对称;重构为两遍绘制,
  光点统一后绘浮于所有弧之上,不再被相邻圆头吞掉;加大加亮成珠状
- glowPad 6→18 与 _drawSetArcs pad 同步,arcR 外移 3→14,
  分段弧与进度弧间距 -1 → +10dpr(size=420),呼吸光环不重叠
2026-08-12 10:34:54 +08:00
lc f1ad3eeb95 feat(timer): 训练时长低于阈值不记录并明确提示
- config.js 新增 minRecordSeconds: 20 阈值配置,改配置即可调整门槛
- timer.js: finishTraining/_onCircuitComplete 低时长不播完成语音;
  _saveAndShowCompletion 兜底判断,任何入口都不会把短时训练落库;
  新增 _showNotRecorded: 不保存记录、不更新连胜,复用完成弹窗展示实际秒数
- timer.wxml/wxss: 完成弹窗新增"未计入记录"提示行(门槛说明),
  未记录时隐藏"查看记录/分享"按钮,只留"再来一次+回到首页"
- 修复状态残留: 未记录后点"再来一次",下次正常完成不会误显示"未计入记录"
2026-08-12 10:20:40 +08:00
lc 178a51d261 docs(memory): 记录 GitHub 推送 HTTP/1.1 解法 2026-08-12 09:41:27 +08:00
14 changed files with 588 additions and 37 deletions
+44
View File
@@ -62,3 +62,47 @@
- `.info-bubble` margin-top 24→40rpx(承接原 timer-status 间距)、padding 20→24rpx。 - `.info-bubble` margin-top 24→40rpx(承接原 timer-status 间距)、padding 20→24rpx。
- 关键机制备忘:**resting 时 status 字段值仍是 'running'**,状态行转绿必须靠 `isResting` 单独加类,不能只看 status。 - 关键机制备忘:**resting 时 status 字段值仍是 'running'**,状态行转绿必须靠 `isResting` 单独加类,不能只看 status。
- 验证:grep 无 `timer-status` 代码残留(仅注释文字),js 未改动。 - 验证:grep 无 `timer-status` 代码残留(仅注释文字),js 未改动。
## 进度弧"0 点对齐"问题:两轮修复被否定,已全部回滚(待用户澄清)
- 用户反馈(两次截图):训练时粗进度弧起点与 0 点/顶部起点差一点没对齐,运动过程中更明显(循环与单组模式都出现过)。
- 尝试过两轮修复(progress-ring.js `_drawSetArcs` 半径/清除区微调),用户判定"完全理解错了",**要求撤销**。
- 已回滚:`git diff` 确认 progress-ring.js 与 HEAD`05c4763` 分段弧初始实现:glowPad=6、arcR=radius+lw/2+3、arcW=8、清除区 clip 内缘 arcR-band)完全一致,`node --check` 通过。
- 教训:对齐类问题不要凭数值推理直接改半径,先让用户明确"0 点"的参照物(刻度环/呼吸环/分段弧?)与出现模式,再定位。
- **最终定位与修复(用户澄清"所有运动中,进度弧零点与表盘 0 点没对齐")**:
- 根因确凿:`_drawSetArcs` 每段起点 `+gap/2`(1.25°),**外圈分段弧第一段从 12 点偏右 1.25° 开始**(半径 ~207dpr 处 ≈ 4.5dpr ≈ 1.5px),而内圈进度弧/12 点长刻度都从精确 -π/2 开始 → 循环模式外圈与内圈 0 点肉眼错开。
- 修复:`a0 = startAngle + (i-1)*segAngle`(第一段精确 -π/2 起),`a1 = a0 + segAngle - gap`(段尾留 gap)。段间空隙均匀(2.5°×3),收尾空隙落在 12 点左侧,环带以 12 点为起点。node 验算:段起点 270°/30°/150°,空隙 2.5° 均匀,首段 12 点 ✓。
- 单组/自由模式无分段弧,canvas 内刻度/进度弧同基准(-π/2),数学对齐;若用户仍反馈错位需再查(可能涉及 canvas 布局/参照物)。
- 注意:`gap/2` 对称偏移是"段内居中空隙"的常见写法,环形进度环带与刻度基准对齐时必须从 -π/2 起算。
- **用户叫停("算了,这个问题先不修复了")**:零点对齐问题整体搁置。该轮 progress-ring.js 的角度修复(仅影响循环模式、覆盖不了单组模式现象)已 `git checkout` 回退,与 HEAD 一致。等用户想继续时再排查,方向可能是 canvas 布局/参照物而非半径。
## 新功能:训练时长低于阈值不记录(config.minRecordSeconds,默认 20 秒)
- 需求:训练有效时长 < 20 秒(可配置)不写入记录,并给用户明确提示;阈值收进 config.js。
- **`config.js`**:新增 `minRecordSeconds: 20`(带注释:不写记录、不更新连胜;循环按有效撑持秒数=各组时长之和判断)。
- **`pages/timer/timer.js`**
- `finishTraining()``elapsed >= minSec` 才播完成语音(低于阈值不庆祝)。
- `_onCircuitComplete()`:循环自然完成路径同样按 `workTotal >= minSec` 播语音。
- `_saveAndShowCompletion(elapsed)` 开头加**阈值兜底**`elapsed < minSec``_showNotRecorded(elapsed, minSec)` 直接 return。手动结束与循环自然完成两条路径都汇聚到这里,任何入口都不会把短时训练落库。
- 新增 `_showNotRecorded(elapsed, minSec)`:复用完成弹窗(避免系统原生 modal 在 WebView stacking context 下 hit-test 不稳),不保存记录/连胜、无 confetti/streak/科学提示,大数字照常 countUp 到 elapsed;置 `completionNotRecorded: true``minRecordSeconds: minSec`、标题"未达最低记录时长"。
- data 新增初始字段 `completionNotRecorded: false``minRecordSeconds: 20`
- **状态残留坑(已修)**`onTrainAgain`(再来一次)与 `_saveAndShowCompletion` 正常分支都必须显式 `completionNotRecorded: false`,否则上轮未记录后重练,下次正常完成弹窗会误显示"未计入记录"且隐藏分享/记录按钮。
- **`timer.wxml`**:完成弹窗新增 `.completion-not-recorded` 提示块(infoFill 图标 + "本次训练未计入记录" + 副行"撑满 X 秒以上才会记录并计入连续打卡");未记录态 `wx:if="{{!completionNotRecorded}}"` 隐藏"查看记录/分享"(没有成绩可看/可晒),仅留"再来一次 + 回到首页"。
- **`timer.wxss`**:新增 `.completion-not-recorded` 主色淡底信息块(与 completion-tip 同风格,tipIn 延迟 0.5s 入场)。
- 保留逻辑:`elapsed < 3` 秒仍走 toast「训练时间太短」+ navigateBack(几乎未开始,不弹完成弹窗)。
- 验证:node --check 通过;grep 确认 minRecordSeconds/completionNotRecorded/_showNotRecorded 三处字段在 config/js/wxml 引用一致。
## 分段弧两端粗细不一致(已修)
- 用户反馈:循环训练时分段弧"两端粗细不一致"。
- 根因(progress-ring.js `_drawSetArcs`):发光光点坐标固定取段尾角 `a1``dx=cx+arcR*cos(a1)`),当前段(active)/休息预告段(next)的尾端 = 光点+8dpr 发光晕(直径≈弧宽+),首端却只是 `lineCap:'round'` 普通圆头 → 同段弧尾粗头细。已完成/未开始段两端都是普通圆头,粗细一致,所以现象集中在运动中那一段。
- 修复:光点移到**段中点** `am=(a0+a1)/2`(3 行),两端自然对称,光点仍作"当前组"标记。node --check 通过。
- 注意:若以后想给分段弧加"当前组跟随倒计时移动"的进度感,光点可改为随剩余时间在段内插值,但需先确认用户是否要这个效果。
## 分段弧间距 + 光点置顶(用户反馈"两弧太近、弧压着光点")
- 用户反馈两点:①分段弧与进度弧距离太近;②光点被分段弧压住(视觉上弧盖着光点)。
- **间距修复**progress-ring.jssize=420 时验证):
- 根因:间距 = gap arcW/2 = 34 = **−1dpr(重叠 1)**。因为分段弧 arcR 仅比进度弧外缘靠外 3dpr,而弧半宽 4dpr。
- `_draw()``glowPad` 6→14dpr(进度弧半径内缩 8,给外圈腾空间);`_drawSetArcs()` 的 radius pad 6→14**两处必须同步**,已加注释)且 arcR 间距 `+3→+9dpr`。结果:进度弧外缘 204→196、分段弧内缘 203→201,间距 −1→**+5dpr**。副作用:单组/自由模式的进度环也内缩 ~8dpr(约 4%),中心数字区略小;进度弧尾端发光点 shadowBlur=12 阴影反而从"被 canvas 裁"变完整。
- 段间空隙 gap=2.5° 是段间距,**与环间距无关,不动**。
- **光点置顶**:原实现光点在逐段循环内、每段弧 stroke 之后画,理论上应在弧上,但视觉上被 round cap 相邻段边缘"吞掉"。重构为**两遍绘制**:pass1 只画所有弧并收集 emphasized 段光点(x/y/color),pass2 统一在所有弧之上画光点。同时光点加大加亮:dotR 从 `0.85~1.2×弧半宽``1.15~1.4×`alpha `0.55~1.0``0.7~1.0`,白芯 0.85→0.9,读起来像"珠珠骑在弧上"。
- 光点仍居段中点(沿用上轮两端对称修复);外缘 209dpr 不超 canvas 半宽 210,与 breath-aura(内缘 ~232)无重叠。
- 验证:node --check 通过;数值全部核算。未提交 git。
- **间距仍不够(用户反馈"还是太近")→ 再翻倍**canvas 半宽 210 是硬上限,剩余空间仅 ~5dpr,要明显拉开只能继续内缩进度弧。glowPad 14→18、arcR 外移量 9→14,间距 **5→10dpr**(进度弧外缘 196→192,分段弧内缘 201→202,光点外缘 211.6 超画布 0.8px 亚像素无感)。进度环直径累计缩 ~6%(408→384)。约束备忘:**间距 = arcR外移量 arcW/2,且 ≤ glowPad arcW margin**;想再拉开只能继续缩环或减弧宽。
+36
View File
@@ -0,0 +1,36 @@
# 2026-08-14 工作日志
## 修复:设置页切换暗黑模式后其他 tab 的 tabBar 不跟随变暗
- **现象**:设置页选暗黑模式后,只有设置 tab 的底栏变暗,切到训练/记录/排行 tab 底栏仍是白色,完全退出重进才正常。
- **根因**:自定义 tabBar 组件的 `pageLifetimes.show()` 对自定义 tabBar **不触发**(微信框架特殊行为——自定义 tabBar 由框架管理,不走普通组件的页面生命周期)。各 tab 页 `onShow` 里只做了 `tb.setData({selected:X})` 设高亮,没有同步主题;`onSelectDarkMode``getCurrentPages()` 只返回当前 tab 页(其他 tab 不在页面栈里),所以 `tb.updateTheme()` 只刷新了设置页的 tabBar 实例。
- **修复**4 个 tab 页 `onShow` 里获取 tabBar 后,在 `setData({selected:X})` 之后加一行 `tb.updateTheme()``updateTheme()` 内部调 `applyThemeToPage(this)`(有"样式未变则跳过"的优化),不会产生多余渲染。涉及 `pages/index/index.js``pages/records/records.js``pages/leaderboard/leaderboard.js``pages/settings/settings.js`
- **教训**:自定义 tabBar 不能依赖 `pageLifetimes` 做状态同步,必须在各 tab 页 `onShow` 里显式调 `this.getTabBar()` 更新。
## 修复:循环训练偶数组数休息语音被「一半」提示吞掉
- **现象**:循环训练设 2 组时,第 1 组练完进入休息,本该播 `restStart`「休息一下」,实际播的是 `halfway`「已完成一半啦」,休息语音不响。
- **根因**:偶数 set 数时 `sets×hold/2` 正好落在某组练完→休息切换的那一 tick。`_circuitCue` 先播 halfway 并设 `_voiceBusyUntil=now+2600` 占麦;紧接着 `_advance()``_circuitPhase('resting')` 检查麦占用,`restStart` 被静音。原代码注释有意让"进度提示优先于阶段提示",但偶数组数下两者撞车。
- **修复**`_circuitCue` 里先算 `atRestBoundary`(phaseRemaining<=0 && rest>0 && setIndex<sets) 与 `restWillSpeak`(hold>=10 && rest>=5)halfway/last30/last10 在"落在休息边界且 restStart 会播"时跳过自身,让休息语音优先。奇数组数一半点在中段不会撞车,不动;短组配置 rest 不播也保持原 halfway 行为,无回归。改 `pages/timer/timer.js``_circuitCue`
- 顺带:last30/last10 在更长偶数组数(如 4 组×30s 的 set3→rest)也会撞同一问题,一并按同规则让位,行为一致。
## 新增:训练完成分享海报(canvas 绘制 + 小程序码)
- **现象**:训练完成后点分享,微信默认截取当前页面,因完成弹窗垂直居中,卡片只截到上半部分(绿色星星+"完成!"),大数字和按钮被切掉。
- **方案**:用隐藏 canvas 2d 绘制一张 5:4(500×400 逻辑像素)的专用分享海报,在 `onShareAppMessage` 里作为 `imageUrl` 返回;提前在 `_saveAndShowCompletion` 弹窗数据就绪后生成,避免分享时现场绘制延迟。
- **海报内容(初版)**:浅灰底 + 顶部品牌 + 白色圆角成绩卡 + 绿色渐变星星图标 + 状态文案 + 大数字时长 + 连续打卡(如有)+ 底部"扫码一起打卡"引导 + 右下角小程序二维码。
- **文件改动**
- `pages/timer/timer.wxml`:添加隐藏 `<canvas type="2d" id="sharePoster">`
- `pages/timer/timer.wxss``.share-poster-canvas` 移出视口、不响应点击
- `pages/timer/timer.js`:新增 `_drawSharePoster()``_formatShareDuration()``_saveAndShowCompletion()` 里 setData 后预生成;`onShareAppMessage()` 返回 `imageUrl: this._shareImageUrl``onTrainAgain()` 清空旧图防止复用
- `static/share-qr.jpg`:加入用户提供的小程序二维码资源
- **注意**:导出用 jpg quality 0.9、dest 500×400,控制文件在 128KB 以内;二维码路径 `/static/share-qr.jpg` 为相对小程序根目录;若二维码加载失败仍导出无二维码的海报,保证分享入口不灰。
## 调整:分享海报(实测反馈)
- **反馈**:① 大数字(分钟数)压住了上方"完成"状态文案;② 顶部小程序名称多余;③ 要删二维码。
- **根因**:初版状态文案 `y=172`(字号22)、大数字基线 `y=230` 且字号最大 90px,数字顶部≈165 落入状态文案区域(150~172),多位数分钟(如纯分钟 "6"→90px)直接压字。
- **修复**(仅改 `_drawSharePoster` 布局):删除顶部"平板的支撑"品牌名;删除右下角二维码及依赖它的"扫码一起打卡"引导(连带 `static/share-qr.jpg` 也删,不再引用);重排垂直节奏——卡片放大(42~358)、图标 `circleY=104`、状态文案 `y=184`(24px)、大数字基线 `y=286`(上限84px,顶部≈226,与状态文案下沿 192 间距 34 安全)、连续打卡 `y=332`;导出改为绘制完直接 `canvasToTempFilePath`,无需等图片加载。
- **结论**:海报现为"成绩卡 + 图标 + 状态 + 大数字 + 连续打卡"的极简版,无品牌名无二维码。
## 新增:新版本更新通知(What's New)弹窗
- **需求**:新版本发布后用户首次进入弹一个"更新了什么"通知弹窗(纯通知,不是微信"点击更新→重启"流程)。用户澄清要的是 What's New,不是 applyUpdate 重启。
- **实现**:复用 `ui-modal`(position:center) 居中弹窗 + `ui-btn`,不新建组件;`config.js``releaseNotes` 数组;首页 `index.js``_checkUpdateNote()``onShow` 检测 `storage.lastUpdateNoteVersion` vs `config.version`,不一致则显示并写回 storage 防重复;顶部绿色渐变徽章 + `sparkleWhite` 图标 + 版本标题 + 要点列表 + "知道了"按钮。
- **文件**`config.js`(releaseNotes)、`pages/index/index.{js,wxml,wxss}``index.json` 无需改(ui-modal/ui-btn 已注册)。
- **发版约定**:每次发版改 `config.version` 并同步更新 `releaseNotes` 即可。前端改动不发云函数。
+3
View File
@@ -12,6 +12,7 @@
- **隐私协议在 MP 后台配置,不在 app.json**`chooseAvatar` 属隐私接口,需在小程序管理后台「设置→服务内容声明→用户隐私保护指引」声明「用户信息(头像/昵称)」。代码里(app.json)看不到隐私配置是正常的;判断隐私合规**先确认后台指引状态**,别只看代码就下"审核风险"结论。已上架运行 = 后台已配好。`__usePrivacyCheck__:true` 仅本地调试用,正式版非必须。 - **隐私协议在 MP 后台配置,不在 app.json**`chooseAvatar` 属隐私接口,需在小程序管理后台「设置→服务内容声明→用户隐私保护指引」声明「用户信息(头像/昵称)」。代码里(app.json)看不到隐私配置是正常的;判断隐私合规**先确认后台指引状态**,别只看代码就下"审核风险"结论。已上架运行 = 后台已配好。`__usePrivacyCheck__:true` 仅本地调试用,正式版非必须。
- **App.onLaunch 的 async/await 不阻塞首屏**:微信框架是「非阻塞生命周期调度」——onLaunch 被调用但页面 onLoad/onShow 并行推进,不等 onLaunch 内 Promise resolve。所以 app.js 里 `await cloud.pullAll()` 不会卡首屏,只是 await 之后的同步逻辑延迟执行。判断启动性能**别把 onLaunch 的 await 当阻塞**。 - **App.onLaunch 的 async/await 不阻塞首屏**:微信框架是「非阻塞生命周期调度」——onLaunch 被调用但页面 onLoad/onShow 并行推进,不等 onLaunch 内 Promise resolve。所以 app.js 里 `await cloud.pullAll()` 不会卡首屏,只是 await 之后的同步逻辑延迟执行。判断启动性能**别把 onLaunch 的 await 当阻塞**。
- **代码审查避免过度判断**:给本项目做问题分析时,勿把"合理设计选择/优化建议"拔高成"问题/风险"。已被纠错的判断:①隐私协议缺失(实为后台配置,代码看不到)②onLaunch await 阻塞启动(机制理解错)③组件默认 isolated 不一致(实为合理分层)。下结论前先核实机制事实,区分"真 bug / 可优化 / 合理设计"。 - **代码审查避免过度判断**:给本项目做问题分析时,勿把"合理设计选择/优化建议"拔高成"问题/风险"。已被纠错的判断:①隐私协议缺失(实为后台配置,代码看不到)②onLaunch await 阻塞启动(机制理解错)③组件默认 isolated 不一致(实为合理分层)。下结论前先核实机制事实,区分"真 bug / 可优化 / 合理设计"。
- **自定义 tabBar 的 `pageLifetimes` 不触发**:微信框架的自定义 tabBar 由框架管理,不走普通自定义组件的页面生命周期钩子(`pageLifetimes.show/hide` 不触发)。因此主题/暗黑模式等状态同步**不能依赖 `pageLifetimes.show`**,必须在各 tab 页 `onShow` 里显式调 `this.getTabBar()` 更新(如 `tb.updateTheme()`)。`getCurrentPages()` 也只返回当前 tab 页(其他 tab 不在页面栈),跨 tab 通知无效。
## 排行榜架构(2026-07-28+,云函数 `cloudfunctions/leaderboard` ## 排行榜架构(2026-07-28+,云函数 `cloudfunctions/leaderboard`
- **预计算快照**:定时触发器(每 4min)`_rebuildSnapshots()``plank_data` 算 day/month/year/endurance 四榜写 `leaderboard_snapshot`;客户端 99% 走 `_serveFromSnapshot()`(≈50ms),仅训练后 `force=true` 那次实时重算+回写。**改后须重新部署云函数**(定时器随部署注册)。 - **预计算快照**:定时触发器(每 4min)`_rebuildSnapshots()``plank_data` 算 day/month/year/endurance 四榜写 `leaderboard_snapshot`;客户端 99% 走 `_serveFromSnapshot()`(≈50ms),仅训练后 `force=true` 那次实时重算+回写。**改后须重新部署云函数**(定时器随部署注册)。
@@ -27,5 +28,7 @@
## 约定 ## 约定
- 页面叫法:index=训练首页,timer=训练页,leaderboard=排行榜,records=记录,settings=设置。 - 页面叫法:index=训练首页,timer=训练页,leaderboard=排行榜,records=记录,settings=设置。
- 美化按"见效快优先"分批,每轮 `node --check` + grep 残留再交付。 - 美化按"见效快优先"分批,每轮 `node --check` + grep 残留再交付。
- **发版更新通知(What's New)**`config.js` 维护 `version`+`releaseNotes`(数组,每条一行短句);首页 `index.js``_checkUpdateNote()``onShow` 检测 `wx.getStorageSync('lastUpdateNoteVersion')``config.version` 不一致→弹 `ui-modal`(position:center) 居中通知,并写回 storage 防重复。纯通知不重启。发版时**改 version 同步更新 releaseNotes** 即可,前端改动无需动云函数。
- **git push 须 `dangerouslyDisableSandbox:true`**(沙箱隔离致 github SSL 超时);无 SSH,仅 HTTPS remote。 - **git push 须 `dangerouslyDisableSandbox:true`**(沙箱隔离致 github SSL 超时);无 SSH,仅 HTTPS remote。
- **GitHub 链路不稳**:国内环境到 `github.com:443` 常 SSL 重置/HTTP2 framing 失败(非代码问题),`git.soao.net`(soao) 国内远程稳定。策略:soao 为主远程、实时同步;`origin`(GitHub) 失败则延后推或本地代理(`127.0.0.1:7890` 等)临时 `git -C <repo> config http.proxy` 推完 `config --unset` 撤销。代码已 commit 则零丢失风险。 - **GitHub 链路不稳**:国内环境到 `github.com:443` 常 SSL 重置/HTTP2 framing 失败(非代码问题),`git.soao.net`(soao) 国内远程稳定。策略:soao 为主远程、实时同步;`origin`(GitHub) 失败则延后推或本地代理(`127.0.0.1:7890` 等)临时 `git -C <repo> config http.proxy` 推完 `config --unset` 撤销。代码已 commit 则零丢失风险。
- **GitHub 推送有效解法(2026-08-12 实测)**:报 `LibreSSL SSL_connect: SSL_ERROR_SYSCALL` 时,若 `curl 直连 https://github.com` 能 200(慢 30s 但通),说明是 git 默认 HTTP/2 framing 问题(curl 默认 HTTP/1.1 所以通)→ 执行 `git -C <repo> config http.version HTTP/1.1` 后再 push 即可成功,推完 `config --unset http.version` 撤销。7890 端口进程可能空转(curl -x 走它返回 000),别依赖该代理。
+41 -23
View File
@@ -105,8 +105,11 @@ Component({
const cx = w / 2 const cx = w / 2
const cy = h / 2 const cy = h / 2
// reserve room for the glowing end dot so its shadow isn't clipped // Reserve room for the glowing end dot's shadow AND the outer set-progress
const glowPad = 6 * dpr // band (circuit mode). _drawSetArcs derives its band radius from this same
// pad, so increasing it here also widens the gap between the countdown
// arc and the set band. 18dpr gives a ~10dpr visible gap (size=420).
const glowPad = 18 * dpr
const radius = (size - ringWidth) / 2 * dpr - glowPad const radius = (size - ringWidth) / 2 * dpr - glowPad
const lw = ringWidth * dpr const lw = ringWidth * dpr
@@ -252,8 +255,9 @@ Component({
const cy = (size / 2) * dpr const cy = (size / 2) * dpr
// Same radius math as _draw(): the countdown arc sits at `radius`; the // Same radius math as _draw(): the countdown arc sits at `radius`; the
// set band wraps just outside it so the two read as concentric layers. // set band wraps just outside it so the two read as concentric layers.
const radius = ((size - ringWidth) / 2) * dpr - 6 * dpr // NOTE: the -18*dpr pad MUST stay in sync with glowPad in _draw().
const arcR = radius + (ringWidth / 2) * dpr + 3 * dpr const radius = ((size - ringWidth) / 2) * dpr - 18 * dpr
const arcR = radius + (ringWidth / 2) * dpr + 14 * dpr
const arcW = 8 * dpr const arcW = 8 * dpr
const band = arcW / 2 + 3 * dpr const band = arcW / 2 + 3 * dpr
@@ -274,6 +278,10 @@ Component({
const gap = (2.5 * Math.PI) / 180 const gap = (2.5 * Math.PI) / 180
const startAngle = -Math.PI / 2 const startAngle = -Math.PI / 2
// Pass 1: draw every segment arc. Breathing dots are collected and painted
// in pass 2, AFTER all arcs, so a dot always floats ON TOP of its segment
// instead of being partially buried under neighbouring round caps.
const dots = []
for (let i = 1; i <= sets; i++) { for (let i = 1; i <= sets; i++) {
const a0 = startAngle + (i - 1) * segAngle + gap / 2 const a0 = startAngle + (i - 1) * segAngle + gap / 2
const a1 = startAngle + i * segAngle - gap / 2 const a1 = startAngle + i * segAngle - gap / 2
@@ -294,28 +302,38 @@ Component({
ctx.stroke() ctx.stroke()
ctx.globalAlpha = 1 ctx.globalAlpha = 1
// Glowing endpoint dot on the emphasized segment.
if (emphasized) { if (emphasized) {
const dx = cx + arcR * Math.cos(a1) const am = (a0 + a1) / 2
const dy = cy + arcR * Math.sin(a1) dots.push({
const dotR = (arcW / 2) * (paused ? 1 : 0.85 + 0.35 * p) x: cx + arcR * Math.cos(am),
const glowColor = i === active ? primaryColor : successColor y: cy + arcR * Math.sin(am),
ctx.save() color: i === active ? primaryColor : successColor
ctx.shadowColor = glowColor })
ctx.shadowBlur = 8 * dpr
ctx.globalAlpha = paused ? 0.9 : 0.55 + 0.45 * p
ctx.beginPath()
ctx.arc(dx, dy, dotR, 0, Math.PI * 2)
ctx.fillStyle = glowColor
ctx.fill()
ctx.restore()
// white core for a "light bulb" feel
ctx.beginPath()
ctx.arc(dx, dy, dotR * 0.45, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255,255,255,0.85)'
ctx.fill()
} }
} }
// Pass 2: glowing dot on each emphasized segment, drawn over every arc.
// Placed at the segment MIDDLE (not the a1 end): a dot pinned to the
// trailing end made that end look visibly thicker than the round-cap
// start, so both ends of the arc stay symmetric. Slightly larger than
// the band width so it reads as a bead riding on top of the arc.
for (const dot of dots) {
const dotR = (arcW / 2) * (paused ? 1.3 : 1.15 + 0.25 * p)
ctx.save()
ctx.shadowColor = dot.color
ctx.shadowBlur = 8 * dpr
ctx.globalAlpha = paused ? 0.95 : 0.7 + 0.3 * p
ctx.beginPath()
ctx.arc(dot.x, dot.y, dotR, 0, Math.PI * 2)
ctx.fillStyle = dot.color
ctx.fill()
ctx.restore()
// white core for a "light bulb" feel
ctx.beginPath()
ctx.arc(dot.x, dot.y, dotR * 0.45, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.fill()
}
}, },
_startSetPulse() { _startSetPulse() {
+25 -3
View File
@@ -4,10 +4,23 @@
*/ */
module.exports = { module.exports = {
/** 应用版本号,外显在设置页「关于」 */ /** 应用版本号,外显在设置页「关于」 */
version: 'v3.2', version: 'v3.3',
/** 最后更新日期,外显在设置页「关于」 */ /** 最后更新日期,外显在设置页「关于」 */
updatedAt: '2026-08-12', updatedAt: '2026-08-14',
/**
* 新版本更新通知(What's New)要点。首页冷启动检测 config.version 与本地
* 记录版本不一致时,弹一次居中通知弹窗展示这些要点。每次发版改 version
* 的同时更新本数组即可(措辞尽量短,每条一行)。
*/
releaseNotes: [
'循环训练分段弧优化:光点居中、与进度弧间距拉大,看得更清楚',
'暗黑模式切换后,各页面底部栏同步变暗',
'循环训练偶数组数:休息语音不再被「完成一半」提示覆盖',
'训练完成新增分享海报,成绩展示更完整',
'训练时长不足 20 秒不计入记录'
],
/** 开发者名称 / 微信号,设置页点击可复制 */ /** 开发者名称 / 微信号,设置页点击可复制 */
developer: '三口一瓶', developer: '三口一瓶',
@@ -25,7 +38,7 @@ module.exports = {
* 耐力榜领奖台顶部文案。哲理注脚,点出"撑得越久不一定越好"的价值观, * 耐力榜领奖台顶部文案。哲理注脚,点出"撑得越久不一定越好"的价值观,
* 与耐力榜(比谁单次撑最久)形成反差。仅耐力榜领奖台顶部显示,改这里即可。 * 与耐力榜(比谁单次撑最久)形成反差。仅耐力榜领奖台顶部显示,改这里即可。
*/ */
leaderboardPodiumSlogan: '平板支撑不是一场和时间的恋爱,撑得越久,不一定越好。真正重要的,是每一秒都没有辜负身体。', leaderboardPodiumSlogan: '平板支撑不是计时器比赛,是肌肉的精细控制。宁做30秒的钢板,不做5分钟的烂泥。',
/** /**
* 语音合成(TTS)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。 * 语音合成(TTS)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。
@@ -67,6 +80,15 @@ module.exports = {
*/ */
caloriesPerSecond: 0.068, caloriesPerSecond: 0.068,
/**
* 最短记录时长(秒)。本次训练有效时长低于该值时:
* - 不写入训练记录、不更新连续打卡天数(避免"撑几秒也算一天"的无效记录)
* - 完成弹窗会明确提示"本次未计入记录",并隐藏查看记录/分享入口
* 循环训练按"有效撑持秒数"(各组时长之和,不含休息)判断。
* 改这里即可调整门槛,无需动逻辑代码。
*/
minRecordSeconds: 15,
/** /**
* 完成弹窗科学提示(按本次训练时长分段)。 * 完成弹窗科学提示(按本次训练时长分段)。
* 完成时按实际撑的秒数命中第一段 maxSeconds 大于它的配置, * 完成时按实际撑的秒数命中第一段 maxSeconds 大于它的配置,
+33 -1
View File
@@ -34,6 +34,12 @@ Page({
presets: [30, 60, 90, 120, 180, 300], presets: [30, 60, 90, 120, 180, 300],
customDuration: 60, customDuration: 60,
customInput: '', customInput: '',
// --- 新版本更新通知(What's New) ---
showUpdateNote: false,
updateNoteVersion: '',
updateNoteItems: [],
updateNoteDate: '',
// --- Circuit (循环训练) config panel (persisted via storage.saveCircuitConfig) --- // --- Circuit (循环训练) config panel (persisted via storage.saveCircuitConfig) ---
showCircuitPicker: false, showCircuitPicker: false,
cHold: 30, cHold: 30,
@@ -50,9 +56,13 @@ Page({
onShow() { onShow() {
themeMod.applyThemeToPage(this) themeMod.applyThemeToPage(this)
this._checkUpdateNote()
try { try {
const tb = this.getTabBar() const tb = this.getTabBar()
if (tb) tb.setData({ selected: 0 }) if (tb) {
tb.setData({ selected: 0 })
tb.updateTheme()
}
} catch (e) {} } catch (e) {}
// Skip the first onShow to avoid double-refresh; subsequent tab switches still refresh. // Skip the first onShow to avoid double-refresh; subsequent tab switches still refresh.
if (this._firstShow) { if (this._firstShow) {
@@ -89,6 +99,28 @@ Page({
} catch (e) {} } catch (e) {}
}, },
/**
* 新版本首次启动的 What's New 通知。复用 config.version 作为版本号,
* 用 storage 记录已提示过的版本,避免每次冷启动重复弹出。纯通知,不重启应用。
*/
_checkUpdateNote() {
try {
const shown = wx.getStorageSync('lastUpdateNoteVersion')
if (shown === config.version) return
this.setData({
showUpdateNote: true,
updateNoteVersion: config.version,
updateNoteItems: config.releaseNotes || [],
updateNoteDate: config.updatedAt || ''
})
wx.setStorageSync('lastUpdateNoteVersion', config.version)
} catch (e) {}
},
onCloseUpdateNote() {
this.setData({ showUpdateNote: false })
},
onPullDownRefresh() { onPullDownRefresh() {
this.refresh() this.refresh()
wx.stopPullDownRefresh() wx.stopPullDownRefresh()
+18
View File
@@ -179,4 +179,22 @@
></ui-btn> ></ui-btn>
<text class="picker-cancel" bindtap="onCloseCircuitPicker">取消</text> <text class="picker-cancel" bindtap="onCloseCircuitPicker">取消</text>
</ui-modal> </ui-modal>
<!-- 新版本更新通知(What's New),首次进入当期版本弹一次 -->
<ui-modal visible="{{showUpdateNote}}" position="center" bind:close="onCloseUpdateNote">
<view class="update-note">
<view class="update-note__badge">
<image class="update-note__icon" src="{{icons.sparkleWhite}}" mode="aspectFit"></image>
</view>
<text class="update-note__title">版本更新 {{updateNoteVersion}}</text>
<text class="update-note__date" wx:if="{{updateNoteDate}}">更新于 {{updateNoteDate}}</text>
<view class="update-note__list">
<view class="update-note__item" wx:for="{{updateNoteItems}}" wx:key="*this">
<text class="update-note__dot">·</text>
<text class="update-note__text">{{item}}</text>
</view>
</view>
<ui-btn variant="primary" size="lg" block text="知道了" bindtap="onCloseUpdateNote" custom-style="border-radius:16rpx;margin-top:28rpx;"></ui-btn>
</view>
</ui-modal>
</view> </view>
+69
View File
@@ -446,3 +446,72 @@
0%, 100% { transform: scale(1); } 0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); } 50% { transform: scale(1.02); }
} }
/* ===== 新版本更新通知(What's New) ===== */
/* slot 内容由页面 wxss 控制;ui-modal--center 的 body 已居中且用 --card-bg,
这里只管内部排版。徽章用负 margin 上探出卡片顶,制造浮动感。 */
.update-note {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.update-note__badge {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
margin: -76rpx auto 18rpx;
background: linear-gradient(135deg, #4CAF50, #81C784);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(76, 175, 80, 0.35);
}
.update-note__icon {
width: 52rpx;
height: 52rpx;
}
.update-note__title {
font-size: 36rpx;
font-weight: 700;
color: var(--text);
line-height: 1.4;
}
.update-note__date {
font-size: 24rpx;
color: var(--text-secondary);
margin-top: 6rpx;
}
.update-note__list {
width: 100%;
margin: 26rpx 0 4rpx;
text-align: left;
box-sizing: border-box;
}
.update-note__item {
display: flex;
align-items: flex-start;
margin-bottom: 16rpx;
}
.update-note__dot {
color: var(--primary);
font-weight: 700;
margin-right: 12rpx;
line-height: 1.5;
flex-shrink: 0;
}
.update-note__text {
flex: 1;
font-size: 27rpx;
color: var(--text);
line-height: 1.5;
text-align: left;
}
+4 -1
View File
@@ -36,7 +36,10 @@ Page({
this.setData({ theme, icons: iconsMod.build(theme) }) this.setData({ theme, icons: iconsMod.build(theme) })
try { try {
const tb = this.getTabBar() const tb = this.getTabBar()
if (tb) tb.setData({ selected: 2 }) if (tb) {
tb.setData({ selected: 2 })
tb.updateTheme()
}
} catch (e) {} } catch (e) {}
// 训练完设的强刷标志:下次进排行榜跳过云函数缓存,立刻拿到含新记录的数据 // 训练完设的强刷标志:下次进排行榜跳过云函数缓存,立刻拿到含新记录的数据
let force = false let force = false
+4 -1
View File
@@ -50,7 +50,10 @@ Page({
themeMod.applyThemeToPage(this) themeMod.applyThemeToPage(this)
try { try {
const tb = this.getTabBar() const tb = this.getTabBar()
if (tb) tb.setData({ selected: 1 }) if (tb) {
tb.setData({ selected: 1 })
tb.updateTheme()
}
} catch (e) {} } catch (e) {}
if (this._firstShow) { if (this._firstShow) {
this._firstShow = false this._firstShow = false
+4 -1
View File
@@ -114,7 +114,10 @@ Page({
onShow() { onShow() {
try { try {
const tb = this.getTabBar() const tb = this.getTabBar()
if (tb) tb.setData({ selected: 3 }) if (tb) {
tb.setData({ selected: 3 })
tb.updateTheme()
}
} catch (e) {} } catch (e) {}
themeMod.applyThemeToPage(this) themeMod.applyThemeToPage(this)
const theme = themeMod.getCurrentTheme() const theme = themeMod.getCurrentTheme()
+241 -6
View File
@@ -40,6 +40,10 @@ Page({
// 组进度改由 progress-ring 外圈分段弧呈现,仅需传 sets/currentSet/resting, // 组进度改由 progress-ring 外圈分段弧呈现,仅需传 sets/currentSet/resting,
// 页面不再维护点阵渲染数据(circuitDots/dotsDone 等已随旧面板一并删除)。 // 页面不再维护点阵渲染数据(circuitDots/dotsDone 等已随旧面板一并删除)。
celebrationLevel: 'normal', // normal / first / milestone / record celebrationLevel: 'normal', // normal / first / milestone / record
// 训练时长低于 config.minRecordSeconds 时置 true:完成弹窗提示"未计入记录",
// 隐藏查看记录/分享入口;弹窗内展示门槛值给用户明确反馈
completionNotRecorded: false,
minRecordSeconds: 20,
celebrationMessage: '', celebrationMessage: '',
confettiPieces: [], confettiPieces: [],
// Completion modal (full-screen summary after training ends) // Completion modal (full-screen summary after training ends)
@@ -350,21 +354,31 @@ Page({
const s = storage.getSettings() const s = storage.getSettings()
const voiceOn = s.voiceGuide !== false const voiceOn = s.voiceGuide !== false
// Even set counts land a progress threshold (halfway / last30 / last10)
// exactly on a work→rest transition. If that prompt speaks it grabs the
// mic for ~2.6s (see _playProgressVoice) and mutes the "休息一下" line
// fired on the same tick. Yield to rest whenever restStart will actually
// play (chatty + rest≥5); short-set configs keep the progress cue as
// before. Odd set counts never hit this because the halfway point sits
// mid-set, so their progress prompts are unaffected.
const atRestBoundary = t.phaseRemaining <= 0 && t.restPerSet > 0 && t.setIndex < t.sets
const restWillSpeak = this._circuitCfg.holdPerSet >= 10 && this._circuitCfg.restPerSet >= 5
// Same duration gates as _remind(), so short circuits (e.g. 3 sets × 5s) // Same duration gates as _remind(), so short circuits (e.g. 3 sets × 5s)
// don't fire "最后30秒" on the very first tick. `<=` rather than `===` // don't fire "最后30秒" on the very first tick. `<=` rather than `===`
// survives a skipped second when the app returns from background. // survives a skipped second when the app returns from background.
if (total >= 10 && !this._halfwaySignaled && done >= Math.floor(total / 2)) { if (total >= 10 && !this._halfwaySignaled && done >= Math.floor(total / 2)) {
this._halfwaySignaled = true this._halfwaySignaled = true
if (voiceOn) this._playProgressVoice('halfway') if (!(atRestBoundary && restWillSpeak) && voiceOn) this._playProgressVoice('halfway')
this._vibrateOnce(2, 150) this._vibrateOnce(2, 150)
} }
if (total > 60 && left <= 30 && !this._last30Signaled) { if (total > 60 && left <= 30 && !this._last30Signaled) {
this._last30Signaled = true this._last30Signaled = true
if (voiceOn) this._playProgressVoice('last30') if (!(atRestBoundary && restWillSpeak) && voiceOn) this._playProgressVoice('last30')
} }
if (total > 20 && left <= 10 && !this._last10Signaled) { if (total > 20 && left <= 10 && !this._last10Signaled) {
this._last10Signaled = true this._last10Signaled = true
if (voiceOn) this._playProgressVoice('last10') if (!(atRestBoundary && restWillSpeak) && voiceOn) this._playProgressVoice('last10')
} }
// Countdown buzz over the last 5s of the FINAL set (the real finish line). // Countdown buzz over the last 5s of the FINAL set (the real finish line).
@@ -435,7 +449,9 @@ Page({
_onCircuitComplete() { _onCircuitComplete() {
this._clearVibrateTimers() this._clearVibrateTimers()
const s = storage.getSettings() const s = storage.getSettings()
if (s.voiceGuide !== false) voice.play('complete') // 有效撑持秒数未达最低记录时长时不播完成语音(弹窗走"未计入记录"分支)
const minSec = config.minRecordSeconds || 20
if (this.data.workTotal >= minSec && s.voiceGuide !== false) voice.play('complete')
this._saveAndShowCompletion(this.data.workTotal) this._saveAndShowCompletion(this.data.workTotal)
}, },
@@ -725,8 +741,10 @@ Page({
return return
} }
// Completion voice (replaces the old auto-onComplete cue). No vibration. // Completion voice (replaces the old auto-onComplete cue). No vibration.
// 未达最低记录时长时不播完成语音——弹窗会走"未计入记录"分支,不庆祝。
const s = storage.getSettings() const s = storage.getSettings()
if (s.voiceGuide !== false) { const minSec = config.minRecordSeconds || 20
if (elapsed >= minSec && s.voiceGuide !== false) {
voice.play('complete') voice.play('complete')
} }
this._saveAndShowCompletion(elapsed) this._saveAndShowCompletion(elapsed)
@@ -759,6 +777,15 @@ Page({
// Re-entry guard: protects the finishTraining path so the save never // Re-entry guard: protects the finishTraining path so the save never
// runs twice even if the user double-taps end. // runs twice even if the user double-taps end.
if (this._saving) return if (this._saving) return
// 未达 config.minRecordSeconds:不写入记录/连胜,弹"未计入记录"提示。
// 兜底判断——手动结束(finishTraining)与循环自然完成(_onCircuitComplete)
// 两条路径最终都汇聚到这里,保证任何入口都不会把短时训练落库。
const minSec = config.minRecordSeconds || 20
if (elapsed < minSec) {
this._showNotRecorded(elapsed, minSec)
return
}
this._saving = true this._saving = true
const { level, message } = this._detectCelebrationLevel(elapsed) const { level, message } = this._detectCelebrationLevel(elapsed)
@@ -797,6 +824,9 @@ Page({
// Background effects keep playing behind the modal // Background effects keep playing behind the modal
status: 'completed', status: 'completed',
isCompleted: true, isCompleted: true,
// 重置未记录标记(上一轮若走了 _showNotRecorded,这里必须回 false,
// 否则下次正常完成的弹窗会误显示"未计入记录"且隐藏分享/记录按钮)
completionNotRecorded: false,
celebrationLevel: level, celebrationLevel: level,
celebrationMessage: message, celebrationMessage: message,
confettiPieces, confettiPieces,
@@ -816,6 +846,9 @@ Page({
completionTipRisk: scienceTip.risk completionTipRisk: scienceTip.risk
}) })
// 预生成分享海报,点"分享"时 onShareAppMessage 直接复用,避免现场绘制延迟
this._drawSharePoster()
// Animate the big number from 0 to elapsed // Animate the big number from 0 to elapsed
if (this._completionCountUp) this._completionCountUp.cancel() if (this._completionCountUp) this._completionCountUp.cancel()
this._completionCountUp = countUp({ this._completionCountUp = countUp({
@@ -827,6 +860,46 @@ Page({
}) })
}, },
/**
* 训练时长低于 config.minRecordSeconds:不保存记录、不更新连胜,
* 复用完成弹窗(避免系统原生 modal 在 WebView stacking context 下
* hit-test 不稳),弹窗内明确提示"本次未计入记录"。
* 仍显示本次实际撑持秒数(大数字 countUp),但不放 confetti/连胜/科学提示,
* 并隐藏"查看记录/分享"入口(没有记录可看)。
*/
_showNotRecorded(elapsed, minSec) {
if (this._saving) return
this._saving = true
this.setData({
status: 'completed',
isCompleted: true,
completionNotRecorded: true,
minRecordSeconds: minSec,
showCompletion: true,
completionElapsed: 0,
completionActualElapsed: elapsed,
completionTarget: this.data.duration,
completionOvertime: 0,
completionStreak: 0,
completionLevel: 'normal',
completionMessage: '未达最低记录时长',
completionTipValue: '',
completionTipRisk: '',
confettiPieces: []
})
// Animate the big number from 0 to elapsed (same as the normal modal)
if (this._completionCountUp) this._completionCountUp.cancel()
this._completionCountUp = countUp({
from: 0,
to: elapsed,
duration: 1200,
onUpdate: (v) => this.setData({ completionElapsed: v }),
onComplete: () => { this._completionCountUp = null }
})
},
onCloseCompletion() { onCloseCompletion() {
if (this._completionCountUp) { if (this._completionCountUp) {
this._completionCountUp.cancel() this._completionCountUp.cancel()
@@ -858,6 +931,8 @@ Page({
this._completionCountUp.cancel() this._completionCountUp.cancel()
this._completionCountUp = null this._completionCountUp = null
} }
// 清空上一轮分享图,避免"再来一次"后立即分享时复用旧图
this._shareImageUrl = ''
// Circuit mode: rebuild the FSM (start() only resets from idle, and a // Circuit mode: rebuild the FSM (start() only resets from idle, and a
// finished/paused timer is no longer idle, so a fresh init is the clean // finished/paused timer is no longer idle, so a fresh init is the clean
// reset; _initCircuit also closes the completion modal). // reset; _initCircuit also closes the completion modal).
@@ -882,6 +957,8 @@ Page({
status: 'idle', status: 'idle',
isRunning: false, isRunning: false,
isPaused: false, isPaused: false,
// 复位未记录标记:若上一轮走了 _showNotRecorded,重练前必须回 false
completionNotRecorded: false,
remaining: this.data.duration, remaining: this.data.duration,
elapsed: 0, elapsed: 0,
goalReached: false, goalReached: false,
@@ -897,6 +974,163 @@ Page({
// taps on the completion modal). // taps on the completion modal).
onNoop() { /* swallow */ }, onNoop() { /* swallow */ },
/**
* 绘制训练完成分享海报并导出为临时图片。
* 在 _saveAndShowCompletion 里弹窗数据就绪后调用,提前生成,
* 用户点"分享"时 onShareAppMessage 直接取 _shareImageUrl。
*/
_drawSharePoster() {
const query = wx.createSelectorQuery().in(this)
query.select('#sharePoster')
.fields({ node: true, size: true })
.exec((res) => {
if (!res || !res[0] || !res[0].node) return
const canvas = res[0].node
const ctx = canvas.getContext('2d')
const dpr = wx.getSystemInfoSync().pixelRatio || 1
// 5:4 比例,控制导出文件<128KB;画布逻辑像素 500x400,实际按 dpr 放大
const W = 500
const H = 400
canvas.width = W * dpr
canvas.height = H * dpr
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, W, H)
const theme = this.data.theme || themeMod.getCurrentTheme()
const primary = theme.primary || '#FF6B35'
// 与完成弹窗视觉一致的成绩数据
const elapsed = this.data.completionActualElapsed || this.data.completionElapsed || 0
const streak = this.data.completionStreak || 0
const message = this.data.completionMessage || '完成!'
// helpers
const roundRect = (x, y, w, h, r) => {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
ctx.lineTo(x + w, y + h - rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
ctx.lineTo(x + rr, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
const drawStar = (cx, cy, outerR, innerR, points) => {
ctx.beginPath()
for (let i = 0; i < points * 2; i++) {
const r = i % 2 === 0 ? outerR : innerR
const a = (Math.PI / points) * i - Math.PI / 2
const x = cx + Math.cos(a) * r
const y = cy + Math.sin(a) * r
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.closePath()
}
// --- background ---
ctx.fillStyle = '#F7F8FA'
ctx.fillRect(0, 0, W, H)
// --- main card shadow ---
ctx.save()
ctx.fillStyle = 'rgba(0,0,0,0.06)'
roundRect(38, 44, 424, 320, 22)
ctx.fill()
ctx.restore()
// --- main card ---
ctx.save()
ctx.fillStyle = '#FFFFFF'
roundRect(35, 42, 430, 316, 20)
ctx.fill()
ctx.restore()
// --- gradient circle icon (same feel as completion-emoji-wrap) ---
const circleX = W / 2
const circleY = 104
const circleR = 40
const grad = ctx.createLinearGradient(circleX - circleR, circleY - circleR, circleX + circleR, circleY + circleR)
grad.addColorStop(0, '#4CAF50')
grad.addColorStop(1, '#81C784')
ctx.fillStyle = grad
ctx.beginPath()
ctx.arc(circleX, circleY, circleR, 0, Math.PI * 2)
ctx.fill()
// --- white star ---
ctx.fillStyle = '#FFFFFF'
drawStar(circleX, circleY, 20, 8, 5)
ctx.fill()
// --- status text (kept well above the big number so a multi-digit
// minute count never overlaps it) ---
ctx.fillStyle = '#333333'
ctx.font = 'normal 600 24px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
ctx.fillText(message, W / 2, 184)
// --- big number + unit ---
const fmt = this._formatShareDuration(elapsed)
const numLen = fmt.num.length
const numSize = numLen <= 2 ? 84 : numLen <= 3 ? 70 : numLen <= 4 ? 56 : 44
ctx.font = `normal 800 ${numSize}px sans-serif`
ctx.textAlign = 'right'
ctx.textBaseline = 'alphabetic'
ctx.fillStyle = primary
const numX = W / 2 + 4
const numY = 286
ctx.fillText(fmt.num, numX, numY)
ctx.fillStyle = '#666666'
ctx.font = 'normal 600 22px sans-serif'
ctx.textAlign = 'left'
ctx.fillText(fmt.unit, numX + 8, numY - 6)
// --- streak ---
if (streak > 0) {
ctx.fillStyle = primary
ctx.font = 'normal 500 18px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`连续打卡 ${streak}`, W / 2, 332)
}
// --- export (no QR; draw immediately) ---
wx.canvasToTempFilePath({
canvas,
width: W,
height: H,
destWidth: W,
destHeight: H,
fileType: 'jpg',
quality: 0.9,
success: (r) => {
this._shareImageUrl = r.tempFilePath
},
fail: (err) => {
console.warn('[sharePoster] export failed', err)
}
})
})
},
/**
* 分享图专用时长格式化(与弹窗 WXS fmt 口径一致)。
*/
_formatShareDuration(s) {
s = Math.floor(s || 0)
if (s < 60) return { num: String(s), unit: '秒' }
const m = Math.floor(s / 60)
const sec = s % 60
return sec === 0 ? { num: String(m), unit: '分钟' } : { num: `${m}${sec}`, unit: '秒' }
},
/** /**
* 分享给朋友。训练完成弹窗里的分享按钮会走到这里,标题里拼上用户 * 分享给朋友。训练完成弹窗里的分享按钮会走到这里,标题里拼上用户
* 刚才撑了多久,转化率更高;右上角"···"菜单也共用同一份文案。 * 刚才撑了多久,转化率更高;右上角"···"菜单也共用同一份文案。
@@ -906,7 +1140,8 @@ Page({
const s = config.share.timer const s = config.share.timer
return { return {
title: s.titleTemplate.replace('{elapsed}', elapsed), title: s.titleTemplate.replace('{elapsed}', elapsed),
path: s.path path: s.path,
imageUrl: this._shareImageUrl || ''
} }
}, },
+15 -1
View File
@@ -172,6 +172,15 @@
<text class="streak-text">连续打卡 {{completionStreak}} 天</text> <text class="streak-text">连续打卡 {{completionStreak}} 天</text>
</view> </view>
<!-- 未达最低记录时长:明确提示本次未计入记录,并说明门槛 -->
<view class="completion-not-recorded" wx:if="{{completionNotRecorded}}">
<view class="completion-not-recorded-main">
<image class="completion-not-recorded-icon" src="{{icons.infoFill}}" mode="aspectFit"></image>
<text class="completion-not-recorded-title">本次训练未计入记录</text>
</view>
<text class="completion-not-recorded-sub">撑满 {{minRecordSeconds}} 秒以上才会记录并计入连续打卡</text>
</view>
<!-- 科学提示:价值行 + 弱化风险行(config.scienceTips 按时长选段) --> <!-- 科学提示:价值行 + 弱化风险行(config.scienceTips 按时长选段) -->
<view class="completion-tip" wx:if="{{completionTipValue}}"> <view class="completion-tip" wx:if="{{completionTipValue}}">
<text class="completion-tip-value">{{completionTipValue}}</text> <text class="completion-tip-value">{{completionTipValue}}</text>
@@ -185,13 +194,15 @@
<view class="completion-btn completion-btn--primary" bindtap="onTrainAgain"> <view class="completion-btn completion-btn--primary" bindtap="onTrainAgain">
<text>再来一次</text> <text>再来一次</text>
</view> </view>
<view class="completion-btn" bindtap="onViewRecords"> <!-- 未记录时没有成绩可看/可晒,隐藏查看记录与分享入口 -->
<view class="completion-btn" wx:if="{{!completionNotRecorded}}" bindtap="onViewRecords">
<text>查看记录</text> <text>查看记录</text>
</view> </view>
<!-- openType="share" 会触发 Page.onShareAppMessage(已实现), <!-- openType="share" 会触发 Page.onShareAppMessage(已实现),
没有这个属性 + onShareAppMessage 时分享按钮就是灰色的。 没有这个属性 + onShareAppMessage 时分享按钮就是灰色的。
ui-btn 透传 openType 到内部原生 button。 --> ui-btn 透传 openType 到内部原生 button。 -->
<ui-btn <ui-btn
wx:if="{{!completionNotRecorded}}"
variant="ghost" variant="ghost"
size="md" size="md"
customStyle="flex:1; height:88rpx;" customStyle="flex:1; height:88rpx;"
@@ -204,4 +215,7 @@
</view> </view>
</view> </view>
</view> </view>
<!-- 隐藏 canvas:用于绘制训练完成分享海报,onShareAppMessage 取它的导出图 -->
<canvas type="2d" id="sharePoster" class="share-poster-canvas"></canvas>
</view> </view>
+51
View File
@@ -425,6 +425,46 @@
height: 32rpx; height: 32rpx;
} }
/* 未达最低记录时长提示(完成弹窗内):主色淡底信息块,与科学提示同风格 */
.completion-not-recorded {
display: flex;
flex-direction: column;
gap: 10rpx;
width: 100%;
box-sizing: border-box;
background: rgba(var(--primary-rgb), 0.08);
border-radius: 20rpx;
padding: 20rpx 24rpx;
margin-bottom: 28rpx;
animation: tipIn 0.4s ease 0.5s both;
}
.completion-not-recorded-main {
display: flex;
align-items: center;
gap: 10rpx;
}
.completion-not-recorded-icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
}
.completion-not-recorded-title {
flex: 1;
font-size: 26rpx;
font-weight: 600;
line-height: 1.4;
color: var(--primary);
}
.completion-not-recorded-sub {
font-size: 24rpx;
line-height: 1.5;
color: var(--text-secondary);
}
.streak-text { .streak-text {
font-weight: 500; font-weight: 500;
} }
@@ -598,3 +638,14 @@
color: #FFFFFF; color: #FFFFFF;
box-shadow: 0 6rpx 16rpx rgba(var(--primary-rgb), 0.3); box-shadow: 0 6rpx 16rpx rgba(var(--primary-rgb), 0.3);
} }
/* ---- hidden canvas for share poster (drawn on completion, consumed by onShareAppMessage) ---- */
.share-poster-canvas {
position: fixed;
left: -9999px;
top: -9999px;
width: 500px;
height: 400px;
pointer-events: none;
opacity: 0;
}