Compare commits
8 Commits
05c4763e49
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 346d46cb27 | |||
| 9d2c752043 | |||
| 8ca709b86b | |||
| 845a1bfd8d | |||
| aa905b1d09 | |||
| b6d94b21cd | |||
| f1ad3eeb95 | |||
| 178a51d261 |
@@ -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.js,size=420 时验证):
|
||||||
|
- 根因:间距 = gap − arcW/2 = 3−4 = **−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**;想再拉开只能继续缩环或减弧宽。
|
||||||
|
|||||||
@@ -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` 即可。前端改动不发云函数。
|
||||||
@@ -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),别依赖该代理。
|
||||||
|
|||||||
@@ -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),
|
||||||
|
color: i === active ? primaryColor : successColor
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.save()
|
||||||
ctx.shadowColor = glowColor
|
ctx.shadowColor = dot.color
|
||||||
ctx.shadowBlur = 8 * dpr
|
ctx.shadowBlur = 8 * dpr
|
||||||
ctx.globalAlpha = paused ? 0.9 : 0.55 + 0.45 * p
|
ctx.globalAlpha = paused ? 0.95 : 0.7 + 0.3 * p
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(dx, dy, dotR, 0, Math.PI * 2)
|
ctx.arc(dot.x, dot.y, dotR, 0, Math.PI * 2)
|
||||||
ctx.fillStyle = glowColor
|
ctx.fillStyle = dot.color
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
// white core for a "light bulb" feel
|
// white core for a "light bulb" feel
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(dx, dy, dotR * 0.45, 0, Math.PI * 2)
|
ctx.arc(dot.x, dot.y, dotR * 0.45, 0, Math.PI * 2)
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.85)'
|
ctx.fillStyle = 'rgba(255,255,255,0.9)'
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_startSetPulse() {
|
_startSetPulse() {
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user