Compare commits
10 Commits
11d49a26df
...
bce796d893
| Author | SHA1 | Date | |
|---|---|---|---|
| bce796d893 | |||
| 6ff70971d8 | |||
| 3532d0111b | |||
| 76cd2b6c0f | |||
| deaff72763 | |||
| 1589d6c387 | |||
| fca3e32d04 | |||
| 19b0c4d4c8 | |||
| 1372ec19b8 | |||
| 903951aee0 |
@@ -0,0 +1,11 @@
|
|||||||
|
# 2026-07-30 工作日志
|
||||||
|
|
||||||
|
## 朋友提供的勋章体系 PRD 评审(纯分析,未改代码)
|
||||||
|
- 方案为三轴:毅力试炼(连续 streak 天数 3/7/15/21/30/66/100)、力量殿堂(累计时长 30min/3h/10h/50h)、极限时刻(单次时长 1/2/5min)。
|
||||||
|
- 结论:方向合理、数据齐(streak.count / totalDuration / maxDuration 现有 storage 均已有)、不浪费现有工作(现有 trainingDayBadges 可降级为"总天数纪念线")。
|
||||||
|
- 三处必须修:
|
||||||
|
- P0 护盾机制只提名字没设计——streak 线生死线,漏一天归零会劝退,需补"消耗品断签保护"规格。
|
||||||
|
- P0 文案/视觉全用 emoji,与用户既定"不想用 emoji"冲突,须换矢量图标(belt/shield/stopwatch 缺,需新增)。
|
||||||
|
- P1 需拍板"首页 C 位放 streak 还是累计天数"(用户早前确认过累计天数可中断,PRD 把 streak 提为核心线,不矛盾但需决策)。
|
||||||
|
- 落地注意:14 枚超单进度条容量→需勋章墙/分 Tab;config 阈值写秒;勋章同获不退(streak 断了不回收)。
|
||||||
|
- 用户明确要求:本次只分析、不改代码。
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 2026-07-31 工作日志
|
||||||
|
|
||||||
|
## 排行榜新增「耐力榜」(endurance)
|
||||||
|
- 需求:全局单次最久 top10,显示该次日期;本人未上榜则在底部 my-bar 显示最佳成绩+产生时间。
|
||||||
|
- 云函数 `cloudfunctions/leaderboard/index.js`:
|
||||||
|
- `_buildFromScan` 新增 `endurance` 分支:遍历每人全部 records 取 `max(duration)`,记下 `bestDate`(该条 r.date);`ranked`/`myEntry` 映射均透传 `bestDate`。
|
||||||
|
- `exports.main` 放行 `endurance`;`_rebuildSnapshots` 循环加入 `endurance`(随定时器每 4 分钟重算,存 top500)。
|
||||||
|
- `bestDate` 经 `publicEntry` 的 `...publicData` 展开自动进快照/响应,data.js 无需改。
|
||||||
|
- 客户端 `pages/leaderboard/leaderboard.js`:
|
||||||
|
- `periods` 加 `{key:'endurance',label:'耐力',maxRank:10}`(日/月/年保持 config.leaderboardMaxRank=50)。
|
||||||
|
- `fetchRank` 按当前周期取 `maxRank`(不再写死)。
|
||||||
|
- `_applyResult` 计算 `subText`(耐力=格式化 bestDate 去秒,其余=`N次`);新增 `_formatBestDate` 辅助。
|
||||||
|
- `_applyLocal` 加耐力兜底分支(本地 records 取 max+日期);分享 map 加 `endurance:'耐力'`。
|
||||||
|
- wxml 5 处 `{{x.sessions}}次` → `{{x.subText}}`(领奖台3/列表1/my-bar1)。
|
||||||
|
- wxss `.podium-base__label` 加 `nowrap`+省略号,防窄屏日期串换行撑破领奖台底座。
|
||||||
|
- 校验:`node --check` 云函数+客户端均通过;grep 确认无 `sessions}}次` 残留。
|
||||||
|
- **待办**:改云函数后须重新部署(定时器随部署注册),否则 endurance 周期不生效。改动尚未提交推送,等用户确认。
|
||||||
|
|
||||||
|
## 领奖台底座日期时间拆两行(耐力榜)
|
||||||
|
- 用户要求领奖台底座的「单次日期」拆成日期+时间两行。
|
||||||
|
- `leaderboard.js`:`_applyResult`/`_applyLocal` 给每条耐力榜数据补 `subDate`(YYYY-MM-DD)、`subTime`(HH:MM) 两字段(新增 `_splitBestDate` 从 bestDate 截断);`subText` 仍保留供列表/my-bar 单行使用。
|
||||||
|
- `leaderboard.wxml`:领奖台 3 处 `<text>` 改为 `<view>` 容器 + `wx:if activePeriod==='endurance'` 渲染两行 `podium-base__date`/`podium-base__time`,否则单行 `subText`。
|
||||||
|
- `leaderboard.wxss`:`.podium-base__label` 由单行绝对定位 text 改为 flex column 居中 view(去掉 nowrap/ellipsis),新增 `.podium-base__date`(20rpx)/`.podium-base__time`(18rpx) 缩小字号以适配最矮的第3名底座(48rpx)。
|
||||||
|
- 校验:`node --check` 通过,grep 确认 subDate/subTime/_splitBestDate 已接入。
|
||||||
|
|
||||||
|
## 领奖台第3名贴顶修复 + 顶部文案
|
||||||
|
- 第3名底座仅 48rpx,两行日期时间文字溢出贴顶。统一抬高三阶底座高度:first 96 / second 64→80 / third 48→64(梯度 96/80/64)。
|
||||||
|
- `.podium-base__label` bottom 8→6rpx;耐力榜两行字号缩小:date 20→18rpx、time 18→16rpx。两级余量足以抗微信字体缩放放大。
|
||||||
|
- 领奖台顶部新增哲理文案(仅耐力榜显示,`wx:if activePeriod==='endurance'`):
|
||||||
|
「平板支撑不是一场和时间的恋爱。撑得越久,不一定越好;真正重要的,是每一秒都没有辜负身体。」
|
||||||
|
- 文案配置进 `config.js` 新增 `leaderboardPodiumSlogan`;`leaderboard.js` data 挂 `podiumSlogan: config.leaderboardPodiumSlogan`;wxml 在 `<block wx:else>` 内 podium 前插入 `.podium-slogan` 块;wxss 新增 `.podium-slogan` 样式(居中、淡色、引用感卡片)。
|
||||||
|
- 校验:`node --check` 客户端 + config 均通过;grep 确认 podiumSlogan / leaderboardPodiumSlogan / podium-slogan 三处链路完整。
|
||||||
|
|
||||||
|
## 顶部文案配色与样式微调
|
||||||
|
- 首版 `.podium-slogan` 为灰底灰字,用户认为不好看 → 改为「极淡橙渐变底 + 主色描边 + 主色微光阴影 + 顶部主色大引号(`::before` `\201C`)点睛」金句卡片质感(用 `--primary-rgb`/`--text` 变量,深色模式自适应)。
|
||||||
|
- 用户再要求:去掉引号、文案改斜体。已删除 `.podium-slogan::before` 块;主块加 `font-style: italic`,并把 padding-top 从 26rpx 收到 22rpx 抵消去掉引号后的顶部留白。纯样式改动,无逻辑、无需重新部署云函数。
|
||||||
|
|
||||||
|
## 首页去掉 toolbar 上方小提示卡片
|
||||||
|
- 删除首页底部随机姿势提示卡片(homeTip):wxml 移除 tip-card 整块、wxss 清理 .tip-card/.tip-icon/.tip-text、js 移除 data.homeTip + 随机取 postureTips 逻辑 + setData 引用。
|
||||||
|
- `config.postureTips` 仍被训练页(timer)复用,保留不动。纯前端,编译即可生效,无需重新部署云函数。
|
||||||
|
- 提交:`deaff72`(含 config.js v3.02→v3.1 升版 + 顶部文案标点修正,均无害)。
|
||||||
|
|
||||||
|
## 三个 P2 小修(体检发现)
|
||||||
|
1. **离线兜底缺"N次"**:`pages/leaderboard/leaderboard.js` `_applyLocal` 非 endurance 分支 entry 漏 `subText`,补 `subText: \`${sessions}次\``,与云端 `_applyResult` 对齐。断网/未部署时底部成绩栏不再留白。
|
||||||
|
2. **同天二次训练文案虚高 1 天**:`pages/timer/timer.js` `_detectCelebrationLevel` 未判断 streak.lastDate 是否已为今天。updateStreak 对同天再次训练不递增,故 `nextStreak` 加 `lastIsToday` 判断:是则取 `streak.count`,否则取 `count+1`。只错文案、存储连胜数本就对。用 `storage.getToday().substring(0,10)` 比 lastDate(timer.js 未引入 util,故未用 util.dateOnly)。
|
||||||
|
3. **热力图日期解析脆弱**:`components/calendar-heatmap/calendar-heatmap.js` line 64 原 `parseInt(r.date.split('-')[2])` 靠 parseInt 侥幸解析 `DD HH:MM:SS`。改为 `parseInt(util.dateOnly(r.date).split('-')[2], 10)`,与全局日期规范统一(该组件已引入 util)。
|
||||||
|
- 校验:`node --check` 三文件均通过;grep 确认三处改动已落实。纯前端,无需重新部署云函数。尚未提交推送,等用户确认。
|
||||||
|
|
||||||
|
## 配置澄清
|
||||||
|
- `config.js` 的 `leaderboardMaxRank: 50` 是**客户端显示行数上限**(日/月/年榜 50、耐力榜 10)。
|
||||||
|
- 云函数 `cloudfunctions/leaderboard/index.js:11` `SNAPSHOT_TOP = 500` 是**快照每周期存储人数上限**(写死在云函数,不读 config)。底部「我的成绩」栏靠 findMyEntry 在存下的完整 500 里找本人,故排 51~500 也能在底部显示;仅走缓存快照时受 500 限制(force 实时重算路径在完整全量榜找人,不受 500 限)。两数字各管一摊。
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# 2026-08-03 工作日志
|
||||||
|
|
||||||
|
## 规划:分阶段训练(循环训练 circuit)功能
|
||||||
|
|
||||||
|
用户想在平板支撑小程序加入"分阶段训练":自定义 每组时长 / 每次组数 / 每周训练次数。
|
||||||
|
分析现有代码后结论:
|
||||||
|
|
||||||
|
- 现有 `utils/plan.js` 是"单组持续撑"模型(hold 模式),计划按天线性加秒;设置页已能自定义公式参数(totalDays/startTarget/increment/cycleDays),但无"组数/休息/周频次"概念。
|
||||||
|
- 用户要的是"循环训练(circuit)"范式,当前数据模型与计时器都表达不了,但云同步/计划选择/进度条/记录页骨架可复用。
|
||||||
|
|
||||||
|
### 已确认的设计决策(与用户拍板)
|
||||||
|
1. **双模式并存**:保留现有3个预设计划和老用户进度(mode:'hold'),新增 circuit 类型(mode:'circuit')。不破坏榜单/已有数据。
|
||||||
|
2. **弹性训练**:任意一天都能练;sessionsPerWeek 只用于显示「本周 X/N 次」(自然周周一重置),不卡进度(`getPlanDay` 已按训练日推进)。
|
||||||
|
3. **扁平 routine 先上**:整个计划每次 routine 相同(如 4组×30秒),靠"完成次数"和"每周次数"做进度;逐周进阶作为后续增强,MVP 不做。
|
||||||
|
|
||||||
|
### 约定细节
|
||||||
|
- 默认组间休息 15 秒(编辑器可改)。
|
||||||
|
- 循环预设示例:"新手 4组×20秒·休息15秒·3次/周·4周"。
|
||||||
|
- `duration` 始终存"有效撑总秒数 = sets×holdPerSet"(不含休息),保证排行榜/里程碑零改动。
|
||||||
|
|
||||||
|
### 计划改动清单(原方案,较完整,本次未走此路线)
|
||||||
|
- utils/plan.js:加 mode + circuit 目标解析 + 1 个循环预设
|
||||||
|
- utils/storage.js:saveRecord 支持 sets/holdPerSet/restPerSet/mode
|
||||||
|
- pages/timer + 新 utils/circuitTimer.js:circuit 状态机与 UI
|
||||||
|
- pages/index:目标显示"4组×30秒·休息15秒" + 本周进度
|
||||||
|
- pages/settings:编辑器加"循环模式"输入项
|
||||||
|
- pages/records:展示"4组×30秒"
|
||||||
|
- 榜单/云函数:不动
|
||||||
|
|
||||||
|
## 方案简化(用户两次追问"还有没有再简单一点的方案")
|
||||||
|
|
||||||
|
原方案把 circuit 做成完整"计划管理系统"(mode/预设/进度进阶/周频次追踪/设置页编辑器) 过度设计。
|
||||||
|
最终定位为「自由训练」的循环形态,而非新计划体系:
|
||||||
|
|
||||||
|
- 首页加「循环训练」入口:输入 每组时长/组数/休息秒数 → 跳 timer?circuit=1&hold=&sets=&rest=
|
||||||
|
- timer 内用新 utils/circuitTimer.js(类 Timer 接口: start/stop/pause/resume + onTick/onPhaseChange) 跑 工作/休息 状态机;UI 显示「第X/Y组」+ 阶段 + 组内剩余
|
||||||
|
- 首页主「开始训练」仍走原 hold 计划,circuit 不替换每日目标 → 完全不动 index 的计划逻辑
|
||||||
|
- storage.saveRecord 加可选 sets/holdPerSet/restPerSet/mode;duration 仍=有效撑总秒(sets×hold),榜单零改
|
||||||
|
- records 页:circuit 记录显示「4组×30秒」
|
||||||
|
- **砍掉**:plan.js mode、circuit 预设、设置页编辑器、plan-day 进阶、周频次追踪(周频次可仅作为 picker 里可选提示,先不做追踪)
|
||||||
|
- 唯一"真新逻辑"= circuitTimer 状态机;其余皆小 UI。风险与原方案同级(无新权限/域名)。
|
||||||
|
|
||||||
|
用户最终确认:**先做简化版,但循环训练设置要持久化**(下次打开不重填)。
|
||||||
|
|
||||||
|
## 已实现:简化版循环训练 + 配置持久化(2026-08-03 落地)
|
||||||
|
|
||||||
|
### 改动文件
|
||||||
|
- **utils/storage.js**:新增 `CIRCUIT_CONFIG_KEY` / `getCircuitConfig` / `saveCircuitConfig`(本地持久化,含默认值 `{holdPerSet:30, sets:4, restPerSet:15, sessionsPerWeek:3}` + 范围校验,**不进云同步**——纯设备偏好)。
|
||||||
|
- **utils/circuitTimer.js**(新):`CircuitTimer` 状态机 `idle→working→resting→…→done`,类 `Timer` 接口(`start/stop/pause/resume` + `onTick/onPhaseChange/onComplete`),`stop()` 返回 `workTotal`(有效撑总秒,不含休息),复用 `Date.now`+`onAppShow` 前台补偿。
|
||||||
|
- **pages/timer/timer.{js,wxml,wxss}**:`_init` 检测 `options.circuit` → `_initCircuit` 建 FSM + circuit 字段;新增 `_circuitCue/_circuitPhase/_onCircuitComplete/_vibrateOnce/_int`;阶段切换走**震动 + phaseTip 文本**(TTS 无"第N组/休息"词,刻意不堆语音),完成复用 `complete` 语音;`_saveAndShowCompletion` 写 `record.mode='circuit'` + `sets/holdPerSet/restPerSet/sessionsPerWeek`,**`planId` 用 `'circuit'`**(避免 `getPlanDay` 把循环记录算进每日计划进度);`onTrainAgain` 在 circuit 模式重建 FSM;completion overtime 在 circuit 隐藏(每阶段是固定目标,无"超长发挥"概念)。
|
||||||
|
- **pages/index/index.{js,wxml,wxss}**:加「循环训练 · 分组练习」ghost 按钮(`repeatFill` 图标);底部配置弹窗(stepper 调 每组时长/组数/休息/每周目标),确认后 `saveCircuitConfig` + 跳 `timer?circuit=1&hold=&sets=&rest=&sessions=`。
|
||||||
|
- **utils/icons.js**:加 `repeat`/`repeatFill`(Material repeat 路径,build 自动给 gray + primary 两变体)。
|
||||||
|
- **pages/records/records.{js,wxml}**:circuit 记录显示「循环 N组×Ms(·休Rs)」,基于 `r.mode`。
|
||||||
|
|
||||||
|
### 关键不变量(续做时牢记)
|
||||||
|
- `duration` 始终 = 有效撑总秒(组数×每组秒,不含休息)→ 排行榜/里程碑/统计零改动。
|
||||||
|
- 循环记录 `planId:'circuit'`,不污染每日计划 `getPlanDay`;连胜 `updateStreak` 仍计入(用户确实练了)。
|
||||||
|
- 进度环在 circuit 模式轨道恒中性灰(否则每阶段都按"累计≥阶段长"误判变绿)。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
6 个 JS 全过 `node --check`。未动 plan.js / 云函数 / 榜单 / 云同步。
|
||||||
|
|
||||||
|
### 未做(可选后续)
|
||||||
|
- 周频次硬追踪(仅存 sessionsPerWeek 作显示,未做"本周 X/N 次"计数)
|
||||||
|
- 逐周自动进阶
|
||||||
|
- 设置页统一管理入口
|
||||||
|
|
||||||
|
## 修复+重做:循环训练页顶部进度面板(2026-08-03 二次迭代)
|
||||||
|
|
||||||
|
用户反馈"顶部只有『1/3组,撑住』太简单难看"。**根因:上一轮只写了 `.circuit-bar` 的 WXML 结构,`timer.wxss` 里一行样式都没写**(grep 确认零匹配),所以渲染出来就是两段裸文字。
|
||||||
|
|
||||||
|
### 新设计(信息层次分工)
|
||||||
|
- **顶部面板管宏观「第几组」**,中间进度环管微观「本组还剩几秒」——不再重复展示同一信息。
|
||||||
|
- 结构:组进度胶囊点阵 → 大数字 `2/4 组` + 阶段徽章 → 副行「已撑 42 秒 · 目标 120 秒」。
|
||||||
|
- 点阵三态:`is-done`(实心主色) / `is-active`(加宽+主色渐变+发光呼吸) / `is-next`(休息时高亮下一组,success 绿呼吸)。
|
||||||
|
- 徽章四态 BEM:`--work`(主色渐变实心+白灯白字) / `--rest`(success 绿淡底) / `--idle` / `--pause`(中性灰)。休息态整张卡边框也转绿,视觉降温。
|
||||||
|
- 待机态显示总组数 `4 组` + "准备开始" + "每组30秒·休息15秒";运行态显示 `2/4 组` + 累计秒数。
|
||||||
|
|
||||||
|
### 实现要点(踩坑记录)
|
||||||
|
- **状态指示灯用纯 CSS 圆点,不用 icon**:项目里 SVG 图标颜色烤死在 data URI,CSS 改不动;而这个灯需要随状态换色(白/绿/灰),纯 CSS 是唯一干净解。
|
||||||
|
- **点阵用 `flex:1` + `max-width`**,任意组数都均分不溢出;`is-active` 用 `flex:2` 加宽突出。组数 >15 (`DOTS_MAX`) 退化为线性进度条 `.circuit-track`。
|
||||||
|
- **面板不设固定 height**,靠 padding 撑开——真机大字体档会放大文字但不放大 rpx 盒子,固定高度必裁字(项目老坑)。
|
||||||
|
- **`.circuit-bar` 必须 `position:relative; z-index:1`**:`.breathe-ring`(z-index:0) 在 DOM 里排在面板之后,不抬层橙色光晕会晕染到白卡片上。
|
||||||
|
- 暂停时 `.is-paused` 冻结所有 dot 呼吸动画——静止本身就是"已暂停"信号。
|
||||||
|
- dots 三态在 `onTick` 里算(与 `isResting` 同批 setData,不会状态不同步):resting 时 CircuitTimer 的 `setIndex` 仍指向"刚做完那组",故 `dotsDone=setIndex`、`dotsActive=0`、`dotsNext=setIndex+1`。
|
||||||
|
- `_initCircuit` 新增 `circuitDots`(仅 [1..sets] 供 wx:for) / `circuitShowDots` / `circuitTotalWork`(sets×hold)。
|
||||||
|
- hint-section 的 circuit idle 文案改为「循环训练 · 共需撑 N 秒」,避免与面板副文案重复。
|
||||||
|
|
||||||
|
改动文件:`pages/timer/timer.{js,wxml,wxss}`。`node --check` 通过,无旧类名残留。
|
||||||
|
|
||||||
|
## 微调:组进度点阵改为按组数严格等分铺满(2026-08-03 三次迭代)
|
||||||
|
|
||||||
|
用户反馈"点阵只占顶部一部分,应该按组数平均分配到整行"。
|
||||||
|
|
||||||
|
- **根因**:`.circuit-dot` 有 `max-width: 64rpx` 给每段宽度封顶,`.circuit-dots` 默认 `justify-content:flex-start` → 组数少时(3-4 组)点阵只占左边一截。此外 `is-active` 用 `flex:2` 加宽,本身就破坏了"按组数等分"。
|
||||||
|
- **修法**:`.circuit-dot` 改 `flex:1 1 0; min-width:0`,**删掉 `max-width`**;`.circuit-dots` 加 `width:100%`。当前组/下一组不再靠"加宽"强调,改为 **增高**(12rpx → 18rpx,圆角同步 6→9rpx)+ 渐变/发光/呼吸,宽度恒等分。
|
||||||
|
- `.circuit-dots` 加 `min-height:18rpx` 锁行高,避免 idle(无 active,行高 12rpx)→ running(有 active,18rpx)时整块跳 6rpx。
|
||||||
|
- transition 从 `flex` 改为 `height`(强调维度变了)。
|
||||||
|
- 仅改 `pages/timer/timer.wxss`,无 JS/WXML 改动。
|
||||||
|
|
||||||
|
## 修复:循环训练缺少过程语音(2026-08-03 四次迭代)
|
||||||
|
|
||||||
|
用户反馈"循环训练缺少正常训练时候的那些语音提示"。**根因有两处,都是设计遗漏**:
|
||||||
|
1. **`_circuitCue` 从未调用 `_remind`**:单组模式 `Timer.onTick` 里有 `this._remind(tick)`(负责 halfway/last30/last10 三条语音),circuit 的 `onTick` 只调 `_circuitCue`,里面仅有"最后一组末 5 秒震动"。
|
||||||
|
2. **`_circuitPhase` 当初刻意不放语音**(注释写明"TTS 无第N组/休息词条")。
|
||||||
|
结果整场只剩 2 声:`onStart` 的 `start` + `_onCircuitComplete` 的 `complete`。
|
||||||
|
|
||||||
|
### 修法
|
||||||
|
- **新增 3 个 TTS 词条**(`utils/voice.js` 与 `cloudfunctions/tts/index.js` 的 PROMPTS **必须同步**):`restStart`(休息一下,调整呼吸) / `nextSet`(下一组,准备开始) / `lastSet`(最后一组,全力冲刺!)。**刻意不含组号数字** → 固定 3 条覆盖任意组数,无需按 N 合成 N 份音频。
|
||||||
|
- **`_circuitCue` 加整场进度播报**:基准是 `total = sets × holdPerSet`(**整场有效秒,不是每组**)——否则 4 组会播 4 次"已完成一半啦",语义全错。判据用 `done >= total/2` 和 `left <= 30 / <= 10`(`<=` 而非 `===`,容忍后台回前台跳秒)。只在 `phase==='working'` 求值(休息时 workTotal 冻结,天然不会重复触发)。门槛沿用 `_remind`:halfway 需 total>=10、last30 需 total>60、last10 需 total>20。
|
||||||
|
- **`_circuitPhase` 加阶段语音 + `chatty` 闸门**:`holdPerSet >= 10` 才播 `nextSet`/`restStart`(一句话约 2 秒,短组会变语音连珠炮);`restStart` 另需 `restPerSet >= 5`;`lastSet` 无条件播(唯一值得打断的节点)。第 1 组不播(`onStart` 的 `start` 同刻已响)。
|
||||||
|
- **`_playProgressVoice` 优先窗口(关键时序坑)**:halfway 触发点 = `sets×hold/2`,**组数为偶数时必然落在第 sets/2 组的最后一秒**,与 rest 阶段切换同一 tick;`voice.play` 的 `_playSeq` 会让后播的截断先播的 → 默认 4 组配置下"已完成一半啦"必被 `restStart` 掐断。故进度语音播放时设 `this._voiceBusyUntil = Date.now()+2600`,`_circuitPhase` 在窗口内让位不播。`_initCircuit` 需重置 `_voiceBusyUntil = 0`(否则"再来一次"会误静音首条过场语音)。
|
||||||
|
- **preload 分模式**:`voice.HOLD_KEYS` / `voice.CIRCUIT_KEYS` 导出,`onLoad` 按 `isCircuitMode` 取,避免单组训练白白预载 3 条循环语音。
|
||||||
|
|
||||||
|
### 部署要求
|
||||||
|
**必须重新部署 `tts` 云函数**,否则 3 个新 key 云端返回 `Unknown prompt key`。降级是安全的(`_fetchUrl` 拿到 `success:false` → 返回 null → `play` 静默 return,不报错、不弹 toast),只是这 3 条不出声。首次播放会各触发一次 TTS 合成(一次性费用,之后走 fileID + 本地文件双层缓存)。
|
||||||
|
|
||||||
|
改动文件:`utils/voice.js`、`cloudfunctions/tts/index.js`、`pages/timer/timer.js`。三个 `node --check` 全过,两边词条 grep 核对一致。
|
||||||
|
|
||||||
|
## 分析(未改代码):训练时禁止息屏
|
||||||
|
|
||||||
|
用户问"训练过程中能否禁止屏幕息屏"。结论:能,`wx.setKeepScreenOn({keepScreenOn})`,基础库 1.4.0+,无授权/无域名/无审核风险。项目当前 **零处调用**(grep `setKeepScreenOn` 无匹配)。
|
||||||
|
|
||||||
|
### 为什么这不只是体验优化,而是修 bug
|
||||||
|
息屏/后台后小程序 JS 定时器被系统节流挂起 → 语音播报(halfway/last30/last10、circuit 的 restStart/nextSet/lastSet)与震动**全部丢失**。`Timer`/`CircuitTimer` 有 `Date.now` 前台补偿,秒数不会错,但中段提示是废的。平板支撑用户手撑地上不碰屏幕,60 秒必息屏 → 现状几乎每次训练的过程提示都收不到。
|
||||||
|
|
||||||
|
### 挂钩位置(关键)
|
||||||
|
- `wx.setKeepScreenOn` 作用域是**整个小程序**,跨页面持续生效,只有**退出小程序**才自动失效 → 在 `pages/timer` 打开后,若不手动关,用户返回首页/记录页/排行榜也会一直常亮耗电。
|
||||||
|
- 建议:`onStart` 开启 → `onUnload` 必须关闭(timer.js 现有 `onLoad`/`onShow`/`onUnload`,**无 `onHide`**)。暂停时保持常亮(用户可能只是短暂调整姿势)。完成弹窗期间也保持,离开页面才关。
|
||||||
|
- `wx.setKeepScreenOn` 是异步 API,`fail` 要静默吞掉(部分 Android ROM 省电模式会拒绝),不弹 toast。
|
||||||
|
|
||||||
|
### 已知限制(需向用户说明)
|
||||||
|
- 只阻止**自动**息屏;用户手动按电源键仍会锁屏。
|
||||||
|
- 部分 Android 厂商 ROM 在省电模式/低电量下会忽略该设置。
|
||||||
|
- 不影响亮度,屏幕可能仍会自动调暗(如需要可配 `wx.setScreenBrightness`,但必须在 `onUnload` 恢复,否则污染用户系统亮度——不建议做)。
|
||||||
|
|
||||||
|
### 建议的落地范围
|
||||||
|
约 5 行,只动 `pages/timer/timer.js`;可选在设置页加「训练时屏幕常亮」开关(默认开),与 `voiceGuide` 同样存在 settings 里。
|
||||||
|
|
||||||
|
## 已实现:训练时屏幕常亮(2026-08-03 五次迭代)
|
||||||
|
|
||||||
|
用户确认落地,且明确「先不要加开关」→ 默认恒开,不入 settings。
|
||||||
|
|
||||||
|
### 实现
|
||||||
|
仅改 `pages/timer/timer.js`,新增 `_keepScreenOn(on)` 方法 + 4 个挂钩点:
|
||||||
|
- `_keepScreenOn(on)`:`typeof wx.setKeepScreenOn !== 'function'` 时直接 return(低基础库降级),`fail(){}` 静默吞掉(Android 省电模式会拒绝,弹 toast 只会让用户困惑)。
|
||||||
|
- `onStart()` 正常启动分支 → 开启;**暂停时不关**(用户多半在调整姿势,闪屏更烦);**完成弹窗期间不关**(让用户看成绩)。
|
||||||
|
- `onStart()` 的 `isPaused` resume 分支 → 也调一次(暂停期间若切过后台,标志位已被系统清掉)。
|
||||||
|
- `onShow()` → `if (this.data.isRunning) this._keepScreenOn(true)` **重新武装**。这是最容易漏的一点:退出/切后台会清掉 app 级标志,回来不重设则后半程又静音。完成后 `isRunning` 已是 false(第 831 行 setData),不会误开。
|
||||||
|
- `onUnload()` → **必须关**。标志是 app 级不是页面级,不关则用户返回首页/记录/榜单全程常亮耗电。
|
||||||
|
|
||||||
|
hold 与 circuit 两种模式共用这一套(都走 onStart/onUnload),无需分别处理。`node --check` 通过。
|
||||||
|
|
||||||
|
## 分析(未改代码):首页打卡卡片左上/左下出现两条竖条
|
||||||
|
|
||||||
|
用户反馈"训练结束返回首页,顶部连续打卡卡片左上角和左下角各显示一个竖条,2-3 秒后消失"。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
`pages/index/index.wxss` 的 `.just-completed`(训练完成高亮)把 `border: 4rpx solid var(--primary) !important` + `box-shadow` 加在了 **`<ui-card>` 宿主节点**上(`index.wxml:13` 的 `class="{{justCompleted ? 'just-completed' : ''}}"`)。而 `components/ui-card/ui-card.wxss` **只给内部 `.ui-card` view 写样式,宿主节点没有任何 `display` 声明** → 宿主是 **inline 盒**。
|
||||||
|
|
||||||
|
inline 盒内包 block 子元素时,inline 盒被打断成"前/后"两个零宽行盒,border 只画在这两个碎片上;左右各 4rpx 贴合 → 视觉上就是 block 上方一条、下方一条竖线,且都贴左侧 = 用户看到的"左上角+左下角两个竖条"。
|
||||||
|
|
||||||
|
时序对上:`index.js:125-129` 在 `onShow` 置 `justCompleted=true`,`setTimeout` 3000ms 后置回 false —— 正是那 2-3 秒。
|
||||||
|
|
||||||
|
### 连带失效(同一根因,用户还没察觉)
|
||||||
|
- `.just-completed` 的 `animation: justCompletedPulse`(`transform: scale`)**从未生效** —— `transform` 对非替换 inline 元素无效。
|
||||||
|
- `box-shadow` 同样只画在两个零宽碎片上,橙色光晕看不见。
|
||||||
|
- 全局 grep `:host` / `display:block` 在 `components/` **零匹配** → 项目里**所有**自定义组件宿主都是 inline,任何在页面侧给组件标签加 border/shadow/transform 的写法都会踩同一个坑。
|
||||||
|
- 附带发现:`ui-card.wxss` 的 `.ui-card.in:nth-child(1..4)` 交错入场延迟**也是失效的** —— `.ui-card` 是组件内部根节点下唯一的第 1 个子元素,永远匹配 `nth-child(1)`,所有卡片延迟恒为 0.05s。要做交错必须由页面侧传 index 或用 `animation-delay` 内联 style。
|
||||||
|
|
||||||
|
### 候选修法(待用户拍板,尚未实施)
|
||||||
|
1. **推荐**:`components/ui-card/ui-card.wxss` 加 `:host { display: block; }`(微信小程序支持 `:host` 选择器)。一处修复所有 ui-card 用法,border/shadow/transform 全部恢复正常。风险:宿主从 inline 变 block 可能影响其他页面既有布局,需回归各页面卡片间距。
|
||||||
|
2. 保守版:只把 `.just-completed` 的高亮样式改成作用于**内部**元素(如页面侧改成给 `.header-card` 加高亮),不动组件。影响面最小但治标不治本。
|
||||||
|
3. 顺带可给其余 6 个自定义组件统一补 `:host { display: block; }`(需逐个回归,尤其 `ui-btn` 若被当行内元素用则不能改)。
|
||||||
|
|
||||||
|
## 已实现:修复竖条 + 恢复交错入场(2026-08-03 六次迭代,用户选方案 A)
|
||||||
|
|
||||||
|
### 改动
|
||||||
|
- **`components/ui-card/ui-card.wxss`**:新增 `:host { display:block; margin-bottom:24rpx; box-sizing:border-box }`,并把 `margin-bottom:24rpx` 从内部 `.ui-card` 移除(**上提到宿主**)——否则页面侧加的描边会把 24rpx 外边距一起圈进去,框比卡片高一截。删掉失效的 `.ui-card.in:nth-child(1..4)` 规则。
|
||||||
|
- **`components/ui-card/ui-card.js`**:加 `index` 属性(Number,默认 0)。
|
||||||
|
- **`components/ui-card/ui-card.wxml`**:`style="animation-delay:{{50 + (index>3?3:(index>0?index:0))*100}}ms;{{customStyle}}"`。**customStyle 必须写在最后**才能覆盖内联 delay。>3 钳到 3,避免设置页第 7 张卡等 0.65s。
|
||||||
|
- **`pages/index/index.wxss`** 的 `.just-completed`:`border: 4rpx solid` → `box-shadow: 0 0 0 4rpx var(--primary), 0 4rpx 24rpx rgba(--primary-rgb,.3)`,加 `border-radius: 24rpx`,去掉两个 `!important`(宿主上已无竞争样式)。
|
||||||
|
- **14 处调用点补 `index`**:index 页 0-1;records 页 0,1,2(trend 分支),2(records 分支,与 trend 互斥故同号),3;settings 页 0-6。
|
||||||
|
|
||||||
|
### 校验与风险
|
||||||
|
- `ui-card.json` 无 `styleIsolation` 覆盖(默认 isolated),`:host` 有效。`customStyle` 从未用在 ui-card 上(只有 ui-btn 在 timer 页用),改动无冲突。
|
||||||
|
- `hidden="{{...}}"`(records 5 张卡)安全:微信内置 `[hidden]{display:none!important}` 带 `!important`,优先级高于 `:host` 的 `display:block`(`:host` 未加 !important)。
|
||||||
|
- 待真机确认:inline→block 会吃掉标签间行内空白,各页卡片间距可能略收紧几 px(属修正,非回归);`transform:scale(1.02)` 脉冲首次真正生效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 提交与推送
|
||||||
|
|
||||||
|
今日全部改动已合为一个提交推送到 `origin/main`:
|
||||||
|
|
||||||
|
- commit `3532d01` `feat: 循环训练模式 + 语音补全 / 屏幕常亮 / 卡片渲染修复`(23 files, +1433 / -44)
|
||||||
|
- 前一个提交是 `76cd2b6`。推送后 `git rev-list --left-right --count origin/main...main` 为 `0 0`,工作区干净。
|
||||||
|
- **未拆分成多个 commit 的原因**:`pages/timer/timer.js` 被循环训练 / 顶部面板 / 语音 / 常亮四轮改动同时触及,按文件粒度无法干净切分,hunk 级拆分成本高且易错。改为一次提交、commit message 分四段写清各自根因。
|
||||||
|
- `.workbuddy/memory/*.md` 一并提交(该目录本就在版本控制内,已有 2026-07-21/22/28 三份历史日志被跟踪)。
|
||||||
|
- 推送按项目惯例走沙箱外(`dangerouslyDisableSandbox`),沙箱内会 SSL 超时。
|
||||||
|
|
||||||
|
### ⚠️ 部署待办
|
||||||
|
`cloudfunctions/tts` **尚未重新部署**。新增的 `restStart / nextSet / lastSet` 三条词条在云端词表白名单里还不存在,线上会返回 `Unknown prompt key` → 客户端静默降级(不报错、不弹窗,只是不出声)。循环训练的过场语音要生效必须先部署这个云函数。
|
||||||
|
|
||||||
|
## 首页按钮改直角(2026-08-03 末尾)
|
||||||
|
|
||||||
|
用户偏好无圆角按钮。把 `pages/index/index.wxml` 中 5 个 `ui-btn` 全加 `custom-style="border-radius:0;"`(主训练按钮 xl-primary、两个 ghost 次按钮、两个弹窗 primary-lg 按钮)。
|
||||||
|
- **实现要点**:`ui-btn` 是全局共用组件且 `style isolation: isolated`,页面 wxss 改不到内部圆角;但组件暴露 `customStyle` 属性(直接挂到内部 `<button>` 的 `style`),内联 `border-radius:0` 可覆盖组件内置的 `48rpx`,**仅影响首页、不动全局与其他页面**。
|
||||||
|
- 生成 `preview-buttons.html` 对照预览(圆角 vs 直角)。
|
||||||
|
- **用户反馈直角不好看 → 改为折中 `border-radius:10rpx`(8–12 中间值)**,5 处 `custom-style` 已更新。仅首页、不动全局。预览页「改后」栏同步更新为 10rpx 效果。
|
||||||
|
- **布局调整**:首页「自由训练 / 循环训练」两个 ghost 次按钮从竖排改为并排一行。wxml 用 `<view class="action-row">` 包住两个 `<ui-btn class="action-cell">`;index.wxss 加 `.action-row{display:flex;gap:16rpx}` + `.action-row .action-cell{flex:1;min-width:0}`(宿主节点为 flex 项,flex:1 各占一半,保留 `block` 让内部按钮撑满宿主)。按钮文字精简为「自由训练」「循环训练」。
|
||||||
|
- **高度对齐**:自由/循环两个 ghost 次按钮 `size` 从 `md`(80rpx) 改为 `xl`(108rpx),与主按钮 `size="xl"` 高度一致(圆角保持 16rpx)。预览页 `btn--md`→`btn--xl` 同步。仅首页、不动全局组件。
|
||||||
+20
-38
@@ -1,43 +1,25 @@
|
|||||||
# 项目长期记忆(wx_pbzc 平板支撑训练小程序)
|
# 项目长期记忆(wx_pbzc 平板支撑训练小程序)
|
||||||
|
|
||||||
## 关键技术陷阱
|
## 关键技术陷阱(最高优先级,改样式/加组件前必读)
|
||||||
- **小程序组件 style isolation**:自定义组件默认 `isolated`。组件内 `variant` 拼出的类(`gradient`/`in` 等)在组件作用域,页面里写的 `.组件类 <slot后代>` 跨作用域选择器**全部失效**(如 `.ui-card.gradient .streak-num`)。唯一能跨边界传递的是 **CSS 自定义属性**(会沿 DOM 继承进 slot)。
|
- **组件 style isolation = `isolated`**:页面 wxss 改不了组件内部类;唯一能跨边界的是 **CSS 变量**(沿 DOM 继承进 slot)。渐变/主色容器内反白文字必须用 `var(--text)`,**绝不**用 `var(--primary)`(橙字画橙底=隐形)。进度条填充用 `var(--on-primary,...)` 让容器可覆盖为白。
|
||||||
- 推论:渐变/主色容器内要反白的文字,必须用 `var(--text)/var(--text-secondary)`(容器在 `.gradient` 里重定义过即会变白),**绝不能用 `var(--primary)`**——否则橙色字画在橙色底上=看不见。
|
- **图标色烤死在 SVG data URI**(`utils/icons.js`:`*Fill`=主题色、`*`(无Fill)=灰 `#999`),CSS 改不了 `<image>`。主色/渐变底(hero 卡、primary 按钮)须用 `*White` 变体(如 `playWhite`),浅底用 `*Fill`。**随状态换色的小装饰用纯 CSS 画**(如 `.circuit-phase-dot`),别用 icon。
|
||||||
- 进度条填充同理:用 `var(--on-primary, <原渐变>)` 让容器可覆盖为白,而非依赖失效的后代选择器。
|
- **圆形头像裁剪**:必须「外层 `overflow:hidden` + `<image>` 自身 `border-radius:50%`」双保险,否则远程照片(cloud://)裁不掉。
|
||||||
- **图标 SVG 颜色是烤死在 data URI 里的**(`utils/icons.js` 的 `build()`:`*Fill` 用 `primary` 主题色、`*`(无 Fill)用灰 `#999`)。CSS 的 `color`/`currentColor`/变量**都改不了 `<image src>` 这种图标**。所以:
|
- **系统字体缩放致文字溢出固定盒**(真机重现、模拟器不重现):`getAppBaseInfo().host.env==='devtools'` 时跳过补偿,真机/PC 才把字号 `÷factor` 并 clamp。凡文字进固定圆/方盒且靠 `overflow:hidden` 裁切,都要考虑。
|
||||||
- 放在**主色/渐变底**(gradient hero 卡、primary 实心按钮)上的图标必须用**白色变体**(`hotWhite`/`formWhite`/`playWhite`/`trophyWhite` 等),否则同色隐形。
|
- **云函数写库必须 `{ data: {...} }` 包裹**(wx-server-sdk 2.6.3):`set/update/add` 漏包裹报 `parameter.data should be object`。读操作 `get()` 无此限制。
|
||||||
- 加新图标或新彩色容器时,先想清楚底色:浅色底用 `*Fill`(主题色),彩色/主色底用 `*White` 变体。
|
- **自定义组件宿主默认 inline**:页面给 `<ui-card>` 等标签加 `border`/`transform`/`box-shadow` 会碎裂或静默失效(仅 `ui-card` 已修 `:host{display:block}`)。描边优先用 `box-shadow:0 0 0 Nrpx` 并补 `border-radius`,别用 `border`。跨组件边界的 `nth-child` 选择器一律失效,交错延迟由页面传 `index` 驱动。
|
||||||
- 主按钮 `ui-btn--primary` 背景是橙渐变,`playFill`(橙) 放上去即隐形——须用 `playWhite`。ghost/outline 按钮底色浅或灰,用 `*Fill`/灰色变体即可。
|
|
||||||
- `components/ui-btn` 图标尺寸 `1.3em`(随按钮字号自适应),不要回到 `1em` 以免显得过小。
|
|
||||||
- 深色模式有两套事实源,改配色前先统一:`theme.json` 的 dark 与 `app.wxss`/`utils/theme.js` 要一致(已统一到 `#131316` 系)。
|
|
||||||
- **微信圆形头像/图片裁剪陷阱(用户基础库实测)**:本项目的圆形头像在用户机器上表现如下——
|
|
||||||
- 单独给 `<image>` 加 `border-radius:50%`(无 overflow 父层)→ 圆框里露方图,失效。
|
|
||||||
- 外层 `<view>` 加 `overflow:hidden`+`border-radius:50%` 包裹 → **能裁切 SVG 占位图,但裁不掉远程照片(cloud:// 真实头像)**,照片仍方。所以日榜(多为占位图)正常、月榜/年榜(真实照片)却方。
|
|
||||||
- **唯一可靠写法**:外层 `overflow:hidden` 父层 **且** `<image>` 自身也加 `border-radius:50%`(双保险)。设置页 `.avatar-btn`(button,overflow:hidden)+`.avatar-img`(image,border-radius:50%) 即是此写法且实测正常。排行榜 `.avatar`/`.avatar__img` 已对齐这一写法。
|
|
||||||
- 推论:以后做圆形头像,**必须** image 自身 `border-radius:50%`,不能只靠父层 overflow。
|
|
||||||
- **微信系统字体缩放导致文字溢出固定盒子(模拟器不重现、真机重现)**:微信会按用户「设置→通用→字体大小」自动放大**所有文字(含 rpx 字号)**,但**不放大 rpx 盒子尺寸**(width/height/padding)。症状:固定尺寸的圆形/方形容器里的文字在真机被放大、超出后被 `overflow:hidden` 裁掉,模拟器(标准档倍率 1.0)正常。
|
|
||||||
- 获取缩放:`wx.getAppBaseInfo().fontSizeScaleFactor`(当前字号÷标准 17px);旧接口 `getSystemInfoSync().fontSizeSetting`(px,标准 17)。
|
|
||||||
- **真机/模拟器不对称(关键坑)**:`getAppBaseInfo().host.env` 在模拟器是 `'devtools'`、真机是 `'wechat'`。模拟器会**原样返回你手机的真实缩放倍率**,但**渲染时并不会把文字放大**;真机才会真的放大。所以若不做区分,补偿把字号缩成 `design/factor` 后:真机放大回 design(正常),模拟器按缩小值渲染(数字变小=「不正常」)。→ **必须在 `host.env === 'devtools'` 时跳过补偿、直接用设计字号**;仅在真机/PC 微信才补偿。首页 `_readFontScale()` 已加此判断。
|
|
||||||
- 补偿法:把容器内的关键文字字号反向除以该倍率(`designRpx / factor`),并在 JS 里 clamp 到安全区间(如 48–96),使真机渲染成和模拟器一致的视觉大小、永不溢出。首页 `.target-time` 已用此法(`index.js` `_readFontScale()`)。
|
|
||||||
- 配套 CSS:`white-space:nowrap` 防换行顶出圆圈。
|
|
||||||
- 推论:以后凡把文字放进**固定尺寸的圆形/方形容器**且依赖 `overflow:hidden` 裁切,都必须考虑字体缩放补偿,否则真机大字体档必溢出。
|
|
||||||
- **排行榜头像 1–2s 延迟根因(设计使然,非 bug)**:`profile.avatarUrl` 存的是 `cloud://` 云存储 fileID(`settings._uploadAvatar` 用 `wx.cloud.uploadFile` 返回 fileID);云函数 `leaderboard` 直接透传,前端 `<image src="cloud://...">` 在 `setData` 渲染时才把 fileID 解析成临时 https URL(等效一次 `getTempFileURL` 网络往返)再下载。文字(昵称/时长/排名)是本地字符串瞬时渲染 → 故"文字先出、头像后出",延迟正是头像专属的「云存储解析 + 原图下载」段,叠加云函数冷启动。
|
|
||||||
- 占位图 `peopleFill` 是本地 SVG data URI 瞬时,所以日榜(多为占位)无感、月/年榜(真实照片)延迟明显。
|
|
||||||
- 优化方向:①云函数返回前对 `cloud://` 的 `avatarUrl` 批量 `cloud.getTempFileURL` 预解析成 https;②`_uploadAvatar` 上传前把 `chooseAvatar` 原图压缩/裁剪到最长边 ~200px、转 jpg/webp,体积降一个数量级;③云函数保活降冷启动。
|
|
||||||
- **云函数数据库写操作必须 `{ data: {...} }` 包裹(wx-server-sdk 关键坑)**:`wx-server-sdk`(本项目 2.6.3) 的 `db.collection().doc(id).set(x)` / `.update(x)` / `.add(x)` **写操作**,参数必须是 `{ data: realObj }` 包裹形式——SDK 内部读 `parameter.data` 当作要写入的文档。直接传裸对象 `set(realObj)` 会让 SDK 读 `realObj.data` === `undefined`,报 `parameter.data should be object instead of undefined`(读操作 `get()` 不受影响,裸对象/无参都行)。`leaderboard` 云函数 `_persistSnapshot` 踩过此坑:写快照全失败、集合一直空。
|
|
||||||
- 修复:`docRef.set({ data: { period, ranked, myOpenid, updatedAt } })`。写入后文档内容即 `realObj`(data 外套被 SDK 解开),读取 `doc.data` 仍是 `realObj`,读逻辑无需改。
|
|
||||||
- 推论:**以后任何云函数写库代码,先写 `{ data: ... }`**,别沿用客户端 SDK 的裸对象习惯;出错时先怀疑是不是漏了 data 包裹,而不是怀疑部署。
|
|
||||||
|
|
||||||
## 排行榜架构(2026-07-28 落地 P0/P1/P2 后)
|
## 排行榜架构(2026-07-28+,云函数 `cloudfunctions/leaderboard`)
|
||||||
- **预计算快照**:`cloudfunctions/leaderboard` 的 `exports.main` 现在区分两种调用——
|
- **预计算快照**:定时触发器(每 4min)`_rebuildSnapshots()` 扫 `plank_data` 算 day/month/year/endurance 四榜写 `leaderboard_snapshot`;客户端 99% 走 `_serveFromSnapshot()`(≈50ms),仅训练后 `force=true` 那次实时重算+回写。**改后须重新部署云函数**(定时器随部署注册)。
|
||||||
- **定时触发器**(`config.json` 的 `triggers`:`snapshotTimer` / `0 */4 * * * * *`,每 4 分钟)进入 `_rebuildSnapshots()`:扫描一次 `plank_data`(`_fetchLatestByOpenid`,走 updatedAt 索引+投影),复用 `_buildFromScan()` 算 day/month/year 三榜,各自写进 `leaderboard_snapshot` 集合(doc._id=周期,字段 `period/ranked/myOpenid/updatedAt`;首次写自动建集合)。
|
- **耐力榜(endurance)**:全局单次最久,取 `max(duration)` 并记 `bestDate`;本人未进 top10 由 my-bar 在底部显示(依赖快照存 top500)。
|
||||||
- **客户端请求**:默认走 `_serveFromSnapshot()`(1 次 `get` ≈ 50ms),快照缺失/过期(>6min)才 `_computeBoard()` 实时重算并回写。**首开/冷启动已从 2-3s 降到 ≈50ms。**
|
- 头像延迟已通过云函数 `getTempFileURL` 预解析解决;`plank_data.updatedAt` 需建单字段索引。
|
||||||
- **force 语义**:仅训练后那一次(客户端 `onShow` 读 `_lb_force_refresh` 置 `force=true`)走 `_computeBoard(force:true,persist:true)` 实时重算+回写快照;其他 99% 请求读快照,已满足"force 不再绕过云缓存"诉求。
|
|
||||||
- **P1 启动预热**:`app.js` `onLaunch` 在 `cloud.init()` 后 fire-and-forget 调一次 `leaderboard`(day),提前暖云实例/快照。
|
## 语音 / 常亮 / 循环训练
|
||||||
- **头像延迟**:云函数内 `getTempFileURL` 预解析已落地(在 `_buildFromScan` 里),前端不再懒加载,故"文字先出头像后出"的 1-2s 延迟已大幅消除;快照每 4min 重写,临时 URL 始终新鲜。
|
- **TTS 词条两份白名单**(`utils/voice.js` 与 `cloudfunctions/tts/index.js`)须同步;加词条后**必须重新部署 tts 云函数**。词条避免含数字/变量(如"第3组"),用通用句。
|
||||||
- **改动注意**:要生效必须**重新部署 `leaderboard` 云函数**(定时器随部署注册)。`plank_data.updatedAt` 单字段索引需在控制台已建(用户已建)。响应结构 `{period,ranked,myOpenid,myEntry,updatedAt}` 不变,客户端 `leaderboard.js` 无需改逻辑(仅注释更新)。
|
- **`voice.play` 截断前一条**(`_playSeq`):circuit halfway 与 rest 切换同刻须用 `_voiceBusyUntil` 让位;每组<10s 不播过场。
|
||||||
|
- **`wx.setKeepScreenOn` 是小程序级、切后台失效**:`onUnload` 须显式关,`onShow` 若 `isRunning` 须重新武装(否则后半程息屏丢语音/震动)。
|
||||||
|
- **循环训练 circuit**(2026-08-03):自由训练的循环形态,不碰 `plan.js`。入口首页 ghost 按钮→配置弹窗→`timer?circuit=1&...`。`duration`=组数×每组秒(不含休息);记录 `mode:'circuit'`、`planId:'circuit'`(计入连胜不计入计划进度)。状态机 `utils/circuitTimer.js`。
|
||||||
|
|
||||||
## 约定
|
## 约定
|
||||||
- UI 动词说法:index=首页/训练首页,timer=训练页,leaderboard=排行榜,records=记录,settings=设置。
|
- 页面叫法:index=训练首页,timer=训练页,leaderboard=排行榜,records=记录,settings=设置。
|
||||||
- 美化按"见效快优先"分批做,每轮 node --check + grep 残留再交付。
|
- 美化按"见效快优先"分批,每轮 `node --check` + grep 残留再交付。
|
||||||
- **git 推送需走 sandbox 外**:本机 Bash 工具默认 sandbox 隔离网络,`git push` 到 github.com 会 `SSL connection timeout`(约 5 分钟才报错)。必须用 `dangerouslyDisableSandbox: true` 在沙箱外执行推送(或 `run_in_background` + 沙箱外)才能连上。无 SSH key,remote 仅 HTTPS。
|
- **git push 须 `dangerouslyDisableSandbox:true`**(沙箱隔离致 github SSL 超时);无 SSH,仅 HTTPS remote。
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 循环训练(简化版 + 配置持久化)实现概览
|
||||||
|
|
||||||
|
## 做了什么
|
||||||
|
在「自由训练」并列新增**「循环训练」入口**,让用户自定义 每组时长 / 组数 / 组间休息(外加每周目标次数,仅作显示),并把这些设置**本地持久化**——下次打开直接沿用,无需重填。这正是你要的"简化版 + 至少要有循环训练设置的持久化"。
|
||||||
|
|
||||||
|
## 核心设计
|
||||||
|
- **数据模型零入侵**:循环记录以 `planId:'circuit'` 落库,`getPlanDay` 不会把它算进每日计划进度;`duration` 仍存"有效撑总秒 = 组数×每组秒"(不含休息),排行榜 / 里程碑 / 统计零改动。
|
||||||
|
- **状态机独立**:`utils/circuitTimer.js` 是 `idle→working→resting→…→done` 的 FSM,复用现有 `Timer` 的 `Date.now` + `onAppShow` 前台补偿风格;`stop()` 返回有效撑总秒,直接复用 timer 页现有保存逻辑。
|
||||||
|
- **配置持久化**:`storage.getCircuitConfig / saveCircuitConfig` 存本地,含默认值与范围校验,不进云同步(纯设备偏好)。
|
||||||
|
- **阶段提示**:语音只有固定 6 个 key、无"第N组 / 休息"词,故阶段切换用**震动 + 屏上文本**提示,完成复用 `complete` 语音——不过度依赖 TTS。进度环在循环模式恒中性灰(避免每阶段都"已达标"变绿误导)。
|
||||||
|
|
||||||
|
## 改动文件
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `utils/storage.js` | 加 circuit 配置读写(本地持久化) |
|
||||||
|
| `utils/circuitTimer.js` | 新:循环状态机 |
|
||||||
|
| `pages/timer/timer.{js,wxml,wxss}` | circuit 模式分支、状态条、阶段提示、完成/保存 |
|
||||||
|
| `pages/index/index.{js,wxml,wxss}` | 「循环训练」入口 + 配置弹窗(stepper + 持久化) |
|
||||||
|
| `utils/icons.js` | 加 `repeat` / `repeatFill` 图标 |
|
||||||
|
| `pages/records/records.{js,wxml}` | 循环记录显示「循环 N组×Ms」 |
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
- 6 个 JS 文件全部通过 `node --check`。
|
||||||
|
- 未改动 `plan.js` / 云函数 / 榜单 / 云同步逻辑,首提风险低。
|
||||||
|
|
||||||
|
## 未做(可选后续)
|
||||||
|
- 周频次硬追踪(仅存 `sessionsPerWeek` 作显示,未做"本周 X/N 次"计数)
|
||||||
|
- 逐周自动进阶
|
||||||
|
- 设置页统一管理入口
|
||||||
|
|
||||||
|
## 真机注意
|
||||||
|
- 环形进度用 countUp 动画,不进固定 rpx 盒,规避字体缩放溢出(首页 `_readFontScale` 思路已避开)。
|
||||||
|
- 主色 / 渐变底图标用 White 变体;`repeatFill` 为橙主题色,在 ghost 按钮浅底 OK。
|
||||||
@@ -149,6 +149,37 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
|
|||||||
for (const doc of latestByOpenid.values()) {
|
for (const doc of latestByOpenid.values()) {
|
||||||
const openid = doc._openid || 'unknown'
|
const openid = doc._openid || 'unknown'
|
||||||
const records = doc.records || {}
|
const records = doc.records || {}
|
||||||
|
|
||||||
|
if (period === 'endurance') {
|
||||||
|
// 全局单次最久:遍历该用户全部月份的 records,取 max(duration) 那条,
|
||||||
|
// 并记下其 r.date 作为"产生时间"。不做日/月/年过滤、不累加。
|
||||||
|
let bestDuration = 0
|
||||||
|
let bestDate = ''
|
||||||
|
let total = 0
|
||||||
|
for (const monthKey of Object.keys(records)) {
|
||||||
|
const arr = records[monthKey]
|
||||||
|
if (!Array.isArray(arr)) continue
|
||||||
|
for (const r of arr) {
|
||||||
|
if (!r || typeof r.date !== 'string') continue
|
||||||
|
total++
|
||||||
|
const dur = Number(r.duration) || 0
|
||||||
|
if (dur > bestDuration) { bestDuration = dur; bestDate = r.date }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestDuration > 0) {
|
||||||
|
const profile = doc.profile || {}
|
||||||
|
userMap.set(openid, {
|
||||||
|
openid,
|
||||||
|
duration: bestDuration,
|
||||||
|
bestDate,
|
||||||
|
sessions: total,
|
||||||
|
nickname: profile.nickname || '',
|
||||||
|
avatarUrl: profile.avatarUrl || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
let duration = 0
|
let duration = 0
|
||||||
let sessions = 0
|
let sessions = 0
|
||||||
|
|
||||||
@@ -208,6 +239,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
|
|||||||
name: _displayName(item),
|
name: _displayName(item),
|
||||||
duration: item.duration,
|
duration: item.duration,
|
||||||
sessions: item.sessions,
|
sessions: item.sessions,
|
||||||
|
bestDate: item.bestDate || '',
|
||||||
avatarUrl: item.avatarUrl || ''
|
avatarUrl: item.avatarUrl || ''
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -231,6 +263,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
|
|||||||
name: _displayName(myEntryRaw),
|
name: _displayName(myEntryRaw),
|
||||||
duration: myEntryRaw.duration,
|
duration: myEntryRaw.duration,
|
||||||
sessions: myEntryRaw.sessions,
|
sessions: myEntryRaw.sessions,
|
||||||
|
bestDate: myEntryRaw.bestDate || '',
|
||||||
avatarUrl: myEntryRaw.avatarUrl || ''
|
avatarUrl: myEntryRaw.avatarUrl || ''
|
||||||
} : null
|
} : null
|
||||||
|
|
||||||
@@ -294,7 +327,7 @@ const _serveFromSnapshot = async (period, limit, myOpenid) => {
|
|||||||
*/
|
*/
|
||||||
const _rebuildSnapshots = async () => {
|
const _rebuildSnapshots = async () => {
|
||||||
const latestByOpenid = await _fetchLatestByOpenid(true)
|
const latestByOpenid = await _fetchLatestByOpenid(true)
|
||||||
for (const p of ['day', 'month', 'year']) {
|
for (const p of ['day', 'month', 'year', 'endurance']) {
|
||||||
const result = await _buildFromScan(latestByOpenid, p, SNAPSHOT_TOP, null)
|
const result = await _buildFromScan(latestByOpenid, p, SNAPSHOT_TOP, null)
|
||||||
console.log('[leaderboard] rebuilt', p, 'rankedLen=', (result && result.ranked) ? result.ranked.length : 'undef')
|
console.log('[leaderboard] rebuilt', p, 'rankedLen=', (result && result.ranked) ? result.ranked.length : 'undef')
|
||||||
await _persistSnapshot(p, result)
|
await _persistSnapshot(p, result)
|
||||||
@@ -328,7 +361,7 @@ exports.main = async (event) => {
|
|||||||
const { period, maxRank, force } = event || {}
|
const { period, maxRank, force } = event || {}
|
||||||
const limit = Math.max(1, Math.min(parseInt(maxRank) || DEFAULT_MAX_RANK, 500))
|
const limit = Math.max(1, Math.min(parseInt(maxRank) || DEFAULT_MAX_RANK, 500))
|
||||||
if (!period) return { err: 'missing period' }
|
if (!period) return { err: 'missing period' }
|
||||||
if (period !== 'day' && period !== 'month' && period !== 'year') return { err: 'invalid period' }
|
if (period !== 'day' && period !== 'month' && period !== 'year' && period !== 'endurance') return { err: 'invalid period' }
|
||||||
|
|
||||||
const myOpenid = cloud.getWXContext().OPENID
|
const myOpenid = cloud.getWXContext().OPENID
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ const PROMPTS = {
|
|||||||
last30: '最后30秒,保持呼吸,稳住姿势!',
|
last30: '最后30秒,保持呼吸,稳住姿势!',
|
||||||
last10: '最后10秒,再加把劲!',
|
last10: '最后10秒,再加把劲!',
|
||||||
goal: '目标达成,继续挑战!',
|
goal: '目标达成,继续挑战!',
|
||||||
complete: '太棒了!今天的目标已完成,继续加油!'
|
complete: '太棒了!今天的目标已完成,继续加油!',
|
||||||
|
// 循环训练(circuit)阶段切换专用。必须与 utils/voice.js 的 PROMPTS 保持一致,
|
||||||
|
// 否则客户端能请求、云端返回 Unknown prompt key(会静默降级为不播)。
|
||||||
|
restStart: '休息一下,调整呼吸',
|
||||||
|
nextSet: '下一组,准备开始',
|
||||||
|
lastSet: '最后一组,全力冲刺!'
|
||||||
}
|
}
|
||||||
|
|
||||||
const CACHE_DIR = 'tts-cache'
|
const CACHE_DIR = 'tts-cache'
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ Component({
|
|||||||
const recordMap = {}
|
const recordMap = {}
|
||||||
if (records && records.length) {
|
if (records && records.length) {
|
||||||
records.forEach((r) => {
|
records.forEach((r) => {
|
||||||
const d = parseInt(r.date.split('-')[2])
|
// 先用 util.dateOnly 归一化日期(兼容 "YYYY-MM-DD HH:MM:SS" / ISO 等),
|
||||||
|
// 再稳定地取出「日」,避免直接 split('-')[2] 取到 "DD HH:MM:SS" 靠 parseInt 侥幸解析的脆弱写法
|
||||||
|
const d = parseInt(util.dateOnly(r.date).split('-')[2], 10)
|
||||||
recordMap[d] = (recordMap[d] || 0) + r.duration
|
recordMap[d] = (recordMap[d] || 0) + r.duration
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const { getBarPercent } = require('../../utils/util')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trend bar chart - pure CSS (no canvas), matches the calendar-heatmap's
|
* Trend bar chart - pure CSS (no canvas), matches the calendar-heatmap's
|
||||||
* rendering approach for consistency.
|
* rendering approach for consistency.
|
||||||
@@ -8,12 +10,13 @@
|
|||||||
* - valueText: formatted string shown above the bar (e.g. "1分30秒")
|
* - valueText: formatted string shown above the bar (e.g. "1分30秒")
|
||||||
* - highlight: truthy -> theme glow + bold label (today / this month)
|
* - highlight: truthy -> theme glow + bold label (today / this month)
|
||||||
*
|
*
|
||||||
* Bar height is value/max*100%, with an 8% floor for any non-zero value so
|
* Bar height is strictly proportional to value/max. Zero values render a
|
||||||
* short sessions stay visible. Zero values render a faint stub.
|
* faint stub so empty dates remain visible without distorting the data.
|
||||||
*/
|
*/
|
||||||
Component({
|
Component({
|
||||||
properties: {
|
properties: {
|
||||||
chartData: { type: Array, value: [] }
|
chartData: { type: Array, value: [] },
|
||||||
|
variant: { type: String, value: 'total' }
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
bars: []
|
bars: []
|
||||||
@@ -39,7 +42,7 @@ Component({
|
|||||||
value: v,
|
value: v,
|
||||||
valueText: (d && d.valueText) || '',
|
valueText: (d && d.valueText) || '',
|
||||||
highlight: !!(d && d.highlight),
|
highlight: !!(d && d.highlight),
|
||||||
percent: max > 0 ? Math.max((v / max) * 100, v > 0 ? 8 : 0) : 0
|
percent: getBarPercent(v, max)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.setData({ bars })
|
this.setData({ bars })
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<view class="trend-chart">
|
<view class="trend-chart {{variant === 'peak' ? 'peak' : ''}}">
|
||||||
<view class="trend-bars">
|
<view class="trend-bars">
|
||||||
<view class="trend-bar-col" wx:for="{{bars}}" wx:key="label">
|
<view class="trend-bar-col" wx:for="{{bars}}" wx:key="label">
|
||||||
<view class="trend-bar {{item.value === 0 ? 'zero' : ''}} {{item.highlight ? 'highlight' : ''}}" style="height: {{item.percent}}%;">
|
<view class="trend-bar {{item.value === 0 ? 'zero' : ''}} {{item.highlight ? 'highlight' : ''}}" style="height: {{item.percent}}%;">
|
||||||
|
|||||||
@@ -36,14 +36,40 @@
|
|||||||
box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.5);
|
box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trend-chart.peak .trend-bars {
|
||||||
|
height: 156rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-chart.peak .trend-bar {
|
||||||
|
width: 12rpx;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 8rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-chart.peak .trend-bar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -8rpx;
|
||||||
|
left: -4rpx;
|
||||||
|
width: 20rpx;
|
||||||
|
height: 20rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-chart.peak .trend-bar.zero::after {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
/* value caption floats above the bar so it never eats into bar height */
|
/* value caption floats above the bar so it never eats into bar height */
|
||||||
.trend-bar-value {
|
.trend-bar-value {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -28rpx;
|
top: -26rpx;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 18rpx;
|
font-size: 16rpx;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
Component({
|
Component({
|
||||||
options: { multipleSlots: true },
|
options: { multipleSlots: true },
|
||||||
properties: {
|
properties: {
|
||||||
variant: { type: String, value: '' }, // '' | 'soft' | 'in'
|
variant: { type: String, value: '' }, // '' | 'soft' | 'in' | 'gradient'
|
||||||
padded: { type: Boolean, value: true },
|
padded: { type: Boolean, value: true },
|
||||||
customStyle: { type: String, value: '' }
|
customStyle: { type: String, value: '' },
|
||||||
|
// 入场交错序号(0 起)。组件无从得知自己是页面里的第几张卡,:nth-child 在组件
|
||||||
|
// 内部又永远命中第 1 个,所以延迟只能由页面显式传入。>3 的一律钳到 3,避免
|
||||||
|
// 卡片多的页面(如设置页 7 张)最后一张要等 0.65s 才出现。
|
||||||
|
index: { type: Number, value: 0 }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
<view class="ui-card {{variant}} {{padded ? '' : 'ui-card--flush'}}" style="{{customStyle}}">
|
<!-- customStyle 写在最后,保证页面传入的样式能覆盖内联的 animation-delay -->
|
||||||
|
<view
|
||||||
|
class="ui-card {{variant}} {{padded ? '' : 'ui-card--flush'}}"
|
||||||
|
style="animation-delay:{{50 + (index > 3 ? 3 : (index > 0 ? index : 0)) * 100}}ms;{{customStyle}}"
|
||||||
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
|
/* 宿主节点必须显式声明 display —— 自定义组件宿主默认是 inline,而内部是 block。
|
||||||
|
inline 盒被 block 子元素打断后会碎成「前半段/后半段」两个零宽行盒,页面侧一旦
|
||||||
|
在 <ui-card> 标签上加 border / box-shadow,就只会画在这两个碎片上,看起来是
|
||||||
|
「角上冒出两根竖条」(首页 .just-completed 高亮踩过这个坑);transform 对非替换
|
||||||
|
inline 元素更是完全无效,缩放脉冲动画一次都不会跑。
|
||||||
|
margin-bottom 也一并上提到宿主:外边距归宿主、内边距归内部,页面侧加边框时
|
||||||
|
才不会把 24rpx 外边距一起圈进框里。 */
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.ui-card {
|
.ui-card {
|
||||||
background: var(--card-bg, #FFFFFF);
|
background: var(--card-bg, #FFFFFF);
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
padding: 32rpx;
|
padding: 32rpx;
|
||||||
margin-bottom: 24rpx;
|
|
||||||
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06);
|
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -41,16 +53,14 @@
|
|||||||
--on-primary: #FFFFFF;
|
--on-primary: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Staggered entrance animation */
|
/* Staggered entrance animation.
|
||||||
|
交错延迟由页面传入的 index 换算成内联 animation-delay(见 ui-card.wxml),
|
||||||
|
不能用 :nth-child —— .ui-card 在组件内部永远是根节点下唯一的第 1 个子元素,
|
||||||
|
恒匹配 nth-child(1),所有卡片延迟都会是同一个值,压根不交错。 */
|
||||||
.ui-card.in {
|
.ui-card.in {
|
||||||
animation: uiCardIn 0.45s ease both;
|
animation: uiCardIn 0.45s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-card.in:nth-child(1) { animation-delay: 0.05s; }
|
|
||||||
.ui-card.in:nth-child(2) { animation-delay: 0.15s; }
|
|
||||||
.ui-card.in:nth-child(3) { animation-delay: 0.25s; }
|
|
||||||
.ui-card.in:nth-child(4) { animation-delay: 0.35s; }
|
|
||||||
|
|
||||||
@keyframes uiCardIn {
|
@keyframes uiCardIn {
|
||||||
from { opacity: 0; transform: translateY(40rpx); }
|
from { opacity: 0; transform: translateY(40rpx); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
*/
|
*/
|
||||||
module.exports = {
|
module.exports = {
|
||||||
/** 应用版本号,外显在设置页「关于」 */
|
/** 应用版本号,外显在设置页「关于」 */
|
||||||
version: 'v3.02',
|
version: 'v3.1',
|
||||||
|
|
||||||
/** 最后更新日期,外显在设置页「关于」 */
|
/** 最后更新日期,外显在设置页「关于」 */
|
||||||
updatedAt: '2026-07-29',
|
updatedAt: '2026-08-03',
|
||||||
|
|
||||||
/** 开发者名称 / 微信号,设置页点击可复制 */
|
/** 开发者名称 / 微信号,设置页点击可复制 */
|
||||||
developer: '三口一瓶',
|
developer: '三口一瓶',
|
||||||
@@ -21,6 +21,12 @@ module.exports = {
|
|||||||
/** 排行榜最大显示人数 */
|
/** 排行榜最大显示人数 */
|
||||||
leaderboardMaxRank: 50,
|
leaderboardMaxRank: 50,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 耐力榜领奖台顶部文案。哲理注脚,点出"撑得越久不一定越好"的价值观,
|
||||||
|
* 与耐力榜(比谁单次撑最久)形成反差。仅耐力榜领奖台顶部显示,改这里即可。
|
||||||
|
*/
|
||||||
|
leaderboardPodiumSlogan: '平板支撑不是一场和时间的恋爱,撑得越久,不一定越好。真正重要的,是每一秒都没有辜负身体。',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 语音合成(TTS)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。
|
* 语音合成(TTS)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。
|
||||||
* 设置页可选男声/女声,客户端按选择取下面对应的一组传给 tts 云函数。
|
* 设置页可选男声/女声,客户端按选择取下面对应的一组传给 tts 云函数。
|
||||||
@@ -56,8 +62,8 @@ module.exports = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 累计训练天数里程碑勋章,按累计去重训练日(可中断,非连续打卡)解锁。
|
* 累计训练天数里程碑勋章,按累计去重训练日(可中断,非连续打卡)解锁。
|
||||||
* 记录页用里程碑进度条展示,节点按 sqrt 感知映射分布(早期里程碑更显眼),
|
* 记录页用等距里程碑进度条展示,每个连接段按当前阶段进度填充为绿色。
|
||||||
* 填充与节点同映射,解锁时填充正好抵达该节点、满级时满格。增删改这里即可,
|
* 解锁下一枚勋章时,对应连接段恰好填满。增删改这里即可,
|
||||||
* 口径=累计去重训练日(可中断);name 仅作趣味命名,不暗示连续打卡。
|
* 口径=累计去重训练日(可中断);name 仅作趣味命名,不暗示连续打卡。
|
||||||
*/
|
*/
|
||||||
trainingDayBadges: [
|
trainingDayBadges: [
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# IconPark 勋章视觉替换设计
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
替换记录页训练勋章的现有通用图标,使其形成更清晰的成长叙事,同时保留主题换色、解锁状态、勋章点击说明和现有进度计算。
|
||||||
|
|
||||||
|
## 已确认的视觉方向
|
||||||
|
|
||||||
|
采用 IconPark 的彩色填充风格(预览方案 A)。图标序列为:火焰、闪电、徽章、奖杯、钻石、星耀、皇冠。
|
||||||
|
|
||||||
|
- 前三枚已解锁图标使用暖橙、金黄、主题绿的温和递进,强调早期坚持。
|
||||||
|
- 第四至第七枚解锁图标使用当前主题色,保持换肤一致性。
|
||||||
|
- 未解锁图标沿用浅灰描边圆形节点。
|
||||||
|
- 图标在本地以 SVG path 形式嵌入既有 `utils/icons.js`,不添加 npm 包、远程请求或新的运行时模块。
|
||||||
|
|
||||||
|
## 数据和交互
|
||||||
|
|
||||||
|
`config.trainingDayBadges` 继续定义天数、名称和图标键;记录页进度、逐段填充、解锁庆祝和点击详情均不改变。
|
||||||
|
|
||||||
|
图标构建器新增每枚勋章的彩色/白色变体:节点解锁时使用彩色图标,主题节点上的图标保持白色以保证对比度;未解锁时使用灰色图标。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 为图标构建结果补充单元测试,验证七枚勋章的键均存在。
|
||||||
|
- 运行完整 Node 测试与 JavaScript 语法检查。
|
||||||
|
- 在微信开发者工具检查默认主题及蓝、绿主题下的记录页:节点、文字、进度段和点击说明均可见。
|
||||||
|
|
||||||
|
## 授权
|
||||||
|
|
||||||
|
图标来源为 ByteDance IconPark,Apache-2.0;项目保留来源和许可证说明。
|
||||||
+61
-7
@@ -15,7 +15,6 @@ Page({
|
|||||||
todayDone: false,
|
todayDone: false,
|
||||||
todayDuration: 0,
|
todayDuration: 0,
|
||||||
todayTargetText: '',
|
todayTargetText: '',
|
||||||
homeTip: '',
|
|
||||||
// Ring number font size in rpx. WeChat auto-scales text by the user's
|
// Ring number font size in rpx. WeChat auto-scales text by the user's
|
||||||
// font-size setting (fontSizeScaleFactor) but does NOT scale rpx box
|
// font-size setting (fontSizeScaleFactor) but does NOT scale rpx box
|
||||||
// dimensions — so on a real device with enlarged system font the 84rpx
|
// dimensions — so on a real device with enlarged system font the 84rpx
|
||||||
@@ -35,6 +34,12 @@ Page({
|
|||||||
presets: [30, 60, 90, 120, 180, 300],
|
presets: [30, 60, 90, 120, 180, 300],
|
||||||
customDuration: 60,
|
customDuration: 60,
|
||||||
customInput: '',
|
customInput: '',
|
||||||
|
// --- Circuit (循环训练) config panel (persisted via storage.saveCircuitConfig) ---
|
||||||
|
showCircuitPicker: false,
|
||||||
|
cHold: 30,
|
||||||
|
cSets: 4,
|
||||||
|
cRest: 15,
|
||||||
|
cSessions: 3,
|
||||||
icons: iconsMod.build()
|
icons: iconsMod.build()
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -148,10 +153,6 @@ Page({
|
|||||||
// 分段格子(每天一格)只在计划天数 <= 10 时用;更长则退化为里程碑连续条,避免格子过密
|
// 分段格子(每天一格)只在计划天数 <= 10 时用;更长则退化为里程碑连续条,避免格子过密
|
||||||
const useSegments = plan.totalDays > 0 && plan.totalDays <= 10
|
const useSegments = plan.totalDays > 0 && plan.totalDays <= 10
|
||||||
const planDays = useSegments ? Array.from({ length: plan.totalDays }, (_, i) => i) : []
|
const planDays = useSegments ? Array.from({ length: plan.totalDays }, (_, i) => i) : []
|
||||||
// 首页姿势要领:从 config.postureTips 随机取一条,每次刷新都换,
|
|
||||||
// 让用户反复进首页能看到不同要领。配置为空时回退到默认文案,避免空白。
|
|
||||||
const tips = config.postureTips || []
|
|
||||||
const homeTip = tips.length ? tips[Math.floor(Math.random() * tips.length)] : '保持身体成一条直线,核心收紧,均匀呼吸'
|
|
||||||
this.setData({
|
this.setData({
|
||||||
theme,
|
theme,
|
||||||
icons: iconsMod.build(theme),
|
icons: iconsMod.build(theme),
|
||||||
@@ -165,8 +166,7 @@ Page({
|
|||||||
planTotal: plan.totalDays,
|
planTotal: plan.totalDays,
|
||||||
planProgress,
|
planProgress,
|
||||||
useSegments,
|
useSegments,
|
||||||
planDays,
|
planDays
|
||||||
homeTip
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -212,6 +212,60 @@ Page({
|
|||||||
wx.navigateTo({ url: `/pages/timer/timer?free=${dur}` })
|
wx.navigateTo({ url: `/pages/timer/timer?free=${dur}` })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 循环训练入口:打开配置弹窗,预填上次保存的设置(getCircuitConfig 含默认值
|
||||||
|
* + 校验),用户改完确认后持久化并跳计时器。设置因此被保存下来——下次打开直接
|
||||||
|
* 沿用,无需重填。
|
||||||
|
*/
|
||||||
|
onCircuitTrain() {
|
||||||
|
const cfg = storage.getCircuitConfig()
|
||||||
|
this.setData({
|
||||||
|
showCircuitPicker: true,
|
||||||
|
cHold: cfg.holdPerSet,
|
||||||
|
cSets: cfg.sets,
|
||||||
|
cRest: cfg.restPerSet,
|
||||||
|
cSessions: cfg.sessionsPerWeek
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onCloseCircuitPicker() {
|
||||||
|
this.setData({ showCircuitPicker: false })
|
||||||
|
},
|
||||||
|
|
||||||
|
onCircuitStep(e) {
|
||||||
|
const field = e.currentTarget.dataset.field
|
||||||
|
const delta = Number(e.currentTarget.dataset.delta) || 0
|
||||||
|
const keyMap = { hold: 'cHold', sets: 'cSets', rest: 'cRest', sessions: 'cSessions' }
|
||||||
|
const key = keyMap[field]
|
||||||
|
if (!key) return
|
||||||
|
const bounds = {
|
||||||
|
hold: [5, 600],
|
||||||
|
sets: [1, 50],
|
||||||
|
rest: [0, 600],
|
||||||
|
sessions: [1, 14]
|
||||||
|
}
|
||||||
|
let v = this.data[key] + delta
|
||||||
|
const min = bounds[field][0]
|
||||||
|
const max = bounds[field][1]
|
||||||
|
if (v < min) v = min
|
||||||
|
if (v > max) v = max
|
||||||
|
this.setData({ [key]: v })
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirmCircuit() {
|
||||||
|
const cfg = {
|
||||||
|
holdPerSet: this.data.cHold,
|
||||||
|
sets: this.data.cSets,
|
||||||
|
restPerSet: this.data.cRest,
|
||||||
|
sessionsPerWeek: this.data.cSessions
|
||||||
|
}
|
||||||
|
storage.saveCircuitConfig(cfg)
|
||||||
|
this.setData({ showCircuitPicker: false })
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/pages/timer/timer?circuit=1&hold=${cfg.holdPerSet}&sets=${cfg.sets}&rest=${cfg.restPerSet}&sessions=${cfg.sessionsPerWeek}`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分享给朋友。微信在用户点击右上角"···"菜单里的"转发给朋友"、
|
* 分享给朋友。微信在用户点击右上角"···"菜单里的"转发给朋友"、
|
||||||
* 或者页面内任何 `<button open-type="share">` / `<button open-type="share">`
|
* 或者页面内任何 `<button open-type="share">` / `<button open-type="share">`
|
||||||
|
|||||||
+82
-17
@@ -10,7 +10,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 连续打卡卡片:火焰连胜 + 分段进度 -->
|
<!-- 连续打卡卡片:火焰连胜 + 分段进度 -->
|
||||||
<ui-card variant="gradient in" class="{{justCompleted ? 'just-completed' : ''}}">
|
<ui-card variant="gradient in" index="0" class="{{justCompleted ? 'just-completed' : ''}}">
|
||||||
<view class="header-card">
|
<view class="header-card">
|
||||||
<!-- 上区:连胜视觉重心 + 计划徽章 -->
|
<!-- 上区:连胜视觉重心 + 计划徽章 -->
|
||||||
<view class="streak-row">
|
<view class="streak-row">
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
<!-- 今日目标卡片 -->
|
<!-- 今日目标卡片 -->
|
||||||
<ui-card variant="in">
|
<ui-card variant="in" index="1">
|
||||||
<view class="target-section">
|
<view class="target-section">
|
||||||
<view class="target-header">
|
<view class="target-header">
|
||||||
<image class="section-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
|
<image class="section-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
|
||||||
@@ -89,25 +89,33 @@
|
|||||||
text="{{todayDone ? '再次训练' : '开始训练'}}"
|
text="{{todayDone ? '再次训练' : '开始训练'}}"
|
||||||
icon-src="{{icons.playWhite}}"
|
icon-src="{{icons.playWhite}}"
|
||||||
bindtap="onStartTrain"
|
bindtap="onStartTrain"
|
||||||
|
custom-style="border-radius:16rpx;"
|
||||||
></ui-btn>
|
></ui-btn>
|
||||||
|
|
||||||
<ui-btn
|
<view class="action-row">
|
||||||
variant="ghost"
|
<ui-btn
|
||||||
size="md"
|
class="action-cell"
|
||||||
block
|
variant="ghost"
|
||||||
text="自由训练 · 自定义时长"
|
size="xl"
|
||||||
icon-src="{{icons.timeFill}}"
|
block
|
||||||
bindtap="onFreeTrain"
|
text="自由训练"
|
||||||
></ui-btn>
|
icon-src="{{icons.timeFill}}"
|
||||||
</view>
|
bindtap="onFreeTrain"
|
||||||
|
custom-style="border-radius:16rpx;"
|
||||||
|
></ui-btn>
|
||||||
|
|
||||||
<!-- 小提示 -->
|
<ui-btn
|
||||||
<ui-card variant="soft" padded="{{false}}">
|
class="action-cell"
|
||||||
<view class="tip-card">
|
variant="ghost"
|
||||||
<image class="tip-icon" src="{{icons.lightFill}}" mode="aspectFit"></image>
|
size="xl"
|
||||||
<text class="tip-text">{{homeTip}}</text>
|
block
|
||||||
|
text="循环训练"
|
||||||
|
icon-src="{{icons.repeatFill}}"
|
||||||
|
bindtap="onCircuitTrain"
|
||||||
|
custom-style="border-radius:16rpx;"
|
||||||
|
></ui-btn>
|
||||||
</view>
|
</view>
|
||||||
</ui-card>
|
</view>
|
||||||
|
|
||||||
<!-- 时间选择弹窗 -->
|
<!-- 时间选择弹窗 -->
|
||||||
<ui-modal
|
<ui-modal
|
||||||
@@ -147,7 +155,64 @@
|
|||||||
block
|
block
|
||||||
text="开始自由训练"
|
text="开始自由训练"
|
||||||
bindtap="onConfirmFree"
|
bindtap="onConfirmFree"
|
||||||
|
custom-style="border-radius:16rpx;"
|
||||||
></ui-btn>
|
></ui-btn>
|
||||||
<text class="picker-cancel" bindtap="onClosePicker">取消</text>
|
<text class="picker-cancel" bindtap="onClosePicker">取消</text>
|
||||||
</ui-modal>
|
</ui-modal>
|
||||||
|
|
||||||
|
<!-- 循环训练配置弹窗:每组时长 / 组数 / 休息 / 每周次数,保存后持久化 -->
|
||||||
|
<ui-modal
|
||||||
|
visible="{{showCircuitPicker}}"
|
||||||
|
position="bottom"
|
||||||
|
bind:close="onCloseCircuitPicker"
|
||||||
|
>
|
||||||
|
<text class="picker-title">循环训练设置</text>
|
||||||
|
<text class="picker-sub">自定义每组时长、组数与组间休息</text>
|
||||||
|
|
||||||
|
<view class="circuit-row">
|
||||||
|
<text class="circuit-row-label">每组时长</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<view class="stepper-btn" data-field="hold" data-delta="-5" bindtap="onCircuitStep">−</view>
|
||||||
|
<text class="stepper-val">{{cHold}}秒</text>
|
||||||
|
<view class="stepper-btn" data-field="hold" data-delta="5" bindtap="onCircuitStep">+</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="circuit-row">
|
||||||
|
<text class="circuit-row-label">组数</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<view class="stepper-btn" data-field="sets" data-delta="-1" bindtap="onCircuitStep">−</view>
|
||||||
|
<text class="stepper-val">{{cSets}}组</text>
|
||||||
|
<view class="stepper-btn" data-field="sets" data-delta="1" bindtap="onCircuitStep">+</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="circuit-row">
|
||||||
|
<text class="circuit-row-label">组间休息</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<view class="stepper-btn" data-field="rest" data-delta="-5" bindtap="onCircuitStep">−</view>
|
||||||
|
<text class="stepper-val">{{cRest}}秒</text>
|
||||||
|
<view class="stepper-btn" data-field="rest" data-delta="5" bindtap="onCircuitStep">+</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="circuit-row">
|
||||||
|
<text class="circuit-row-label">每周目标</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<view class="stepper-btn" data-field="sessions" data-delta="-1" bindtap="onCircuitStep">−</view>
|
||||||
|
<text class="stepper-val">{{cSessions}}次/周</text>
|
||||||
|
<view class="stepper-btn" data-field="sessions" data-delta="1" bindtap="onCircuitStep">+</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<ui-btn
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
text="保存并开始"
|
||||||
|
bindtap="onConfirmCircuit"
|
||||||
|
custom-style="border-radius:16rpx;"
|
||||||
|
></ui-btn>
|
||||||
|
<text class="picker-cancel" bindtap="onCloseCircuitPicker">取消</text>
|
||||||
|
</ui-modal>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
+71
-20
@@ -313,27 +313,15 @@
|
|||||||
margin: 8rpx 0 8rpx;
|
margin: 8rpx 0 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- tip card ---- */
|
/* 自由训练 / 循环训练 并排一行 */
|
||||||
.tip-card {
|
.action-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
gap: 16rpx;
|
||||||
padding: 24rpx 32rpx;
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tip-icon {
|
.action-row .action-cell {
|
||||||
width: 32rpx;
|
flex: 1;
|
||||||
height: 32rpx;
|
min-width: 0; /* 防止图标+文字在窄屏撑破 flex 项 */
|
||||||
margin-right: 12rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-text {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- picker ---- */
|
/* ---- picker ---- */
|
||||||
@@ -427,11 +415,74 @@
|
|||||||
margin-top: 8rpx;
|
margin-top: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- circuit (循环训练) config panel ---- */
|
||||||
|
.picker-sub {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: -18rpx 0 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circuit-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx 8rpx;
|
||||||
|
border-bottom: 1rpx solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.circuit-row-label {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-btn {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
transition: transform 0.12s ease, opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-btn:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-val {
|
||||||
|
min-width: 124rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- 训练完成高亮 (3 秒后淡出) ---- */
|
/* ---- 训练完成高亮 (3 秒后淡出) ---- */
|
||||||
|
/* 这个 class 落在 <ui-card> 宿主节点上(ui-card.wxss 已把宿主设为 display:block,
|
||||||
|
否则 border/box-shadow 只会碎成两根竖条、transform 完全不生效)。
|
||||||
|
描边用 box-shadow 的 spread 而非 border:border 会撑大宿主 8rpx,高亮出现/消失
|
||||||
|
时整页布局跳动;box-shadow 不占布局空间。宿主自身无圆角,必须补上与内部卡片
|
||||||
|
一致的 24rpx,否则描边会是直角框套在圆角卡片外面。 */
|
||||||
.just-completed {
|
.just-completed {
|
||||||
animation: justCompletedPulse 1s ease-in-out 2;
|
animation: justCompletedPulse 1s ease-in-out 2;
|
||||||
border: 4rpx solid var(--primary) !important;
|
border-radius: 24rpx;
|
||||||
box-shadow: 0 4rpx 24rpx rgba(var(--primary-rgb), 0.3) !important;
|
box-shadow: 0 0 0 4rpx var(--primary), 0 4rpx 24rpx rgba(var(--primary-rgb), 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes justCompletedPulse {
|
@keyframes justCompletedPulse {
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ Page({
|
|||||||
theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' },
|
theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' },
|
||||||
themeStyle: themeMod.BASE_VARS,
|
themeStyle: themeMod.BASE_VARS,
|
||||||
periods: [
|
periods: [
|
||||||
{ key: 'day', label: '日榜' },
|
{ key: 'day', label: '日榜', maxRank: config.leaderboardMaxRank },
|
||||||
{ key: 'month', label: '月榜' },
|
{ key: 'month', label: '月榜', maxRank: config.leaderboardMaxRank },
|
||||||
{ key: 'year', label: '年榜' }
|
{ key: 'year', label: '年榜', maxRank: config.leaderboardMaxRank },
|
||||||
|
{ key: 'endurance', label: '耐力', maxRank: 10 }
|
||||||
],
|
],
|
||||||
activePeriod: 'day',
|
activePeriod: 'day',
|
||||||
|
podiumSlogan: config.leaderboardPodiumSlogan,
|
||||||
rankedList: [],
|
rankedList: [],
|
||||||
myEntry: null,
|
myEntry: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -68,6 +70,8 @@ Page({
|
|||||||
this._fetchSeq = (this._fetchSeq || 0) + 1
|
this._fetchSeq = (this._fetchSeq || 0) + 1
|
||||||
const seq = this._fetchSeq
|
const seq = this._fetchSeq
|
||||||
const period = this.data.activePeriod
|
const period = this.data.activePeriod
|
||||||
|
const periodObj = this.data.periods.find(p => p.key === period)
|
||||||
|
const maxRank = (periodObj && periodObj.maxRank) || config.leaderboardMaxRank
|
||||||
|
|
||||||
// 乐观缓存:命中该 period 的近期缓存就先秒显旧数据,后台再静默拉新。
|
// 乐观缓存:命中该 period 的近期缓存就先秒显旧数据,后台再静默拉新。
|
||||||
// 命中时不进 loading 骨架屏(避免旧数据被空白覆盖),用 refreshing 标记
|
// 命中时不进 loading 骨架屏(避免旧数据被空白覆盖),用 refreshing 标记
|
||||||
@@ -91,7 +95,7 @@ Page({
|
|||||||
name: 'leaderboard',
|
name: 'leaderboard',
|
||||||
data: {
|
data: {
|
||||||
period,
|
period,
|
||||||
maxRank: config.leaderboardMaxRank,
|
maxRank,
|
||||||
force: !!force
|
force: !!force
|
||||||
}
|
}
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
@@ -116,15 +120,35 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
_applyResult(result) {
|
_applyResult(result) {
|
||||||
const list = (result.ranked || []).map(item => ({
|
const isEndurance = this.data.activePeriod === 'endurance'
|
||||||
...item,
|
const list = (result.ranked || []).map(item => {
|
||||||
durationText: util.formatDuration(item.duration)
|
const base = { ...item, durationText: util.formatDuration(item.duration) }
|
||||||
}))
|
if (isEndurance) {
|
||||||
const myEntry = result.myEntry ? {
|
const { date, time } = this._splitBestDate(item.bestDate)
|
||||||
...result.myEntry,
|
base.subText = this._formatBestDate(item.bestDate)
|
||||||
durationText: util.formatDuration(result.myEntry.duration),
|
base.subDate = date
|
||||||
isMe: true
|
base.subTime = time
|
||||||
} : null
|
} else {
|
||||||
|
base.subText = `${item.sessions}次`
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
})
|
||||||
|
const myEntry = result.myEntry ? (() => {
|
||||||
|
const base = {
|
||||||
|
...result.myEntry,
|
||||||
|
durationText: util.formatDuration(result.myEntry.duration),
|
||||||
|
isMe: true
|
||||||
|
}
|
||||||
|
if (isEndurance) {
|
||||||
|
const { date, time } = this._splitBestDate(result.myEntry.bestDate)
|
||||||
|
base.subText = this._formatBestDate(result.myEntry.bestDate)
|
||||||
|
base.subDate = date
|
||||||
|
base.subTime = time
|
||||||
|
} else {
|
||||||
|
base.subText = `${result.myEntry.sessions}次`
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
})() : null
|
||||||
const empty = list.length === 0 && !myEntry
|
const empty = list.length === 0 && !myEntry
|
||||||
this.setData({
|
this.setData({
|
||||||
rankedList: list,
|
rankedList: list,
|
||||||
@@ -141,11 +165,34 @@ Page({
|
|||||||
_applyLocal() {
|
_applyLocal() {
|
||||||
const period = this.data.activePeriod
|
const period = this.data.activePeriod
|
||||||
const allRecords = Object.values(storage.getRecords()).flat()
|
const allRecords = Object.values(storage.getRecords()).flat()
|
||||||
|
const profile = storage.getProfile()
|
||||||
|
const localName = profile.nickname || '我'
|
||||||
|
|
||||||
|
// 耐力榜本地兜底:取本地全部记录里单次最久 + 其日期
|
||||||
|
if (period === 'endurance') {
|
||||||
|
let bestDuration = 0
|
||||||
|
let bestDate = ''
|
||||||
|
allRecords.forEach(r => {
|
||||||
|
const dur = Number(r.duration) || 0
|
||||||
|
if (dur > bestDuration) { bestDuration = dur; bestDate = r.date }
|
||||||
|
})
|
||||||
|
if (bestDuration > 0) {
|
||||||
|
const { date, time } = this._splitBestDate(bestDate)
|
||||||
|
const entry = {
|
||||||
|
rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '',
|
||||||
|
duration: bestDuration, durationText: util.formatDuration(bestDuration),
|
||||||
|
bestDate, subText: this._formatBestDate(bestDate), subDate: date, subTime: time
|
||||||
|
}
|
||||||
|
this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false })
|
||||||
|
} else {
|
||||||
|
this.setData({ loading: false, refreshing: false, empty: true })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const today = storage.getToday()
|
const today = storage.getToday()
|
||||||
const thisMonth = today.substring(0, 7)
|
const thisMonth = today.substring(0, 7)
|
||||||
const thisYear = String(new Date().getFullYear())
|
const thisYear = String(new Date().getFullYear())
|
||||||
const profile = storage.getProfile()
|
|
||||||
const localName = profile.nickname || '我'
|
|
||||||
|
|
||||||
let duration = 0
|
let duration = 0
|
||||||
let sessions = 0
|
let sessions = 0
|
||||||
@@ -158,7 +205,8 @@ Page({
|
|||||||
if (duration > 0) {
|
if (duration > 0) {
|
||||||
const entry = {
|
const entry = {
|
||||||
rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '',
|
rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '',
|
||||||
duration, durationText: util.formatDuration(duration), sessions
|
duration, durationText: util.formatDuration(duration), sessions,
|
||||||
|
subText: `${sessions}次`
|
||||||
}
|
}
|
||||||
this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false })
|
this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false })
|
||||||
} else {
|
} else {
|
||||||
@@ -166,6 +214,26 @@ Page({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把记录里的 "YYYY-MM-DD HH:MM:SS" 截成 "YYYY-MM-DD HH:MM"(去掉秒),
|
||||||
|
* 纯日期串原样返回。耐力榜用其展示单次的"产生时间"。
|
||||||
|
*/
|
||||||
|
_formatBestDate(s) {
|
||||||
|
if (!s || typeof s !== 'string') return ''
|
||||||
|
if (s.length >= 16) return s.substring(0, 16)
|
||||||
|
return s
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 "YYYY-MM-DD HH:MM"(来自 _formatBestDate)拆成
|
||||||
|
* { date: 'YYYY-MM-DD', time: 'HH:MM' },供领奖台底座两行展示耐力榜的"单次日期"。
|
||||||
|
*/
|
||||||
|
_splitBestDate(s) {
|
||||||
|
const f = this._formatBestDate(s)
|
||||||
|
if (f && f.length >= 11) return { date: f.substring(0, 10), time: f.substring(11) }
|
||||||
|
return { date: f || '', time: '' }
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分享给朋友 — 排行榜页。把用户当前名次写进标题,有"攀比"属性,
|
* 分享给朋友 — 排行榜页。把用户当前名次写进标题,有"攀比"属性,
|
||||||
* 分享欲望会强一些。
|
* 分享欲望会强一些。
|
||||||
@@ -175,7 +243,7 @@ Page({
|
|||||||
const s = config.share.leaderboard
|
const s = config.share.leaderboard
|
||||||
// myEntry.rank 依赖云函数返回,缺失时回退静态 title,避免出现"排第 undefined"
|
// myEntry.rank 依赖云函数返回,缺失时回退静态 title,避免出现"排第 undefined"
|
||||||
if (me && me.rank) {
|
if (me && me.rank) {
|
||||||
const periodLabel = { day: '日', month: '月', year: '年' }[this.data.activePeriod] || ''
|
const periodLabel = { day: '日', month: '月', year: '年', endurance: '耐力' }[this.data.activePeriod] || ''
|
||||||
return {
|
return {
|
||||||
title: s.titleTemplate.replace('{period}', periodLabel).replace('{rank}', me.rank),
|
title: s.titleTemplate.replace('{period}', periodLabel).replace('{rank}', me.rank),
|
||||||
path: s.path
|
path: s.path
|
||||||
|
|||||||
@@ -41,6 +41,9 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<block wx:else>
|
<block wx:else>
|
||||||
|
<!-- 耐力榜领奖台顶部文案:哲理注脚,点出"撑得越久不一定越好" -->
|
||||||
|
<view class="podium-slogan" wx:if="{{activePeriod === 'endurance'}}">{{podiumSlogan}}</view>
|
||||||
|
|
||||||
<!-- Podium: top 3 (only when there are at least 3 entries) -->
|
<!-- Podium: top 3 (only when there are at least 3 entries) -->
|
||||||
<view class="podium" wx:if="{{rankedList.length >= 3}}">
|
<view class="podium" wx:if="{{rankedList.length >= 3}}">
|
||||||
<!-- 2nd -->
|
<!-- 2nd -->
|
||||||
@@ -51,7 +54,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<text class="podium-name">{{rankedList[1].name}}</text>
|
<text class="podium-name">{{rankedList[1].name}}</text>
|
||||||
<text class="podium-dur">{{rankedList[1].durationText}}</text>
|
<text class="podium-dur">{{rankedList[1].durationText}}</text>
|
||||||
<view class="podium-base podium-base--second"><text class="podium-base__label">{{rankedList[1].sessions}}次</text></view>
|
<view class="podium-base podium-base--second"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[1].subDate}}</text><text class="podium-base__time">{{rankedList[1].subTime}}</text></block><text wx:else>{{rankedList[1].subText}}</text></view></view>
|
||||||
</view>
|
</view>
|
||||||
<!-- 1st -->
|
<!-- 1st -->
|
||||||
<view class="podium-slot podium-slot--first rank-item--enter" style="animation-delay: 0ms;">
|
<view class="podium-slot podium-slot--first rank-item--enter" style="animation-delay: 0ms;">
|
||||||
@@ -62,7 +65,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<text class="podium-name podium-name--first">{{rankedList[0].name}}</text>
|
<text class="podium-name podium-name--first">{{rankedList[0].name}}</text>
|
||||||
<text class="podium-dur podium-dur--first">{{rankedList[0].durationText}}</text>
|
<text class="podium-dur podium-dur--first">{{rankedList[0].durationText}}</text>
|
||||||
<view class="podium-base podium-base--first"><text class="podium-base__label">{{rankedList[0].sessions}}次</text></view>
|
<view class="podium-base podium-base--first"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[0].subDate}}</text><text class="podium-base__time">{{rankedList[0].subTime}}</text></block><text wx:else>{{rankedList[0].subText}}</text></view></view>
|
||||||
</view>
|
</view>
|
||||||
<!-- 3rd -->
|
<!-- 3rd -->
|
||||||
<view class="podium-slot podium-slot--third rank-item--enter" style="animation-delay: 120ms;">
|
<view class="podium-slot podium-slot--third rank-item--enter" style="animation-delay: 120ms;">
|
||||||
@@ -72,7 +75,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<text class="podium-name">{{rankedList[2].name}}</text>
|
<text class="podium-name">{{rankedList[2].name}}</text>
|
||||||
<text class="podium-dur">{{rankedList[2].durationText}}</text>
|
<text class="podium-dur">{{rankedList[2].durationText}}</text>
|
||||||
<view class="podium-base podium-base--third"><text class="podium-base__label">{{rankedList[2].sessions}}次</text></view>
|
<view class="podium-base podium-base--third"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[2].subDate}}</text><text class="podium-base__time">{{rankedList[2].subTime}}</text></block><text wx:else>{{rankedList[2].subText}}</text></view></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -91,7 +94,7 @@
|
|||||||
<view class="avatar avatar--sm {{item.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{item.avatarUrl || icons.peopleFill}}" mode="{{item.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
|
<view class="avatar avatar--sm {{item.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{item.avatarUrl || icons.peopleFill}}" mode="{{item.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
|
||||||
<view class="rank-info">
|
<view class="rank-info">
|
||||||
<text class="rank-name">{{item.name}}</text>
|
<text class="rank-name">{{item.name}}</text>
|
||||||
<text class="rank-sessions">{{item.sessions}}次</text>
|
<text class="rank-sessions">{{item.subText}}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="rank-dur">{{item.durationText}}</text>
|
<text class="rank-dur">{{item.durationText}}</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -104,7 +107,7 @@
|
|||||||
<view class="avatar avatar--sm my-bar__avatar {{myEntry.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{myEntry.avatarUrl || icons.peopleFill}}" mode="{{myEntry.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
|
<view class="avatar avatar--sm my-bar__avatar {{myEntry.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{myEntry.avatarUrl || icons.peopleFill}}" mode="{{myEntry.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
|
||||||
<view class="rank-info">
|
<view class="rank-info">
|
||||||
<text class="rank-name">我 ({{myEntry.name}})</text>
|
<text class="rank-name">我 ({{myEntry.name}})</text>
|
||||||
<text class="rank-sessions">{{myEntry.sessions}}次</text>
|
<text class="rank-sessions">{{myEntry.subText}}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="rank-dur">{{myEntry.durationText}}</text>
|
<text class="rank-dur">{{myEntry.durationText}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -69,6 +69,23 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 耐力榜领奖台顶部文案:哲理注脚,主色呼应 + 金句卡片质感 */
|
||||||
|
.podium-slogan {
|
||||||
|
position: relative;
|
||||||
|
margin: 8rpx 32rpx 4rpx;
|
||||||
|
padding: 22rpx 28rpx 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
|
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.10), rgba(var(--primary-rgb), 0.03));
|
||||||
|
border: 1rpx solid rgba(var(--primary-rgb), 0.16);
|
||||||
|
border-radius: 18rpx;
|
||||||
|
box-shadow: 0 4rpx 14rpx rgba(var(--primary-rgb), 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- podium (top 3) ---- */
|
/* ---- podium (top 3) ---- */
|
||||||
.podium {
|
.podium {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -194,14 +211,32 @@
|
|||||||
stepped base heights (the podium itself is bottom-aligned via flex-end). */
|
stepped base heights (the podium itself is bottom-aligned via flex-end). */
|
||||||
.podium-base__label {
|
.podium-base__label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 4rpx;
|
||||||
right: 0;
|
right: 4rpx;
|
||||||
bottom: 10rpx;
|
bottom: 6rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
line-height: 1;
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 耐力榜副标签(单次日期)在领奖台底座上拆成「日期 + 时间」两行。
|
||||||
|
缩小字号 + 抬高最矮的第 3 名底座(48→64rpx),三阶统一为 96/80/64,
|
||||||
|
让两行文字在各级底座内都垂直居中、不贴顶(含微信字体缩放余量)。 */
|
||||||
|
.podium-base__date {
|
||||||
|
font-size: 18rpx;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
.podium-base__time {
|
||||||
|
font-size: 16rpx;
|
||||||
|
line-height: 1.1;
|
||||||
|
opacity: 0.85;
|
||||||
|
margin-top: 2rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.podium-dur {
|
.podium-dur {
|
||||||
@@ -232,12 +267,12 @@
|
|||||||
box-shadow: 0 6rpx 18rpx rgba(250, 140, 22, 0.30), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45);
|
box-shadow: 0 6rpx 18rpx rgba(250, 140, 22, 0.30), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45);
|
||||||
}
|
}
|
||||||
.podium-base--second {
|
.podium-base--second {
|
||||||
height: 64rpx;
|
height: 80rpx;
|
||||||
background: linear-gradient(180deg, rgba(201, 205, 212, 0.50), rgba(140, 140, 140, 0.16));
|
background: linear-gradient(180deg, rgba(201, 205, 212, 0.50), rgba(140, 140, 140, 0.16));
|
||||||
box-shadow: 0 6rpx 18rpx rgba(140, 140, 140, 0.22), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45);
|
box-shadow: 0 6rpx 18rpx rgba(140, 140, 140, 0.22), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45);
|
||||||
}
|
}
|
||||||
.podium-base--third {
|
.podium-base--third {
|
||||||
height: 48rpx;
|
height: 64rpx;
|
||||||
background: linear-gradient(180deg, rgba(232, 160, 106, 0.50), rgba(212, 107, 8, 0.16));
|
background: linear-gradient(180deg, rgba(232, 160, 106, 0.50), rgba(212, 107, 8, 0.16));
|
||||||
box-shadow: 0 6rpx 18rpx rgba(212, 107, 8, 0.22), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45);
|
box-shadow: 0 6rpx 18rpx rgba(212, 107, 8, 0.22), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45);
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-55
@@ -3,7 +3,12 @@ const util = require('../../utils/util')
|
|||||||
const themeMod = require('../../utils/theme')
|
const themeMod = require('../../utils/theme')
|
||||||
const iconsMod = require('../../utils/icons')
|
const iconsMod = require('../../utils/icons')
|
||||||
const config = require('../../config')
|
const config = require('../../config')
|
||||||
const { getBadgeProgress, getBadgeCelebration } = require('../../utils/badge-state')
|
const {
|
||||||
|
getBadgePositions,
|
||||||
|
getBadgeSegments,
|
||||||
|
getBadgeDisplayLabel,
|
||||||
|
getBadgeCelebration
|
||||||
|
} = require('../../utils/badge-state')
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
@@ -17,15 +22,19 @@ Page({
|
|||||||
maxDurationText: '',
|
maxDurationText: '',
|
||||||
totalDays: 0,
|
totalDays: 0,
|
||||||
trainingBadges: [],
|
trainingBadges: [],
|
||||||
badgeProgress: 0,
|
badgeSegments: [],
|
||||||
nextBadgeHint: '',
|
nextBadgeHint: '',
|
||||||
currentYear: new Date().getFullYear(),
|
currentYear: new Date().getFullYear(),
|
||||||
currentMonth: new Date().getMonth() + 1,
|
currentMonth: new Date().getMonth() + 1,
|
||||||
monthRecords: [],
|
monthRecords: [],
|
||||||
historyList: [],
|
historyList: [],
|
||||||
listMode: 'month',
|
listMode: 'month',
|
||||||
|
contentMode: 'records',
|
||||||
trendMode: 'week',
|
trendMode: 'week',
|
||||||
trendData: [],
|
trendData: [],
|
||||||
|
trendSummary: {},
|
||||||
|
bestTrendData: [],
|
||||||
|
bestTrendSummary: {},
|
||||||
showDayDetail: false,
|
showDayDetail: false,
|
||||||
dayDetail: {},
|
dayDetail: {},
|
||||||
icons: iconsMod.build()
|
icons: iconsMod.build()
|
||||||
@@ -56,10 +65,19 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
refresh() {
|
refresh() {
|
||||||
storage.validateStreak()
|
const streak = storage.validateStreak()
|
||||||
const stats = storage.getTotalStats()
|
const stats = storage.getTotalStats()
|
||||||
const monthKey = `${this.data.currentYear}-${String(this.data.currentMonth).padStart(2, '0')}`
|
const monthKey = `${this.data.currentYear}-${String(this.data.currentMonth).padStart(2, '0')}`
|
||||||
const records = (storage.getRecordsByMonth(monthKey) || []).map(r => ({ ...r, durationText: util.formatTime(r.duration) }))
|
// Decorate each record with a display string for circuit (循环) sessions.
|
||||||
|
const withText = (r) => {
|
||||||
|
const base = { ...r, durationText: util.formatTime(r.duration) }
|
||||||
|
if (r.mode === 'circuit') {
|
||||||
|
const rest = r.restPerSet ? ` · 休${r.restPerSet}s` : ''
|
||||||
|
base.circuitText = `循环 ${r.sets}组×${r.holdPerSet}s${rest}`
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
const records = (storage.getRecordsByMonth(monthKey) || []).map(withText)
|
||||||
|
|
||||||
// Each month is already sorted newest-first in storage. To get top 30 most recent,
|
// Each month is already sorted newest-first in storage. To get top 30 most recent,
|
||||||
// walk months in reverse chrono order and concat — O(months*30) instead of O(n log n).
|
// walk months in reverse chrono order and concat — O(months*30) instead of O(n log n).
|
||||||
@@ -68,27 +86,24 @@ Page({
|
|||||||
const historyList = []
|
const historyList = []
|
||||||
for (const mk of monthKeys) {
|
for (const mk of monthKeys) {
|
||||||
for (const r of byMonth[mk]) {
|
for (const r of byMonth[mk]) {
|
||||||
historyList.push({ ...r, durationText: util.formatTime(r.duration) })
|
historyList.push(withText(r))
|
||||||
if (historyList.length >= 30) break
|
if (historyList.length >= 30) break
|
||||||
}
|
}
|
||||||
if (historyList.length >= 30) break
|
if (historyList.length >= 30) break
|
||||||
}
|
}
|
||||||
|
|
||||||
// 累计训练天数勋章(里程碑进度条):统计口径=累计去重训练日(可中断)。
|
// 累计训练天数勋章(里程碑进度条):统计口径=累计去重训练日(可中断)。
|
||||||
// 节点等距排列(间距一致、视觉整齐),填充在区间内线性插值,
|
// 节点等距排列;每个连接段独立填充,绿色铺满即解锁下一枚勋章。
|
||||||
// 解锁时填充正好抵达该节点,满级时填充满格。
|
|
||||||
const totalDays = stats.totalDays
|
const totalDays = stats.totalDays
|
||||||
const badgeDefs = config.trainingDayBadges
|
const badgeDefs = config.trainingDayBadges
|
||||||
const n = badgeDefs.length
|
const badgePositions = getBadgePositions(badgeDefs)
|
||||||
const TRACK_PAD = 4 // 首尾留白,保证首尾节点/标签不溢出
|
|
||||||
const segWidth = n > 1 ? (100 - TRACK_PAD * 2) / (n - 1) : 0
|
|
||||||
const posOf = (i) => TRACK_PAD + i * segWidth
|
|
||||||
const trainingBadges = badgeDefs.map((b, i) => ({
|
const trainingBadges = badgeDefs.map((b, i) => ({
|
||||||
...b,
|
...b,
|
||||||
unlocked: totalDays >= b.days,
|
unlocked: totalDays >= b.days,
|
||||||
pos: Math.round(posOf(i) * 10) / 10
|
pos: badgePositions[i],
|
||||||
|
label: getBadgeDisplayLabel(b, totalDays >= b.days)
|
||||||
}))
|
}))
|
||||||
const badgeProgress = getBadgeProgress(badgeDefs, totalDays)
|
const badgeSegments = getBadgeSegments(badgeDefs, totalDays)
|
||||||
|
|
||||||
// 单枚解锁庆祝反馈:对比持久化"已见解锁集合",只庆祝新增勋章。
|
// 单枚解锁庆祝反馈:对比持久化"已见解锁集合",只庆祝新增勋章。
|
||||||
// 首次运行(无存储)仅初始化集合,避免老用户一打开就被历史勋章刷屏。
|
// 首次运行(无存储)仅初始化集合,避免老用户一打开就被历史勋章刷屏。
|
||||||
@@ -101,6 +116,9 @@ Page({
|
|||||||
: `还差 ${badgeDefs[nextIdx].days - totalDays} 天解锁「${badgeDefs[nextIdx].name}」`
|
: `还差 ${badgeDefs[nextIdx].days - totalDays} 天解锁「${badgeDefs[nextIdx].name}」`
|
||||||
|
|
||||||
const theme = themeMod.getCurrentTheme()
|
const theme = themeMod.getCurrentTheme()
|
||||||
|
const trendMode = this.data.trendMode || 'week'
|
||||||
|
const trendData = this._computeTrend(trendMode)
|
||||||
|
const bestTrendData = this._computeBestTrend(trendMode)
|
||||||
this.setData({
|
this.setData({
|
||||||
theme,
|
theme,
|
||||||
icons: iconsMod.build(theme),
|
icons: iconsMod.build(theme),
|
||||||
@@ -111,13 +129,16 @@ Page({
|
|||||||
maxDurationText: util.formatDuration(stats.maxDuration),
|
maxDurationText: util.formatDuration(stats.maxDuration),
|
||||||
totalDays,
|
totalDays,
|
||||||
trainingBadges,
|
trainingBadges,
|
||||||
badgeProgress,
|
badgeSegments,
|
||||||
nextBadgeHint,
|
nextBadgeHint,
|
||||||
monthRecords: records,
|
monthRecords: records,
|
||||||
historyList,
|
historyList,
|
||||||
listMode: this.data.listMode || 'month',
|
listMode: this.data.listMode || 'month',
|
||||||
displayList: (this.data.listMode || 'month') === 'month' ? records : historyList,
|
displayList: (this.data.listMode || 'month') === 'month' ? records : historyList,
|
||||||
trendData: this._computeTrend(this.data.trendMode || 'week'),
|
trendData,
|
||||||
|
trendSummary: { ...util.getTrendSummary(trendData, trendMode), streakCount: Number(streak.count) || 0 },
|
||||||
|
bestTrendData,
|
||||||
|
bestTrendSummary: util.getTrendSummary(bestTrendData, trendMode),
|
||||||
loading: false
|
loading: false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -153,10 +174,24 @@ Page({
|
|||||||
this.refresh()
|
this.refresh()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onToggleContentMode(e) {
|
||||||
|
const mode = e.currentTarget.dataset.mode
|
||||||
|
if (!['records', 'trend'].includes(mode) || mode === this.data.contentMode) return
|
||||||
|
this.setData({ contentMode: mode })
|
||||||
|
},
|
||||||
|
|
||||||
onToggleTrendMode(e) {
|
onToggleTrendMode(e) {
|
||||||
const mode = e.currentTarget.dataset.mode
|
const mode = e.currentTarget.dataset.mode
|
||||||
if (!mode || mode === this.data.trendMode) return
|
if (!mode || mode === this.data.trendMode) return
|
||||||
this.setData({ trendMode: mode, trendData: this._computeTrend(mode) })
|
const trendData = this._computeTrend(mode)
|
||||||
|
const bestTrendData = this._computeBestTrend(mode)
|
||||||
|
this.setData({
|
||||||
|
trendMode: mode,
|
||||||
|
trendData,
|
||||||
|
trendSummary: { ...util.getTrendSummary(trendData, mode), streakCount: Number(storage.getStreak().count) || 0 },
|
||||||
|
bestTrendData,
|
||||||
|
bestTrendSummary: util.getTrendSummary(bestTrendData, mode)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -166,46 +201,11 @@ Page({
|
|||||||
* 返回 [{ label, value, valueText, highlight }] 供 trend-chart 渲染。
|
* 返回 [{ label, value, valueText, highlight }] 供 trend-chart 渲染。
|
||||||
*/
|
*/
|
||||||
_computeTrend(mode) {
|
_computeTrend(mode) {
|
||||||
const allRecords = Object.values(storage.getRecords()).flat()
|
return util.getTrendData(storage.getRecords(), mode)
|
||||||
const today = new Date()
|
},
|
||||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
|
||||||
const pad = (n) => String(n).padStart(2, '0')
|
|
||||||
|
|
||||||
if (mode === 'month') {
|
_computeBestTrend(mode) {
|
||||||
const data = []
|
return util.getBestTrendData(storage.getRecords(), mode)
|
||||||
for (let i = 5; i >= 0; i--) {
|
|
||||||
const d = new Date(today.getFullYear(), today.getMonth() - i, 1)
|
|
||||||
const monthKey = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`
|
|
||||||
const monthRecords = storage.getRecordsByMonth(monthKey) || []
|
|
||||||
const total = monthRecords.reduce((s, r) => s + (Number(r.duration) || 0), 0)
|
|
||||||
data.push({
|
|
||||||
label: `${d.getMonth() + 1}月`,
|
|
||||||
value: total,
|
|
||||||
valueText: total > 0 ? util.formatDuration(total) : '',
|
|
||||||
highlight: i === 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// default: week (近 7 天)
|
|
||||||
const data = []
|
|
||||||
for (let i = 6; i >= 0; i--) {
|
|
||||||
const d = new Date(today)
|
|
||||||
d.setDate(d.getDate() - i)
|
|
||||||
const dateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
||||||
const total = allRecords
|
|
||||||
.filter((r) => util.dateOnly(r.date) === dateStr)
|
|
||||||
.reduce((s, r) => s + (Number(r.duration) || 0), 0)
|
|
||||||
const isToday = i === 0
|
|
||||||
data.push({
|
|
||||||
label: isToday ? '今天' : weekdays[d.getDay()],
|
|
||||||
value: total,
|
|
||||||
valueText: total > 0 ? util.formatDuration(total) : '',
|
|
||||||
highlight: isToday
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onDayTap(e) {
|
onDayTap(e) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 统计卡片 (有数据时才显示) -->
|
<!-- 统计卡片 (有数据时才显示) -->
|
||||||
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
<ui-card variant="in" index="0" hidden="{{totalSessions === 0}}">
|
||||||
<view class="stats">
|
<view class="stats">
|
||||||
<view class="stat-item">
|
<view class="stat-item">
|
||||||
<image class="stat-icon" src="{{icons.rankFill}}" mode="aspectFit"></image>
|
<image class="stat-icon" src="{{icons.rankFill}}" mode="aspectFit"></image>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
<!-- 训练勋章 (里程碑进度条) -->
|
<!-- 训练勋章 (里程碑进度条) -->
|
||||||
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
<ui-card variant="in" index="1" hidden="{{totalSessions === 0}}">
|
||||||
<view class="badge-section">
|
<view class="badge-section">
|
||||||
<view class="badge-header">
|
<view class="badge-header">
|
||||||
<view class="badge-title-wrap">
|
<view class="badge-title-wrap">
|
||||||
@@ -59,7 +59,10 @@
|
|||||||
<text class="badge-status-next" wx:if="{{nextBadgeHint}}">{{nextBadgeHint}}</text>
|
<text class="badge-status-next" wx:if="{{nextBadgeHint}}">{{nextBadgeHint}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="badge-track">
|
<view class="badge-track">
|
||||||
<view class="badge-fill" style="width: {{badgeProgress}}%;"></view>
|
<view class="badge-segment" wx:for="{{badgeSegments}}" wx:key="index"
|
||||||
|
style="left: {{item.left}}%; width: {{item.width}}%;">
|
||||||
|
<view class="badge-segment-fill" style="width: {{item.progress}}%;"></view>
|
||||||
|
</view>
|
||||||
<view class="badge-node {{item.unlocked ? 'unlocked' : ''}} {{item.justUnlocked ? 'celebrate' : ''}}"
|
<view class="badge-node {{item.unlocked ? 'unlocked' : ''}} {{item.justUnlocked ? 'celebrate' : ''}}"
|
||||||
wx:for="{{trainingBadges}}" wx:key="days"
|
wx:for="{{trainingBadges}}" wx:key="days"
|
||||||
style="left: {{item.pos}}%;"
|
style="left: {{item.pos}}%;"
|
||||||
@@ -72,13 +75,20 @@
|
|||||||
<view class="badge-labels">
|
<view class="badge-labels">
|
||||||
<text class="badge-label {{item.unlocked ? 'unlocked' : ''}}"
|
<text class="badge-label {{item.unlocked ? 'unlocked' : ''}}"
|
||||||
wx:for="{{trainingBadges}}" wx:key="days"
|
wx:for="{{trainingBadges}}" wx:key="days"
|
||||||
style="left: {{item.pos}}%;">{{item.unlocked ? item.name : item.days}}</text>
|
style="left: {{item.pos}}%;">{{item.label}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
|
<!-- 内容分区:日历与记录共享月份状态,趋势按需展示,减少默认页长度。 -->
|
||||||
|
<view class="records-content-tabs" hidden="{{totalSessions === 0}}">
|
||||||
|
<view class="records-content-tab {{contentMode === 'records' ? 'active' : ''}}" data-mode="records" bindtap="onToggleContentMode">记录日历</view>
|
||||||
|
<view class="records-content-tab {{contentMode === 'trend' ? 'active' : ''}}" data-mode="trend" bindtap="onToggleContentMode">趋势</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 训练趋势 -->
|
<!-- 训练趋势 -->
|
||||||
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
<block wx:if="{{contentMode === 'trend'}}">
|
||||||
|
<ui-card variant="in" index="2" hidden="{{totalSessions === 0}}">
|
||||||
<view class="trend-section">
|
<view class="trend-section">
|
||||||
<view class="trend-header">
|
<view class="trend-header">
|
||||||
<view class="trend-title-wrap">
|
<view class="trend-title-wrap">
|
||||||
@@ -90,12 +100,41 @@
|
|||||||
<view class="rec-seg-item {{trendMode === 'month' ? 'active' : ''}}" data-mode="month" bindtap="onToggleTrendMode">近6月</view>
|
<view class="rec-seg-item {{trendMode === 'month' ? 'active' : ''}}" data-mode="month" bindtap="onToggleTrendMode">近6月</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="trend-summary">
|
||||||
|
<view class="trend-summary-item">
|
||||||
|
<text class="trend-summary-value">{{trendSummary.totalText}}</text>
|
||||||
|
<text class="trend-summary-label">累计时长</text>
|
||||||
|
</view>
|
||||||
|
<view class="trend-summary-item">
|
||||||
|
<text class="trend-summary-value">{{trendSummary.activeCount}}</text>
|
||||||
|
<text class="trend-summary-label">{{trendSummary.activeLabel}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="trend-summary-item">
|
||||||
|
<text class="trend-summary-value">{{trendSummary.streakCount}}</text>
|
||||||
|
<text class="trend-summary-label">连续打卡</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
<trend-chart chart-data="{{trendData}}"></trend-chart>
|
<trend-chart chart-data="{{trendData}}"></trend-chart>
|
||||||
|
<view class="trend-highlight">
|
||||||
|
<image class="trend-highlight-icon" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
|
||||||
|
<text class="trend-highlight-title">{{trendSummary.peakTitle}}</text>
|
||||||
|
<text class="trend-highlight-text">{{trendSummary.peakText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="best-trend-header">
|
||||||
|
<view class="trend-title-wrap">
|
||||||
|
<image class="history-title-icon" src="{{icons.crownFill}}" mode="aspectFit"></image>
|
||||||
|
<text class="best-trend-title">个人最佳趋势</text>
|
||||||
|
</view>
|
||||||
|
<text class="best-trend-best">{{bestTrendSummary.peakText}}</text>
|
||||||
|
</view>
|
||||||
|
<trend-chart chart-data="{{bestTrendData}}" variant="peak"></trend-chart>
|
||||||
</view>
|
</view>
|
||||||
</ui-card>
|
</ui-card>
|
||||||
|
</block>
|
||||||
|
|
||||||
<!-- 日历热力图 -->
|
<!-- 日历热力图 -->
|
||||||
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
<block wx:if="{{contentMode === 'records'}}">
|
||||||
|
<ui-card variant="in" index="2" hidden="{{totalSessions === 0}}">
|
||||||
<view class="calendar-section">
|
<view class="calendar-section">
|
||||||
<view class="month-picker">
|
<view class="month-picker">
|
||||||
<view class="month-arrow" bindtap="onPrevMonth">
|
<view class="month-arrow" bindtap="onPrevMonth">
|
||||||
@@ -120,7 +159,7 @@
|
|||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
<!-- 历史记录 -->
|
<!-- 历史记录 -->
|
||||||
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
<ui-card variant="in" index="3" hidden="{{totalSessions === 0}}">
|
||||||
<view class="history-section">
|
<view class="history-section">
|
||||||
<view class="history-header">
|
<view class="history-header">
|
||||||
<view class="history-title-wrap">
|
<view class="history-title-wrap">
|
||||||
@@ -151,7 +190,7 @@
|
|||||||
></image>
|
></image>
|
||||||
<view class="history-left">
|
<view class="history-left">
|
||||||
<text class="history-date">{{item.date}}</text>
|
<text class="history-date">{{item.date}}</text>
|
||||||
<text class="history-plan">{{item.day === 0 ? '自由训练' : '第 ' + item.day + ' 天'}}</text>
|
<text class="history-plan">{{item.mode === 'circuit' ? item.circuitText : (item.day === 0 ? '自由训练' : '第 ' + item.day + ' 天')}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="history-right">
|
<view class="history-right">
|
||||||
<text class="history-duration">{{item.durationText}}</text>
|
<text class="history-duration">{{item.durationText}}</text>
|
||||||
@@ -164,6 +203,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</ui-card>
|
</ui-card>
|
||||||
|
</block>
|
||||||
</view>
|
</view>
|
||||||
<!-- /hidden wrapper -->
|
<!-- /hidden wrapper -->
|
||||||
|
|
||||||
|
|||||||
+104
-2
@@ -101,11 +101,14 @@
|
|||||||
border-radius: 2rpx;
|
border-radius: 2rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-fill {
|
.badge-segment {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-segment-fill {
|
||||||
|
height: 100%;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
border-radius: 2rpx;
|
border-radius: 2rpx;
|
||||||
transition: width 0.6s ease;
|
transition: width 0.6s ease;
|
||||||
@@ -196,6 +199,32 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- records / trend top-level switch ---- */
|
||||||
|
.records-content-tabs {
|
||||||
|
display: flex;
|
||||||
|
margin: 8rpx 0 20rpx;
|
||||||
|
padding: 6rpx;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-content-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14rpx 0;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 26rpx;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.25s ease, color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-content-tab.active {
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- calendar ---- */
|
/* ---- calendar ---- */
|
||||||
.calendar-section {
|
.calendar-section {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -264,6 +293,79 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trend-summary {
|
||||||
|
display: flex;
|
||||||
|
margin: 16rpx 0 4rpx;
|
||||||
|
padding: 16rpx 0;
|
||||||
|
background: rgba(var(--primary-rgb), 0.06);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-summary-item {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-summary-value {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-summary-label {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-highlight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
padding: 14rpx 16rpx;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border-radius: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-highlight-icon {
|
||||||
|
width: 28rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-highlight-title {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-highlight-text {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.best-trend-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.best-trend-title {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.best-trend-best {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- history ---- */
|
/* ---- history ---- */
|
||||||
.history-section {
|
.history-section {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<view class="container page-enter" style="{{themeStyle}}">
|
<view class="container page-enter" style="{{themeStyle}}">
|
||||||
<!-- 个人资料 -->
|
<!-- 个人资料 -->
|
||||||
<ui-card variant="in">
|
<ui-card variant="in" index="0">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<image class="section-icon" src="{{icons.peopleFill}}" mode="aspectFit"></image>
|
<image class="section-icon" src="{{icons.peopleFill}}" mode="aspectFit"></image>
|
||||||
<text class="section-title">个人资料</text>
|
<text class="section-title">个人资料</text>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
<!-- 配色方案 -->
|
<!-- 配色方案 -->
|
||||||
<ui-card variant="in">
|
<ui-card variant="in" index="1">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
|
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
|
||||||
<text class="section-title">配色方案</text>
|
<text class="section-title">配色方案</text>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
<!-- 深色模式 -->
|
<!-- 深色模式 -->
|
||||||
<ui-card variant="in">
|
<ui-card variant="in" index="2">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
|
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
|
||||||
<text class="section-title">深色模式</text>
|
<text class="section-title">深色模式</text>
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
<!-- 训练计划选择 -->
|
<!-- 训练计划选择 -->
|
||||||
<ui-card variant="in">
|
<ui-card variant="in" index="3">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<image class="section-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
|
<image class="section-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
|
||||||
<text class="section-title">训练计划</text>
|
<text class="section-title">训练计划</text>
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
<!-- 训练偏好 -->
|
<!-- 训练偏好 -->
|
||||||
<ui-card variant="in">
|
<ui-card variant="in" index="4">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<image class="section-icon" src="{{icons.settingsFill}}" mode="aspectFit"></image>
|
<image class="section-icon" src="{{icons.settingsFill}}" mode="aspectFit"></image>
|
||||||
<text class="section-title">训练偏好</text>
|
<text class="section-title">训练偏好</text>
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
<!-- 数据管理 -->
|
<!-- 数据管理 -->
|
||||||
<ui-card variant="in">
|
<ui-card variant="in" index="5">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<image class="section-icon" src="{{icons.deleteActive}}" mode="aspectFit"></image>
|
<image class="section-icon" src="{{icons.deleteActive}}" mode="aspectFit"></image>
|
||||||
<text class="section-title">数据管理</text>
|
<text class="section-title">数据管理</text>
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
<!-- 关于 -->
|
<!-- 关于 -->
|
||||||
<ui-card variant="in">
|
<ui-card variant="in" index="6">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<image class="section-icon" src="{{icons.infoFill}}" mode="aspectFit"></image>
|
<image class="section-icon" src="{{icons.infoFill}}" mode="aspectFit"></image>
|
||||||
<text class="section-title">关于</text>
|
<text class="section-title">关于</text>
|
||||||
|
|||||||
+352
-7
@@ -1,4 +1,5 @@
|
|||||||
const Timer = require('../../utils/timer')
|
const Timer = require('../../utils/timer')
|
||||||
|
const CircuitTimer = require('../../utils/circuitTimer')
|
||||||
const storage = require('../../utils/storage')
|
const storage = require('../../utils/storage')
|
||||||
const planMod = require('../../utils/plan')
|
const planMod = require('../../utils/plan')
|
||||||
const themeMod = require('../../utils/theme')
|
const themeMod = require('../../utils/theme')
|
||||||
@@ -22,6 +23,27 @@ Page({
|
|||||||
todayTarget: 0,
|
todayTarget: 0,
|
||||||
planDay: 1,
|
planDay: 1,
|
||||||
isFreeMode: false,
|
isFreeMode: false,
|
||||||
|
// --- Circuit (循环训练) mode fields ---
|
||||||
|
isCircuitMode: false,
|
||||||
|
circuitSets: 4,
|
||||||
|
circuitHold: 30,
|
||||||
|
circuitRest: 15,
|
||||||
|
circuitSessions: 3,
|
||||||
|
currentSet: 0,
|
||||||
|
totalSets: 0,
|
||||||
|
phase: 'idle', // idle | working | resting | done
|
||||||
|
phaseRemaining: 0,
|
||||||
|
phaseElapsed: 0,
|
||||||
|
workTotal: 0, // effective held seconds (excludes rest)
|
||||||
|
isResting: false,
|
||||||
|
phaseTip: '', // 阶段切换提示文本(如"最后一组!"/"休息 15 秒")
|
||||||
|
// 顶部循环进度面板:组进度胶囊点阵
|
||||||
|
circuitDots: [], // [1..sets],仅用于 wx:for 渲染,组数过多时不渲染
|
||||||
|
circuitShowDots: true, // 组数 > DOTS_MAX 时退化为线性进度条
|
||||||
|
circuitTotalWork: 0, // sets × holdPerSet,面板里作为"目标秒数"展示
|
||||||
|
dotsDone: 0, // 已完成组数
|
||||||
|
dotsActive: 0, // 正在进行的组序号(休息时为 0)
|
||||||
|
dotsNext: 0, // 休息时即将开始的组序号(工作时为 0)
|
||||||
celebrationLevel: 'normal', // normal / first / milestone / record
|
celebrationLevel: 'normal', // normal / first / milestone / record
|
||||||
celebrationMessage: '',
|
celebrationMessage: '',
|
||||||
confettiPieces: [],
|
confettiPieces: [],
|
||||||
@@ -40,9 +62,13 @@ Page({
|
|||||||
onLoad(options) {
|
onLoad(options) {
|
||||||
themeMod.applyThemeToPage(this)
|
themeMod.applyThemeToPage(this)
|
||||||
this._init(options)
|
this._init(options)
|
||||||
// 预载语音到本地缓存,训练开始时即可即时播放(仅语音引导开启时)
|
// 预载语音到本地缓存,训练开始时即可即时播放(仅语音引导开启时)。
|
||||||
|
// 按模式取词条:循环模式要 restStart/nextSet/lastSet,单组模式要 goal,
|
||||||
|
// 各自不载对方的,省掉无谓的云函数调用与下载。
|
||||||
const s = storage.getSettings()
|
const s = storage.getSettings()
|
||||||
if (s.voiceGuide !== false) voice.preload()
|
if (s.voiceGuide !== false) {
|
||||||
|
voice.preload(this.data.isCircuitMode ? voice.CIRCUIT_KEYS : voice.HOLD_KEYS)
|
||||||
|
}
|
||||||
// countUp: 数字从 0 滚到目标秒数
|
// countUp: 数字从 0 滚到目标秒数
|
||||||
const target = this.data.duration
|
const target = this.data.duration
|
||||||
if (this._countUpTask) this._countUpTask.cancel()
|
if (this._countUpTask) this._countUpTask.cancel()
|
||||||
@@ -59,9 +85,21 @@ Page({
|
|||||||
themeMod.applyThemeToPage(this)
|
themeMod.applyThemeToPage(this)
|
||||||
const theme = themeMod.getCurrentTheme()
|
const theme = themeMod.getCurrentTheme()
|
||||||
this.setData({ theme, icons: iconsMod.build(theme) })
|
this.setData({ theme, icons: iconsMod.build(theme) })
|
||||||
|
// Coming back from the background (WeChat chat, phone call, home screen)
|
||||||
|
// clears the app-wide keep-awake flag, so re-arm it if a session is still
|
||||||
|
// in flight — otherwise the second half of the training silently loses
|
||||||
|
// every voice/haptic cue again.
|
||||||
|
if (this.data.isRunning) this._keepScreenOn(true)
|
||||||
},
|
},
|
||||||
|
|
||||||
_init(options) {
|
_init(options) {
|
||||||
|
this._initOptions = options || {}
|
||||||
|
// Circuit (循环训练) mode: build the work/rest FSM and bail out of the
|
||||||
|
// single-hold init below. All controls / completion modal / save path are shared.
|
||||||
|
if (this._initOptions.circuit) {
|
||||||
|
this._initCircuit(this._initOptions)
|
||||||
|
return
|
||||||
|
}
|
||||||
let target
|
let target
|
||||||
let planDay = 1
|
let planDay = 1
|
||||||
let isFreeMode = false
|
let isFreeMode = false
|
||||||
@@ -125,6 +163,250 @@ Page({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a circuit (循环) training session. Reads the persisted config
|
||||||
|
* (utils/storage.getCircuitConfig) but lets URL params override so the home
|
||||||
|
* page can jump straight in with the last-saved settings.
|
||||||
|
*/
|
||||||
|
_initCircuit(options) {
|
||||||
|
if (this._timer) {
|
||||||
|
this._timer.stop()
|
||||||
|
this._timer = null
|
||||||
|
}
|
||||||
|
// Reset per-session state (mirrors the single-hold path)
|
||||||
|
this._halfwaySignaled = false
|
||||||
|
this._last30Signaled = false
|
||||||
|
this._last10Signaled = false
|
||||||
|
this._lastMinuteAt = 0
|
||||||
|
this._lastCountdown = 0
|
||||||
|
this._saving = false
|
||||||
|
this._lastTipIndex = -1
|
||||||
|
this._voiceBusyUntil = 0
|
||||||
|
this._clearVibrateTimers()
|
||||||
|
|
||||||
|
const cfg = storage.getCircuitConfig()
|
||||||
|
const hold = this._int(options.hold, cfg.holdPerSet, 5, 600)
|
||||||
|
const sets = this._int(options.sets, cfg.sets, 1, 50)
|
||||||
|
const rest = this._int(options.rest, cfg.restPerSet, 0, 600)
|
||||||
|
this._circuitCfg = {
|
||||||
|
holdPerSet: hold,
|
||||||
|
sets,
|
||||||
|
restPerSet: rest,
|
||||||
|
sessionsPerWeek: cfg.sessionsPerWeek
|
||||||
|
}
|
||||||
|
|
||||||
|
this._timer = new CircuitTimer({
|
||||||
|
holdPerSet: hold,
|
||||||
|
sets,
|
||||||
|
restPerSet: rest,
|
||||||
|
onTick: (t) => {
|
||||||
|
// 组进度点阵三态。休息阶段 CircuitTimer 的 setIndex 仍指向"刚做完
|
||||||
|
// 的那一组",所以 resting 时 setIndex 组算已完成、下一组标为 next。
|
||||||
|
const resting = t.phase === 'resting'
|
||||||
|
this.setData({
|
||||||
|
currentSet: t.setIndex,
|
||||||
|
totalSets: t.sets,
|
||||||
|
phase: t.phase,
|
||||||
|
phaseRemaining: t.phaseRemaining,
|
||||||
|
phaseElapsed: t.phaseElapsed,
|
||||||
|
workTotal: t.workTotal,
|
||||||
|
isResting: resting,
|
||||||
|
dotsDone: resting ? t.setIndex : Math.max(0, t.setIndex - 1),
|
||||||
|
dotsActive: resting ? 0 : t.setIndex,
|
||||||
|
dotsNext: resting ? t.setIndex + 1 : 0,
|
||||||
|
// Reuse the single-hold display fields so progress-ring / completion
|
||||||
|
// number keep working: the ring shows the CURRENT set's countdown.
|
||||||
|
remaining: t.phaseRemaining,
|
||||||
|
elapsed: t.workTotal,
|
||||||
|
duration: t.phase === 'working' ? hold : rest
|
||||||
|
})
|
||||||
|
this._circuitCue(t)
|
||||||
|
},
|
||||||
|
onPhaseChange: (p) => this._circuitPhase(p),
|
||||||
|
onComplete: () => this._onCircuitComplete()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 点阵超过 DOTS_MAX 个就挤成头发丝了,退化成一条线性进度条。
|
||||||
|
const DOTS_MAX = 15
|
||||||
|
const dots = []
|
||||||
|
if (sets <= DOTS_MAX) {
|
||||||
|
for (let i = 1; i <= sets; i++) dots.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
isCircuitMode: true,
|
||||||
|
isFreeMode: false,
|
||||||
|
planDay: 0,
|
||||||
|
circuitSets: sets,
|
||||||
|
circuitHold: hold,
|
||||||
|
circuitRest: rest,
|
||||||
|
circuitSessions: this._circuitCfg.sessionsPerWeek,
|
||||||
|
circuitDots: dots,
|
||||||
|
circuitShowDots: sets <= DOTS_MAX,
|
||||||
|
circuitTotalWork: sets * hold,
|
||||||
|
dotsDone: 0,
|
||||||
|
dotsActive: 0,
|
||||||
|
dotsNext: 0,
|
||||||
|
currentSet: 0,
|
||||||
|
totalSets: sets,
|
||||||
|
phase: 'idle',
|
||||||
|
phaseRemaining: hold,
|
||||||
|
phaseElapsed: 0,
|
||||||
|
workTotal: 0,
|
||||||
|
isResting: false,
|
||||||
|
phaseTip: '',
|
||||||
|
duration: hold,
|
||||||
|
remaining: hold,
|
||||||
|
todayTarget: hold,
|
||||||
|
goalReached: false,
|
||||||
|
elapsed: 0,
|
||||||
|
status: 'idle',
|
||||||
|
isRunning: false,
|
||||||
|
isPaused: false,
|
||||||
|
isCompleted: false,
|
||||||
|
showCompletion: false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
_int(v, fallback, min, max) {
|
||||||
|
const n = parseInt(v)
|
||||||
|
if (!Number.isFinite(n)) return fallback
|
||||||
|
return Math.max(min, Math.min(max, n))
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a haptic buzz, tracked so pause/stop/unload can cancel pending ones.
|
||||||
|
* Mirrors the vibrate helper inside _remind().
|
||||||
|
*/
|
||||||
|
_vibrateOnce(n, ms) {
|
||||||
|
const s = storage.getSettings()
|
||||||
|
if (s.vibrate === false) return
|
||||||
|
const intensity = s.vibrateIntensity || 'heavy'
|
||||||
|
const doVibrate = () => {
|
||||||
|
const fail = (err) => console.warn('[vibrate] failed:', err.errMsg || err)
|
||||||
|
if (intensity === 'light') wx.vibrateShort({ type: 'light', fail })
|
||||||
|
else if (intensity === 'medium') wx.vibrateShort({ type: 'medium', fail })
|
||||||
|
else wx.vibrateLong({ fail })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
doVibrate()
|
||||||
|
const idx = this._vibrateTimers.indexOf(id)
|
||||||
|
if (idx > -1) this._vibrateTimers.splice(idx, 1)
|
||||||
|
}, i * (ms + 30))
|
||||||
|
this._vibrateTimers.push(id)
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-tick circuit cues. Mirrors _remind() for the single-hold mode, but the
|
||||||
|
* yardstick is the WHOLE session's effective held time (sets × holdPerSet),
|
||||||
|
* NOT the current set — otherwise a 4-set circuit would announce "已完成一半啦"
|
||||||
|
* four times (once per set), which is both noisy and semantically wrong.
|
||||||
|
*
|
||||||
|
* Only evaluated while `working`: workTotal is frozen during rest, so the
|
||||||
|
* thresholds can never re-fire mid-break.
|
||||||
|
*/
|
||||||
|
_circuitCue(t) {
|
||||||
|
if (t.phase !== 'working') return
|
||||||
|
|
||||||
|
const total = this._circuitCfg.sets * this._circuitCfg.holdPerSet
|
||||||
|
const done = t.workTotal
|
||||||
|
const left = total - done
|
||||||
|
const s = storage.getSettings()
|
||||||
|
const voiceOn = s.voiceGuide !== false
|
||||||
|
|
||||||
|
// Same duration gates as _remind(), so short circuits (e.g. 3 sets × 5s)
|
||||||
|
// don't fire "最后30秒" on the very first tick. `<=` rather than `===`
|
||||||
|
// survives a skipped second when the app returns from background.
|
||||||
|
if (total >= 10 && !this._halfwaySignaled && done >= Math.floor(total / 2)) {
|
||||||
|
this._halfwaySignaled = true
|
||||||
|
if (voiceOn) this._playProgressVoice('halfway')
|
||||||
|
this._vibrateOnce(2, 150)
|
||||||
|
}
|
||||||
|
if (total > 60 && left <= 30 && !this._last30Signaled) {
|
||||||
|
this._last30Signaled = true
|
||||||
|
if (voiceOn) this._playProgressVoice('last30')
|
||||||
|
}
|
||||||
|
if (total > 20 && left <= 10 && !this._last10Signaled) {
|
||||||
|
this._last10Signaled = true
|
||||||
|
if (voiceOn) this._playProgressVoice('last10')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Countdown buzz over the last 5s of the FINAL set (the real finish line).
|
||||||
|
if (t.setIndex >= this._circuitCfg.sets && t.phaseRemaining <= 5 && t.phaseRemaining > 0 && t.phaseRemaining !== this._lastCountdown) {
|
||||||
|
this._lastCountdown = t.phaseRemaining
|
||||||
|
this._vibrateOnce(1, 100)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a whole-session progress prompt (halfway / last30 / last10) and claim
|
||||||
|
* a short priority window afterwards.
|
||||||
|
*
|
||||||
|
* Why: the halfway mark is `sets × hold / 2`, which for an EVEN set count
|
||||||
|
* lands exactly on the final second of set sets/2 — i.e. the same tick as the
|
||||||
|
* rest transition. voice.play() drops any in-flight clip when a new one
|
||||||
|
* starts, so without this the phase line would cut "已完成一半啦" off after
|
||||||
|
* one syllable. Progress prompts win; the phase line is the expendable one.
|
||||||
|
*/
|
||||||
|
_playProgressVoice(key) {
|
||||||
|
this._voiceBusyUntil = Date.now() + 2600 // ~ one spoken line
|
||||||
|
voice.play(key)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired once on each work/rest transition: haptic + on-screen phaseTip +
|
||||||
|
* a voice line (restStart / nextSet / lastSet — number-free, so 3 fixed
|
||||||
|
* clips cover any set count).
|
||||||
|
*
|
||||||
|
* `chatty` gate: a spoken line takes ~2s, so on short sets (<10s each) the
|
||||||
|
* prompts would overlap into a stream of chatter and step on the training
|
||||||
|
* rhythm. Below that we keep only the "last set" cue (the one moment worth
|
||||||
|
* interrupting for) and let haptics + text carry the rest.
|
||||||
|
*/
|
||||||
|
_circuitPhase(p) {
|
||||||
|
const s = storage.getSettings()
|
||||||
|
const chatty = this._circuitCfg.holdPerSet >= 10
|
||||||
|
// Yield the mic if a progress prompt is still speaking (see _playProgressVoice).
|
||||||
|
const voiceOn = s.voiceGuide !== false && Date.now() >= (this._voiceBusyUntil || 0)
|
||||||
|
|
||||||
|
if (p.phase === 'working') {
|
||||||
|
if (p.setIndex === 1) {
|
||||||
|
// No voice here: onStart already plays 'start' at the same instant.
|
||||||
|
this.setData({ phaseTip: '开始第一组' })
|
||||||
|
} else if (p.isLastSet) {
|
||||||
|
this.setData({ phaseTip: '最后一组,冲刺!' })
|
||||||
|
if (voiceOn) voice.play('lastSet')
|
||||||
|
this._vibrateOnce(2, 150)
|
||||||
|
} else {
|
||||||
|
this.setData({ phaseTip: '第 ' + p.setIndex + ' 组' })
|
||||||
|
if (voiceOn && chatty) voice.play('nextSet')
|
||||||
|
this._vibrateOnce(1, 150)
|
||||||
|
}
|
||||||
|
} else if (p.phase === 'resting') {
|
||||||
|
this.setData({ phaseTip: '休息 ' + this._circuitCfg.restPerSet + ' 秒' })
|
||||||
|
// Skip on very short breaks — the clip would still be talking when the
|
||||||
|
// next set starts, colliding with nextSet/lastSet.
|
||||||
|
if (voiceOn && chatty && this._circuitCfg.restPerSet >= 5) voice.play('restStart')
|
||||||
|
this._vibrateOnce(1, 100)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All sets completed naturally. workTotal already equals sets*holdPerSet.
|
||||||
|
* Reuse the shared completion/save path so celebration + streak + record
|
||||||
|
* all behave identically to a normal session.
|
||||||
|
*/
|
||||||
|
_onCircuitComplete() {
|
||||||
|
this._clearVibrateTimers()
|
||||||
|
const s = storage.getSettings()
|
||||||
|
if (s.voiceGuide !== false) voice.play('complete')
|
||||||
|
this._saveAndShowCompletion(this.data.workTotal)
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotate the posture cue in the hint area every config.tipInterval seconds.
|
* Rotate the posture cue in the hint area every config.tipInterval seconds.
|
||||||
* Only runs while still under the target (after goalReached the hint
|
* Only runs while still under the target (after goalReached the hint
|
||||||
@@ -233,6 +515,31 @@ Page({
|
|||||||
this._vibrateTimers = []
|
this._vibrateTimers = []
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the screen awake while training.
|
||||||
|
*
|
||||||
|
* Why this matters here: during a plank the user's hands are on the floor —
|
||||||
|
* nobody taps the screen, so the system's idle timer (usually 60s) fires
|
||||||
|
* mid-session. Once the screen locks, the mini program is backgrounded and
|
||||||
|
* JS timers get throttled/suspended: the elapsed count still recovers
|
||||||
|
* (Timer/CircuitTimer reconcile against Date.now on resume), but every
|
||||||
|
* real-time cue in between — halfway / last30 / last10 voice, set-change
|
||||||
|
* and rest prompts, haptics — is simply lost.
|
||||||
|
*
|
||||||
|
* Scope caveat: wx.setKeepScreenOn is APP-wide, not page-scoped, and only
|
||||||
|
* resets when the user exits the mini program. So onUnload MUST turn it
|
||||||
|
* back off, otherwise the screen stays lit while they browse records or
|
||||||
|
* the leaderboard afterwards.
|
||||||
|
*
|
||||||
|
* fail is swallowed on purpose: some Android ROMs ignore this under battery
|
||||||
|
* saver, and a "failed to keep screen on" toast would only confuse the user
|
||||||
|
* — the training itself is unaffected. Supported since base library 1.4.0.
|
||||||
|
*/
|
||||||
|
_keepScreenOn(on) {
|
||||||
|
if (typeof wx.setKeepScreenOn !== 'function') return
|
||||||
|
wx.setKeepScreenOn({ keepScreenOn: !!on, fail() {} })
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect which celebration level to show. Called from _saveAndShowCompletion
|
* Detect which celebration level to show. Called from _saveAndShowCompletion
|
||||||
* (runs in finishTraining, BEFORE saveRecord), so stats reflect pre-save state.
|
* (runs in finishTraining, BEFORE saveRecord), so stats reflect pre-save state.
|
||||||
@@ -244,7 +551,14 @@ Page({
|
|||||||
_detectCelebrationLevel(elapsed) {
|
_detectCelebrationLevel(elapsed) {
|
||||||
const stats = storage.getTotalStats()
|
const stats = storage.getTotalStats()
|
||||||
const streak = storage.getStreak()
|
const streak = storage.getStreak()
|
||||||
const nextStreak = (streak.lastDate && streak.count >= 1) ? streak.count + 1 : 1
|
// 本函数运行在保存记录之前(pre-save state)。updateStreak 对「同一天再次训练」
|
||||||
|
// 会直接 return、不递增连胜数;所以若 streak.lastDate 已是今天,nextStreak 应取
|
||||||
|
// 当前 count 而非 count+1,否则庆祝文案会比真实连胜多 1 天。
|
||||||
|
const todayOnly = storage.getToday().substring(0, 10)
|
||||||
|
const lastIsToday = !!(streak.lastDate && streak.lastDate.substring(0, 10) === todayOnly)
|
||||||
|
const nextStreak = lastIsToday
|
||||||
|
? (streak.count || 1)
|
||||||
|
: ((streak.lastDate && streak.count >= 1) ? streak.count + 1 : 1)
|
||||||
|
|
||||||
if (stats.totalSessions === 0) {
|
if (stats.totalSessions === 0) {
|
||||||
return { level: 'first', message: '第一次训练!坚持就是胜利' }
|
return { level: 'first', message: '第一次训练!坚持就是胜利' }
|
||||||
@@ -283,6 +597,9 @@ Page({
|
|||||||
onUnload() {
|
onUnload() {
|
||||||
voice.stop()
|
voice.stop()
|
||||||
voice.destroy()
|
voice.destroy()
|
||||||
|
// Mandatory: the flag is app-wide, so leaving it on would keep the screen
|
||||||
|
// lit across every other page until the user quits the mini program.
|
||||||
|
this._keepScreenOn(false)
|
||||||
this._clearVibrateTimers()
|
this._clearVibrateTimers()
|
||||||
if (this._timer) {
|
if (this._timer) {
|
||||||
this._timer.stop()
|
this._timer.stop()
|
||||||
@@ -301,10 +618,18 @@ Page({
|
|||||||
onStart() {
|
onStart() {
|
||||||
if (this.data.isPaused) {
|
if (this.data.isPaused) {
|
||||||
this._timer.resume()
|
this._timer.resume()
|
||||||
|
// Re-assert: if the user backgrounded the app while paused, WeChat has
|
||||||
|
// already cleared the flag for us.
|
||||||
|
this._keepScreenOn(true)
|
||||||
this.setData({ isRunning: true, isPaused: false, status: 'running' })
|
this.setData({ isRunning: true, isPaused: false, status: 'running' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.data.isRunning) return
|
if (this.data.isRunning) return
|
||||||
|
// Screen stays awake from here until onUnload. Deliberately NOT turned
|
||||||
|
// off on pause (users pause to adjust posture — a screen blackout mid-set
|
||||||
|
// is worse than a few seconds of extra backlight) nor on the completion
|
||||||
|
// modal (they're reading their result).
|
||||||
|
this._keepScreenOn(true)
|
||||||
// 取消可能还在滚的 countUp,确保 remaining = duration
|
// 取消可能还在滚的 countUp,确保 remaining = duration
|
||||||
if (this._countUpTask) {
|
if (this._countUpTask) {
|
||||||
this._countUpTask.cancel()
|
this._countUpTask.cancel()
|
||||||
@@ -393,12 +718,23 @@ Page({
|
|||||||
|
|
||||||
// Save record + streak
|
// Save record + streak
|
||||||
const today = storage.getToday()
|
const today = storage.getToday()
|
||||||
storage.saveRecord({
|
const record = {
|
||||||
date: today,
|
date: today,
|
||||||
duration: elapsed,
|
duration: elapsed,
|
||||||
planId: storage.getSettings().planId,
|
// Circuit records use a special planId so getPlanDay() never counts
|
||||||
|
// them toward the daily-plan progress; leaderboard/stats aggregate by
|
||||||
|
// `duration`, so a circuit's effective held seconds count normally.
|
||||||
|
planId: this.data.isCircuitMode ? 'circuit' : storage.getSettings().planId,
|
||||||
day: this.data.planDay
|
day: this.data.planDay
|
||||||
})
|
}
|
||||||
|
if (this.data.isCircuitMode && this._circuitCfg) {
|
||||||
|
record.mode = 'circuit'
|
||||||
|
record.sets = this._circuitCfg.sets
|
||||||
|
record.holdPerSet = this._circuitCfg.holdPerSet
|
||||||
|
record.restPerSet = this._circuitCfg.restPerSet
|
||||||
|
record.sessionsPerWeek = this._circuitCfg.sessionsPerWeek
|
||||||
|
}
|
||||||
|
storage.saveRecord(record)
|
||||||
storage.updateStreak(today)
|
storage.updateStreak(today)
|
||||||
|
|
||||||
// Flag for the index page's "训练完成" highlight
|
// Flag for the index page's "训练完成" highlight
|
||||||
@@ -420,7 +756,9 @@ Page({
|
|||||||
completionElapsed: 0,
|
completionElapsed: 0,
|
||||||
completionActualElapsed: elapsed,
|
completionActualElapsed: elapsed,
|
||||||
completionTarget: this.data.duration,
|
completionTarget: this.data.duration,
|
||||||
completionOvertime: Math.max(0, elapsed - this.data.duration),
|
// Circuit mode has no "overtime" concept (each set is a fixed target)
|
||||||
|
// and `duration` here is just the last set's length, so hide it.
|
||||||
|
completionOvertime: this.data.isCircuitMode ? 0 : Math.max(0, elapsed - this.data.duration),
|
||||||
completionStreak: finalStreak,
|
completionStreak: finalStreak,
|
||||||
completionLevel: level,
|
completionLevel: level,
|
||||||
completionMessage: message
|
completionMessage: message
|
||||||
@@ -468,6 +806,13 @@ Page({
|
|||||||
this._completionCountUp.cancel()
|
this._completionCountUp.cancel()
|
||||||
this._completionCountUp = null
|
this._completionCountUp = null
|
||||||
}
|
}
|
||||||
|
// 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
|
||||||
|
// reset; _initCircuit also closes the completion modal).
|
||||||
|
if (this.data.isCircuitMode) {
|
||||||
|
this._initCircuit(this._initOptions)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Reset per-session state so the next run fires reminders/saves
|
// Reset per-session state so the next run fires reminders/saves
|
||||||
// correctly (previously these stayed set, muting cues on the 2nd run).
|
// correctly (previously these stayed set, muting cues on the 2nd run).
|
||||||
this._saving = false
|
this._saving = false
|
||||||
|
|||||||
+41
-3
@@ -15,6 +15,43 @@
|
|||||||
<text class="achievement-text">{{celebrationMessage}}</text>
|
<text class="achievement-text">{{celebrationMessage}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 循环训练进度面板:组点阵 + 第 X/Y 组 + 阶段徽章 + 累计秒数。
|
||||||
|
层次分工:这里管「第几组」(宏观),中间的进度环管「本组还剩几秒」(微观)。 -->
|
||||||
|
<view
|
||||||
|
class="circuit-bar {{isResting ? 'is-rest' : ''}} {{status === 'paused' ? 'is-paused' : ''}}"
|
||||||
|
wx:if="{{isCircuitMode && status !== 'completed'}}"
|
||||||
|
>
|
||||||
|
<!-- 组进度点阵:已完成 / 进行中 / 下一组 三态 -->
|
||||||
|
<view class="circuit-dots" wx:if="{{circuitShowDots}}">
|
||||||
|
<view
|
||||||
|
wx:for="{{circuitDots}}"
|
||||||
|
wx:key="*this"
|
||||||
|
class="circuit-dot {{item <= dotsDone ? 'is-done' : ''}} {{item === dotsActive ? 'is-active' : ''}} {{item === dotsNext ? 'is-next' : ''}}"
|
||||||
|
></view>
|
||||||
|
</view>
|
||||||
|
<!-- 组数过多(>15)时退化为线性进度条,避免点阵挤成头发丝 -->
|
||||||
|
<view class="circuit-track" wx:else>
|
||||||
|
<view class="circuit-track-fill" style="width: {{dotsDone * 100 / totalSets}}%"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="circuit-meta">
|
||||||
|
<view class="circuit-count">
|
||||||
|
<text class="circuit-count-num">{{status === 'idle' ? totalSets : currentSet}}</text>
|
||||||
|
<text class="circuit-count-total" wx:if="{{status !== 'idle'}}">/{{totalSets}}</text>
|
||||||
|
<text class="circuit-count-unit">组</text>
|
||||||
|
</view>
|
||||||
|
<view class="circuit-phase circuit-phase--{{status === 'paused' ? 'pause' : (status === 'idle' ? 'idle' : (isResting ? 'rest' : 'work'))}}">
|
||||||
|
<view class="circuit-phase-dot"></view>
|
||||||
|
<text class="circuit-phase-text">{{status === 'paused' ? '已暂停' : (status === 'idle' ? '准备开始' : (isResting ? '休息中' : '撑住'))}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="circuit-sub">
|
||||||
|
<block wx:if="{{status === 'idle'}}">每组 {{circuitHold}} 秒{{circuitRest > 0 ? ' · 休息 ' + circuitRest + ' 秒' : ''}}</block>
|
||||||
|
<block wx:else>已撑 {{workTotal}} 秒 · 目标 {{circuitTotalWork}} 秒</block>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 呼吸引导环 + 进度条 -->
|
<!-- 呼吸引导环 + 进度条 -->
|
||||||
<view class="ring-wrapper">
|
<view class="ring-wrapper">
|
||||||
<view class="breathe-ring breathe-ring-1 {{status === 'running' ? 'fast' : ''}}"
|
<view class="breathe-ring breathe-ring-1 {{status === 'running' ? 'fast' : ''}}"
|
||||||
@@ -34,7 +71,7 @@
|
|||||||
status="{{status}}"
|
status="{{status}}"
|
||||||
primaryColor="{{theme.primary}}"
|
primaryColor="{{theme.primary}}"
|
||||||
primaryLightColor="{{theme.primaryLight}}"
|
primaryLightColor="{{theme.primaryLight}}"
|
||||||
trackColor="{{elapsed >= duration ? '#00B578' : (isDark ? '#3A3A40' : '#EEEEEE')}}"
|
trackColor="{{isCircuitMode ? '#EEEEEE' : (elapsed >= duration ? '#00B578' : '#EEEEEE')}}"
|
||||||
></progress-ring>
|
></progress-ring>
|
||||||
|
|
||||||
<!-- 完成庆祝粒子 -->
|
<!-- 完成庆祝粒子 -->
|
||||||
@@ -53,7 +90,8 @@
|
|||||||
<view class="hint-row" wx:if="{{status === 'idle'}}">
|
<view class="hint-row" wx:if="{{status === 'idle'}}">
|
||||||
<image class="hint-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
|
<image class="hint-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
|
||||||
<text class="hint-text">
|
<text class="hint-text">
|
||||||
<block wx:if="{{isFreeMode}}">自由训练 · 目标 {{duration}} 秒</block>
|
<block wx:if="{{isCircuitMode}}">循环训练 · 共需撑 {{circuitTotalWork}} 秒</block>
|
||||||
|
<block wx:elif="{{isFreeMode}}">自由训练 · 目标 {{duration}} 秒</block>
|
||||||
<block wx:else>今日目标 {{duration}} 秒 · 第 {{planDay}} 天</block>
|
<block wx:else>今日目标 {{duration}} 秒 · 第 {{planDay}} 天</block>
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -63,7 +101,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="hint-row" wx:elif="{{status === 'running'}}">
|
<view class="hint-row" wx:elif="{{status === 'running'}}">
|
||||||
<image class="hint-icon running-pulse" src="{{icons.likeFill}}" mode="aspectFit"></image>
|
<image class="hint-icon running-pulse" src="{{icons.likeFill}}" mode="aspectFit"></image>
|
||||||
<text class="hint-text running">{{hintTip}}</text>
|
<text class="hint-text running">{{isCircuitMode ? phaseTip : hintTip}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="hint-row" wx:elif="{{status === 'paused'}}">
|
<view class="hint-row" wx:elif="{{status === 'paused'}}">
|
||||||
<image class="hint-icon" src="{{icons.notificationForbidFill}}" mode="aspectFit"></image>
|
<image class="hint-icon" src="{{icons.notificationForbidFill}}" mode="aspectFit"></image>
|
||||||
|
|||||||
@@ -9,6 +9,221 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- 循环训练进度面板 ----
|
||||||
|
注意:整块不设固定 height,靠 padding 撑开。真机大字体档下文字会被微信
|
||||||
|
放大,固定高度 + overflow 会把数字裁掉(项目里踩过这个坑)。 */
|
||||||
|
.circuit-bar {
|
||||||
|
/* 抬一层:呼吸光晕(.breathe-ring, z-index:0)在 DOM 里排在本面板之后,
|
||||||
|
不抬层会把橙色晕染盖到卡片上。 */
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 620rpx;
|
||||||
|
max-width: 86%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 26rpx 32rpx 22rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1rpx solid rgba(var(--primary-rgb), 0.14);
|
||||||
|
border-radius: 28rpx;
|
||||||
|
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.05);
|
||||||
|
transition: border-color 0.35s ease, box-shadow 0.35s ease;
|
||||||
|
animation: circuitBarIn 0.45s cubic-bezier(0.34, 1.4, 0.64, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 休息态整块降温:边框转成 success 绿,和"撑住"的主色态形成对比 */
|
||||||
|
.circuit-bar.is-rest {
|
||||||
|
border-color: rgba(var(--success-rgb), 0.32);
|
||||||
|
box-shadow: 0 6rpx 20rpx rgba(var(--success-rgb), 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes circuitBarIn {
|
||||||
|
0% { opacity: 0; transform: translateY(-16rpx); }
|
||||||
|
100% { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 组进度点阵 ---
|
||||||
|
整行铺满面板内宽,每段按组数严格等分(分段进度条式)。 */
|
||||||
|
.circuit-dots {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
/* 锁住行高:当前组比其它段高 6rpx,不锁行高会在 idle→running 时整块跳一下 */
|
||||||
|
min-height: 18rpx;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* flex:1 1 0 + min-width:0 → 每段等分剩余宽度,与内容无关。
|
||||||
|
不设 max-width:一旦封顶,组数少时点阵就只占左边一截(此前的问题)。
|
||||||
|
当前组不再靠"加宽"强调(那会破坏等分),改为增高+发光,宽度恒等分。 */
|
||||||
|
.circuit-dot {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
height: 12rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
transition: height 0.3s ease, background 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circuit-dot.is-done {
|
||||||
|
background: var(--primary);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 当前组:增高 + 渐变 + 发光呼吸,一眼锁定进度位置(宽度仍等分) */
|
||||||
|
.circuit-dot.is-active {
|
||||||
|
height: 18rpx;
|
||||||
|
border-radius: 9rpx;
|
||||||
|
opacity: 1;
|
||||||
|
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
||||||
|
box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.55);
|
||||||
|
animation: circuitDotPulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 休息时高亮"下一组",给用户一个即将开始的预告焦点 */
|
||||||
|
.circuit-dot.is-next {
|
||||||
|
height: 18rpx;
|
||||||
|
border-radius: 9rpx;
|
||||||
|
background: rgba(var(--success-rgb), 0.45);
|
||||||
|
animation: circuitDotPulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes circuitDotPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.55; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暂停时冻结所有呼吸动画,静止本身就是"已暂停"的信号 */
|
||||||
|
.circuit-bar.is-paused .circuit-dot {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 组数过多时的线性降级条 --- */
|
||||||
|
.circuit-track {
|
||||||
|
height: 12rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circuit-track-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 主信息行:大数字 + 阶段徽章 --- */
|
||||||
|
.circuit-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circuit-count {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circuit-count-num {
|
||||||
|
font-size: 56rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circuit-count-total {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1;
|
||||||
|
margin-left: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circuit-count-unit {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 阶段徽章 --- */
|
||||||
|
.circuit-phase {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
padding: 12rpx 24rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态指示灯用 CSS 画而非 icon:项目里的 SVG 图标颜色烤死在 data URI 里,
|
||||||
|
CSS 改不动;这里需要跟随状态换色,纯 CSS 圆点是唯一干净的做法。 */
|
||||||
|
.circuit-phase-dot {
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circuit-phase-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工作态:主色渐变实心 + 白灯白字 */
|
||||||
|
.circuit-phase--work {
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||||||
|
box-shadow: 0 4rpx 14rpx rgba(var(--primary-rgb), 0.32);
|
||||||
|
}
|
||||||
|
.circuit-phase--work .circuit-phase-dot {
|
||||||
|
background: #FFFFFF;
|
||||||
|
animation: phaseDotBlink 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.circuit-phase--work .circuit-phase-text { color: #FFFFFF; }
|
||||||
|
|
||||||
|
/* 休息态:绿色淡底,视觉降温 */
|
||||||
|
.circuit-phase--rest {
|
||||||
|
background: rgba(var(--success-rgb), 0.14);
|
||||||
|
}
|
||||||
|
.circuit-phase--rest .circuit-phase-dot {
|
||||||
|
background: var(--success);
|
||||||
|
animation: phaseDotBlink 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.circuit-phase--rest .circuit-phase-text { color: var(--success); }
|
||||||
|
|
||||||
|
/* 待机态 / 暂停态:中性灰,不抢视觉 */
|
||||||
|
.circuit-phase--idle,
|
||||||
|
.circuit-phase--pause {
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
.circuit-phase--idle .circuit-phase-dot,
|
||||||
|
.circuit-phase--pause .circuit-phase-dot {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.circuit-phase--idle .circuit-phase-text,
|
||||||
|
.circuit-phase--pause .circuit-phase-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes phaseDotBlink {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.4; transform: scale(0.8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 副信息行 --- */
|
||||||
|
.circuit-sub {
|
||||||
|
display: block;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- ring wrapper ---- */
|
/* ---- ring wrapper ---- */
|
||||||
.ring-wrapper {
|
.ring-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>首页按钮:圆角 vs 直角 对照预览</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #FF6B35;
|
||||||
|
--primary-light: #FF8C5A;
|
||||||
|
--primary-bg: #FFF3ED;
|
||||||
|
--primary-rgb: 255,107,53;
|
||||||
|
--text-secondary: #999999;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
|
||||||
|
background: #f4f5f7;
|
||||||
|
color: #222;
|
||||||
|
padding: 32px 16px 60px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
h1 { font-size: 20px; font-weight: 700; margin-bottom: 6px; }
|
||||||
|
.sub { font-size: 13px; color: #888; margin-bottom: 28px; text-align: center; line-height: 1.6; }
|
||||||
|
|
||||||
|
.compare {
|
||||||
|
display: flex;
|
||||||
|
gap: 28px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.col {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 24px 22px 28px;
|
||||||
|
width: 320px;
|
||||||
|
box-shadow: 0 6px 24px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.col h2 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.col .tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.col.round .tag { background: var(--primary-bg); color: var(--primary); }
|
||||||
|
.col.square .tag { background: #eee; color: #666; }
|
||||||
|
|
||||||
|
/* ---- button replica (matches ui-btn) ---- */
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.btn .dot { width: 18px; height: 18px; border-radius: 50%; background: currentColor; opacity: .85; }
|
||||||
|
|
||||||
|
.btn--xl { height: 54px; font-size: 18px; }
|
||||||
|
.btn--md { height: 40px; font-size: 15px; }
|
||||||
|
.btn--lg { height: 48px; font-size: 17px; }
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.3);
|
||||||
|
}
|
||||||
|
.btn--ghost {
|
||||||
|
background: rgba(var(--primary-rgb), 0.14);
|
||||||
|
color: var(--primary);
|
||||||
|
border: 1px solid rgba(var(--primary-rgb), 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the only difference that matters: */
|
||||||
|
.round .btn { border-radius: 24px; }
|
||||||
|
.square .btn { border-radius: 8px; } /* ≈16rpx */
|
||||||
|
|
||||||
|
/* two secondary buttons side by side */
|
||||||
|
.btn-row { display: flex; gap: 8px; }
|
||||||
|
.btn-row .btn { margin-bottom: 14px; }
|
||||||
|
|
||||||
|
.context-note {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-top: 1px dashed #eee;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.verdict {
|
||||||
|
margin-top: 30px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 660px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.verdict b { color: var(--primary); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>首页功能按钮 · 圆角 vs 直角</h1>
|
||||||
|
<p class="sub">左侧是小程序现状(圆角 48rpx),右侧是本次改后的效果(直角 0)。<br/>同一套按钮、同一套配色,方便你直接对比「好不好看」。</p>
|
||||||
|
|
||||||
|
<div class="compare">
|
||||||
|
<!-- 圆角现状 -->
|
||||||
|
<div class="col round">
|
||||||
|
<h2>现状 · 圆角</h2>
|
||||||
|
<span class="tag">border-radius: 48rpx</span>
|
||||||
|
|
||||||
|
<div class="btn btn--primary btn--xl"><span class="dot"></span>开始训练</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<div class="btn btn--ghost btn--xl"><span class="dot"></span>自由训练</div>
|
||||||
|
<div class="btn btn--ghost btn--md"><span class="dot"></span>循环训练</div>
|
||||||
|
</div>
|
||||||
|
<hr style="border:none;border-top:1px solid #f0f0f0;margin:18px 0 16px;">
|
||||||
|
<div class="btn btn--primary btn--lg">开始自由训练</div>
|
||||||
|
<div class="btn btn--primary btn--lg">保存并开始</div>
|
||||||
|
|
||||||
|
<div class="context-note">页面其余元素仍是圆角:卡片 24rpx、进度条 6rpx、目标环是正圆。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 直角改后 -->
|
||||||
|
<div class="col square">
|
||||||
|
<h2>改后 · 小圆角</h2>
|
||||||
|
<span class="tag">border-radius: 16rpx</span>
|
||||||
|
|
||||||
|
<div class="btn btn--primary btn--xl"><span class="dot"></span>开始训练</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<div class="btn btn--ghost btn--xl"><span class="dot"></span>自由训练</div>
|
||||||
|
<div class="btn btn--ghost btn--md"><span class="dot"></span>循环训练</div>
|
||||||
|
</div>
|
||||||
|
<hr style="border:none;border-top:1px solid #f0f0f0;margin:18px 0 16px;">
|
||||||
|
<div class="btn btn--primary btn--lg">开始自由训练</div>
|
||||||
|
<div class="btn btn--primary btn--lg">保存并开始</div>
|
||||||
|
|
||||||
|
<div class="context-note">仅首页这几个按钮变直角,全局组件与其他页面不受影响。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="verdict">
|
||||||
|
小圆角(16rpx)比 10rpx 更圆润一点,更接近卡片的 24rpx 圆角语言,但仍不是全胶囊,保留了清爽感。<br/>
|
||||||
|
已应用到首页 5 个按钮(仅首页、不动全局组件)。若还想微调,往上可贴近卡片的 24rpx、往下回到 10rpx,说一声即可。
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
const test = require('node:test')
|
const test = require('node:test')
|
||||||
const assert = require('node:assert/strict')
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
const { getBadgeProgress, getBadgeCelebration } = require('../utils/badge-state')
|
const {
|
||||||
|
getBadgeProgress,
|
||||||
|
getBadgePositions,
|
||||||
|
getBadgeSegments,
|
||||||
|
getBadgeDisplayLabel,
|
||||||
|
getBadgeCelebration
|
||||||
|
} = require('../utils/badge-state')
|
||||||
|
|
||||||
const badges = [{ days: 3 }, { days: 7 }, { days: 14 }]
|
const badges = [
|
||||||
|
{ days: 3, name: '抖登' },
|
||||||
|
{ days: 7, name: '中登' },
|
||||||
|
{ days: 14, name: '老登' },
|
||||||
|
{ days: 30, name: '神登' },
|
||||||
|
{ days: 60, name: '上古神登' },
|
||||||
|
{ days: 80, name: '洪荒神登' },
|
||||||
|
{ days: 100, name: '究极登祖' }
|
||||||
|
]
|
||||||
|
|
||||||
test('badge progress remains zero before the first milestone', () => {
|
test('badge progress remains zero before the first milestone', () => {
|
||||||
assert.equal(getBadgeProgress(badges, 1), 0)
|
assert.equal(getBadgeProgress(badges, 1), 0)
|
||||||
@@ -11,6 +25,30 @@ test('badge progress remains zero before the first milestone', () => {
|
|||||||
assert.equal(getBadgeProgress(badges, 3), 4)
|
assert.equal(getBadgeProgress(badges, 3), 4)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('milestones use equal spacing and overall fill stays aligned', () => {
|
||||||
|
assert.deepEqual(getBadgePositions(badges), [4, 19.3, 34.7, 50, 65.3, 80.7, 96])
|
||||||
|
assert.equal(getBadgeProgress(badges, 7), 19.3)
|
||||||
|
assert.equal(getBadgeProgress(badges, 60), 65.3)
|
||||||
|
assert.equal(getBadgeProgress(badges, 100), 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('only the active badge connection is partially green', () => {
|
||||||
|
assert.deepEqual(getBadgeSegments(badges, 16), [
|
||||||
|
{ left: 4, width: 15.3, progress: 100 },
|
||||||
|
{ left: 19.3, width: 15.4, progress: 100 },
|
||||||
|
{ left: 34.7, width: 15.3, progress: 12.5 },
|
||||||
|
{ left: 50, width: 15.3, progress: 0 },
|
||||||
|
{ left: 65.3, width: 15.4, progress: 0 },
|
||||||
|
{ left: 80.7, width: 15.3, progress: 0 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('equal spacing keeps full unlocked badge names readable', () => {
|
||||||
|
assert.equal(getBadgeDisplayLabel(badges[3], true), '神登')
|
||||||
|
assert.equal(getBadgeDisplayLabel(badges[4], true), '上古神登')
|
||||||
|
assert.equal(getBadgeDisplayLabel(badges[6], false), '100天')
|
||||||
|
})
|
||||||
|
|
||||||
test('badge celebration keeps prior milestones seen after records are deleted', () => {
|
test('badge celebration keeps prior milestones seen after records are deleted', () => {
|
||||||
const afterDeletion = getBadgeCelebration([3], [])
|
const afterDeletion = getBadgeCelebration([3], [])
|
||||||
assert.deepEqual(afterDeletion, { newly: [], seen: [3] })
|
assert.deepEqual(afterDeletion, { newly: [], seen: [3] })
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const { getTrendData, getBestTrendData, getTrendSummary, getBarPercent } = require('../utils/util')
|
||||||
|
|
||||||
|
test('weekly trend combines multiple sessions from the same day', () => {
|
||||||
|
const records = {
|
||||||
|
'2026-07': [
|
||||||
|
{ date: '2026-07-29 08:00:00', duration: 30 },
|
||||||
|
{ date: '2026-07-29 20:00:00', duration: 45 },
|
||||||
|
{ date: '2026-07-23 08:00:00', duration: 60 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(getTrendData(records, 'week', new Date(2026, 6, 29)), [
|
||||||
|
{ label: '四', value: 60, valueText: '01:00', highlight: false },
|
||||||
|
{ label: '五', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '六', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '日', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '一', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '二', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '今天', value: 75, valueText: '01:15', highlight: true }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('monthly trend spans a year boundary from a single record snapshot', () => {
|
||||||
|
const records = {
|
||||||
|
'2025-08': [{ date: '2025-08-31 08:00:00', duration: 60 }],
|
||||||
|
'2025-12': [{ date: '2025-12-01 08:00:00', duration: 120 }],
|
||||||
|
'2026-01': [{ date: '2026-01-02 08:00:00', duration: 3600 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(getTrendData(records, 'month', new Date(2026, 0, 15)), [
|
||||||
|
{ label: '8月', value: 60, valueText: '01:00', highlight: false },
|
||||||
|
{ label: '9月', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '10月', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '11月', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '12月', value: 120, valueText: '02:00', highlight: false },
|
||||||
|
{ label: '1月', value: 3600, valueText: '60m', highlight: true }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bar height remains proportional for very short sessions', () => {
|
||||||
|
assert.equal(getBarPercent(0, 120), 0)
|
||||||
|
assert.ok(getBarPercent(1, 120) < 1)
|
||||||
|
assert.equal(getBarPercent(120, 120), 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('trend summary reports total, active periods, and the highest period', () => {
|
||||||
|
const summary = getTrendSummary([
|
||||||
|
{ label: '一', value: 60 },
|
||||||
|
{ label: '二', value: 0 },
|
||||||
|
{ label: '今天', value: 75 }
|
||||||
|
], 'week')
|
||||||
|
|
||||||
|
assert.deepEqual(summary, {
|
||||||
|
totalText: '02:15',
|
||||||
|
activeCount: 2,
|
||||||
|
activeLabel: '训练天数',
|
||||||
|
peakTitle: '最高训练日',
|
||||||
|
peakText: '今天 · 01:15'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('personal best trend keeps the longest single session in each period', () => {
|
||||||
|
const records = {
|
||||||
|
'2026-07': [
|
||||||
|
{ date: '2026-07-29 08:00:00', duration: 30 },
|
||||||
|
{ date: '2026-07-29 20:00:00', duration: 45 },
|
||||||
|
{ date: '2026-07-23 08:00:00', duration: 60 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = getBestTrendData(records, 'week', new Date(2026, 6, 29))
|
||||||
|
assert.equal(data[0].value, 60)
|
||||||
|
assert.equal(data[6].value, 45)
|
||||||
|
assert.equal(data[6].valueText, '00:45')
|
||||||
|
})
|
||||||
+46
-3
@@ -1,5 +1,15 @@
|
|||||||
const TRACK_PAD = 4
|
const TRACK_PAD = 4
|
||||||
|
|
||||||
|
const roundPosition = (value) => Math.round(value * 10) / 10
|
||||||
|
|
||||||
|
const getBadgePositions = (badges) => {
|
||||||
|
const defs = Array.isArray(badges) ? badges : []
|
||||||
|
if (defs.length === 0) return []
|
||||||
|
if (defs.length === 1) return [TRACK_PAD]
|
||||||
|
const segmentWidth = (100 - TRACK_PAD * 2) / (defs.length - 1)
|
||||||
|
return defs.map((_, index) => roundPosition(TRACK_PAD + index * segmentWidth))
|
||||||
|
}
|
||||||
|
|
||||||
const getBadgeProgress = (badges, totalDays) => {
|
const getBadgeProgress = (badges, totalDays) => {
|
||||||
const defs = Array.isArray(badges) ? badges : []
|
const defs = Array.isArray(badges) ? badges : []
|
||||||
if (defs.length === 0) return 0
|
if (defs.length === 0) return 0
|
||||||
@@ -8,12 +18,39 @@ const getBadgeProgress = (badges, totalDays) => {
|
|||||||
if (days < defs[0].days) return 0
|
if (days < defs[0].days) return 0
|
||||||
if (defs.length === 1 || days >= defs[defs.length - 1].days) return 100
|
if (defs.length === 1 || days >= defs[defs.length - 1].days) return 100
|
||||||
|
|
||||||
const segmentWidth = (100 - TRACK_PAD * 2) / (defs.length - 1)
|
const positions = getBadgePositions(defs)
|
||||||
let index = 0
|
let index = 0
|
||||||
while (index < defs.length - 1 && days >= defs[index + 1].days) index++
|
while (index < defs.length - 1 && days >= defs[index + 1].days) index++
|
||||||
|
|
||||||
const ratio = (days - defs[index].days) / (defs[index + 1].days - defs[index].days)
|
const ratio = (days - defs[index].days) / (defs[index + 1].days - defs[index].days)
|
||||||
return Math.round((TRACK_PAD + index * segmentWidth + ratio * segmentWidth) * 10) / 10
|
return roundPosition(positions[index] + ratio * (positions[index + 1] - positions[index]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBadgeSegments = (badges, totalDays) => {
|
||||||
|
const defs = Array.isArray(badges) ? badges : []
|
||||||
|
const positions = getBadgePositions(defs)
|
||||||
|
const days = Math.max(0, Number(totalDays) || 0)
|
||||||
|
|
||||||
|
return defs.slice(0, -1).map((badge, index) => {
|
||||||
|
const nextBadge = defs[index + 1]
|
||||||
|
const segmentDays = nextBadge.days - badge.days
|
||||||
|
const progress = days <= badge.days
|
||||||
|
? 0
|
||||||
|
: days >= nextBadge.days
|
||||||
|
? 100
|
||||||
|
: roundPosition((days - badge.days) / segmentDays * 100)
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: positions[index],
|
||||||
|
width: roundPosition(positions[index + 1] - positions[index]),
|
||||||
|
progress
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBadgeDisplayLabel = (badge, unlocked) => {
|
||||||
|
if (!badge) return ''
|
||||||
|
return unlocked && badge.name ? badge.name : `${badge.days}天`
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBadgeCelebration = (previous, unlockedDays) => {
|
const getBadgeCelebration = (previous, unlockedDays) => {
|
||||||
@@ -25,4 +62,10 @@ const getBadgeCelebration = (previous, unlockedDays) => {
|
|||||||
return { newly, seen: Array.from(new Set(previous.concat(unlocked))) }
|
return { newly, seen: Array.from(new Set(previous.concat(unlocked))) }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getBadgeProgress, getBadgeCelebration }
|
module.exports = {
|
||||||
|
getBadgeProgress,
|
||||||
|
getBadgePositions,
|
||||||
|
getBadgeSegments,
|
||||||
|
getBadgeDisplayLabel,
|
||||||
|
getBadgeCelebration
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Circuit (循环) training timer — a finite state machine driving
|
||||||
|
* work / rest intervals across multiple sets.
|
||||||
|
*
|
||||||
|
* idle → working(set 1) → resting → working(set 2) → resting → … → done
|
||||||
|
*
|
||||||
|
* Distinct from utils/timer.js (which counts a single continuous hold up to
|
||||||
|
* a goal and then keeps going until the user stops). A circuit instead has
|
||||||
|
* discrete sets with rest gaps, so it needs its own FSM.
|
||||||
|
*
|
||||||
|
* `workTotal` (effective held seconds, EXCLUDING rest) is what we persist as
|
||||||
|
* the record's `duration` — same meaning as Timer.stop()'s return value — so
|
||||||
|
* the leaderboard / stats aggregation needs zero changes.
|
||||||
|
*
|
||||||
|
* Mirrors utils/timer.js: 1s interval driven by Date.now() (drift-free),
|
||||||
|
* plus a wx.onAppShow hook so the UI snaps to the real elapsed time when the
|
||||||
|
* mini-program returns from background (WeChat freezes setInterval there).
|
||||||
|
*/
|
||||||
|
class CircuitTimer {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.holdPerSet = Math.max(1, parseInt(options.holdPerSet) || 30)
|
||||||
|
this.sets = Math.max(1, parseInt(options.sets) || 1)
|
||||||
|
this.restPerSet = Math.max(0, parseInt(options.restPerSet) || 0)
|
||||||
|
|
||||||
|
this.onTick = options.onTick || (() => {})
|
||||||
|
this.onPhaseChange = options.onPhaseChange || (() => {})
|
||||||
|
this.onComplete = options.onComplete || (() => {})
|
||||||
|
|
||||||
|
this._intervalId = null
|
||||||
|
this._running = false
|
||||||
|
this._paused = false
|
||||||
|
this._appShowBound = false
|
||||||
|
|
||||||
|
this.reset()
|
||||||
|
|
||||||
|
this._onAppShow = () => {
|
||||||
|
if (this._running && !this._paused) this._tick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.phase = 'idle' // idle | working | resting | done
|
||||||
|
this.setIndex = 0 // 1-based current set (0 before start)
|
||||||
|
this.phaseRemaining = this.holdPerSet
|
||||||
|
this.phaseElapsed = 0
|
||||||
|
this.workTotal = 0 // effective held seconds (excludes rest)
|
||||||
|
this._completedSets = 0
|
||||||
|
this._phaseStart = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.phase === 'idle') {
|
||||||
|
this.phase = 'working'
|
||||||
|
this.setIndex = 1
|
||||||
|
this.phaseRemaining = this.holdPerSet
|
||||||
|
this.phaseElapsed = 0
|
||||||
|
this._completedSets = 0
|
||||||
|
this._emitPhaseChange()
|
||||||
|
}
|
||||||
|
this._running = true
|
||||||
|
this._paused = false
|
||||||
|
this._phaseStart = Date.now() - this.phaseElapsed * 1000
|
||||||
|
this._tick()
|
||||||
|
this._intervalId = setInterval(() => this._tick(), 1000)
|
||||||
|
this._bindAppShow()
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
if (!this._running || this._paused) return
|
||||||
|
this._paused = true
|
||||||
|
clearInterval(this._intervalId)
|
||||||
|
this._intervalId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
resume() {
|
||||||
|
if (!this._running || !this._paused) return
|
||||||
|
this._paused = false
|
||||||
|
this._phaseStart = Date.now() - this.phaseElapsed * 1000
|
||||||
|
this._tick()
|
||||||
|
this._intervalId = setInterval(() => this._tick(), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this._running = false
|
||||||
|
this._paused = false
|
||||||
|
clearInterval(this._intervalId)
|
||||||
|
this._intervalId = null
|
||||||
|
this._unbindAppShow()
|
||||||
|
// Effective held seconds — same role as Timer.stop()'s `elapsed`.
|
||||||
|
return this.workTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
_tick() {
|
||||||
|
if (!this._running || this._paused) return
|
||||||
|
const cap = this.phase === 'working' ? this.holdPerSet : this.restPerSet
|
||||||
|
const elapsed = Math.floor((Date.now() - this._phaseStart) / 1000)
|
||||||
|
this.phaseElapsed = elapsed
|
||||||
|
this.phaseRemaining = Math.max(0, cap - elapsed)
|
||||||
|
|
||||||
|
if (this.phase === 'working') {
|
||||||
|
// Total held seconds = fully-completed sets + current set progress.
|
||||||
|
this.workTotal = this._completedSets * this.holdPerSet + elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
this._emitTick()
|
||||||
|
|
||||||
|
if (this.phaseRemaining <= 0) {
|
||||||
|
this._advance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_advance() {
|
||||||
|
if (this.phase === 'working') {
|
||||||
|
this._completedSets += 1
|
||||||
|
if (this._completedSets >= this.sets) {
|
||||||
|
// All sets done.
|
||||||
|
this.phase = 'done'
|
||||||
|
this.setIndex = this.sets
|
||||||
|
this._running = false
|
||||||
|
clearInterval(this._intervalId)
|
||||||
|
this._intervalId = null
|
||||||
|
this._unbindAppShow()
|
||||||
|
this.onComplete({
|
||||||
|
workTotal: this.workTotal,
|
||||||
|
sets: this.sets,
|
||||||
|
holdPerSet: this.holdPerSet
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Move to rest (if any) or straight into the next set.
|
||||||
|
if (this.restPerSet > 0) {
|
||||||
|
this.phase = 'resting'
|
||||||
|
} else {
|
||||||
|
this.phase = 'working'
|
||||||
|
this.setIndex += 1
|
||||||
|
}
|
||||||
|
this.phaseElapsed = 0
|
||||||
|
this.phaseRemaining = this.phase === 'resting' ? this.restPerSet : this.holdPerSet
|
||||||
|
this._phaseStart = Date.now()
|
||||||
|
this._emitPhaseChange()
|
||||||
|
this._emitTick()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.phase === 'resting') {
|
||||||
|
this.phase = 'working'
|
||||||
|
this.setIndex += 1
|
||||||
|
this.phaseElapsed = 0
|
||||||
|
this.phaseRemaining = this.holdPerSet
|
||||||
|
this._phaseStart = Date.now()
|
||||||
|
this._emitPhaseChange()
|
||||||
|
this._emitTick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_emitTick() {
|
||||||
|
this.onTick({
|
||||||
|
phase: this.phase,
|
||||||
|
setIndex: this.setIndex,
|
||||||
|
sets: this.sets,
|
||||||
|
phaseRemaining: this.phaseRemaining,
|
||||||
|
phaseElapsed: this.phaseElapsed,
|
||||||
|
holdPerSet: this.holdPerSet,
|
||||||
|
restPerSet: this.restPerSet,
|
||||||
|
workTotal: this.workTotal
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_emitPhaseChange() {
|
||||||
|
this.onPhaseChange({
|
||||||
|
phase: this.phase,
|
||||||
|
setIndex: this.setIndex,
|
||||||
|
sets: this.sets,
|
||||||
|
isLastSet: this.setIndex >= this.sets
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindAppShow() {
|
||||||
|
if (this._appShowBound || typeof wx.onAppShow !== 'function') return
|
||||||
|
try {
|
||||||
|
wx.onAppShow(this._onAppShow)
|
||||||
|
this._appShowBound = true
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
_unbindAppShow() {
|
||||||
|
if (!this._appShowBound || typeof wx.offAppShow !== 'function') return
|
||||||
|
try {
|
||||||
|
wx.offAppShow(this._onAppShow)
|
||||||
|
} catch (e) {}
|
||||||
|
this._appShowBound = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CircuitTimer
|
||||||
@@ -43,6 +43,9 @@ const PATHS = {
|
|||||||
pauseFill: '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>',
|
pauseFill: '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>',
|
||||||
time: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" fill-rule="evenodd"/>',
|
time: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" fill-rule="evenodd"/>',
|
||||||
timeFill: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>',
|
timeFill: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>',
|
||||||
|
// Loop / repeat (circuit training entry)
|
||||||
|
repeat: '<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>',
|
||||||
|
repeatFill: '<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>',
|
||||||
// Markers & targets
|
// Markers & targets
|
||||||
target: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" fill-rule="evenodd"/><circle cx="12" cy="12" r="5" fill-rule="evenodd"/><circle cx="12" cy="12" r="2"/>',
|
target: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" fill-rule="evenodd"/><circle cx="12" cy="12" r="5" fill-rule="evenodd"/><circle cx="12" cy="12" r="2"/>',
|
||||||
targetFill: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>',
|
targetFill: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const SETTINGS_KEY = 'user_settings'
|
|||||||
const STREAK_KEY = 'current_streak'
|
const STREAK_KEY = 'current_streak'
|
||||||
const CUSTOM_PLANS_KEY = 'custom_plans'
|
const CUSTOM_PLANS_KEY = 'custom_plans'
|
||||||
const PROFILE_KEY = 'user_profile'
|
const PROFILE_KEY = 'user_profile'
|
||||||
|
const CIRCUIT_CONFIG_KEY = 'circuit_config'
|
||||||
|
|
||||||
let _idSeq = 0
|
let _idSeq = 0
|
||||||
|
|
||||||
@@ -230,6 +231,53 @@ const saveProfile = (profile) => {
|
|||||||
|
|
||||||
const getStreak = () => wx.getStorageSync(STREAK_KEY) || { count: 0, lastDate: '' }
|
const getStreak = () => wx.getStorageSync(STREAK_KEY) || { count: 0, lastDate: '' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circuit (循环) training configuration — persisted locally (device-only,
|
||||||
|
* NOT cloud-synced; it's a per-device training preference, not account
|
||||||
|
* data, so no cloud.pushAll()). This is the "持久化" the simplified
|
||||||
|
* circuit-train feature needs: the user's { 每组时长 / 组数 / 休息 / 每周次数 }
|
||||||
|
* survives restarts and pre-fills the config panel next time.
|
||||||
|
*
|
||||||
|
* Shape: {
|
||||||
|
* holdPerSet: number // seconds held per set (>=5)
|
||||||
|
* sets: number // sets per session (>=1)
|
||||||
|
* restPerSet: number // rest seconds between sets (>=0; 0 = none)
|
||||||
|
* sessionsPerWeek: number // weekly target (>=1) — DISPLAY ONLY in the
|
||||||
|
* simplified version, no progress tracking yet
|
||||||
|
* }
|
||||||
|
* Any missing / out-of-range field falls back to a sensible default, so a
|
||||||
|
* corrupt or partial config never crashes the timer.
|
||||||
|
*/
|
||||||
|
const DEFAULT_CIRCUIT_CONFIG = { holdPerSet: 30, sets: 4, restPerSet: 15, sessionsPerWeek: 3 }
|
||||||
|
|
||||||
|
const _clampInt = (v, min, max, fallback) => {
|
||||||
|
const n = parseInt(v)
|
||||||
|
if (!Number.isFinite(n)) return fallback
|
||||||
|
return Math.max(min, Math.min(max, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCircuitConfig = () => {
|
||||||
|
const raw = wx.getStorageSync(CIRCUIT_CONFIG_KEY)
|
||||||
|
if (!raw || typeof raw !== 'object') return { ...DEFAULT_CIRCUIT_CONFIG }
|
||||||
|
return {
|
||||||
|
holdPerSet: _clampInt(raw.holdPerSet, 5, 600, DEFAULT_CIRCUIT_CONFIG.holdPerSet),
|
||||||
|
sets: _clampInt(raw.sets, 1, 50, DEFAULT_CIRCUIT_CONFIG.sets),
|
||||||
|
restPerSet: _clampInt(raw.restPerSet, 0, 600, DEFAULT_CIRCUIT_CONFIG.restPerSet),
|
||||||
|
sessionsPerWeek: _clampInt(raw.sessionsPerWeek, 1, 14, DEFAULT_CIRCUIT_CONFIG.sessionsPerWeek)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCircuitConfig = (cfg) => {
|
||||||
|
if (!cfg || typeof cfg !== 'object') return
|
||||||
|
const clean = {
|
||||||
|
holdPerSet: _clampInt(cfg.holdPerSet, 5, 600, DEFAULT_CIRCUIT_CONFIG.holdPerSet),
|
||||||
|
sets: _clampInt(cfg.sets, 1, 50, DEFAULT_CIRCUIT_CONFIG.sets),
|
||||||
|
restPerSet: _clampInt(cfg.restPerSet, 0, 600, DEFAULT_CIRCUIT_CONFIG.restPerSet),
|
||||||
|
sessionsPerWeek: _clampInt(cfg.sessionsPerWeek, 1, 14, DEFAULT_CIRCUIT_CONFIG.sessionsPerWeek)
|
||||||
|
}
|
||||||
|
wx.setStorageSync(CIRCUIT_CONFIG_KEY, clean)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-customized training plans, keyed by planId (e.g. 'beginner').
|
* User-customized training plans, keyed by planId (e.g. 'beginner').
|
||||||
* Each value is a full plan object built by `utils/plan.generatePlanDays`
|
* Each value is a full plan object built by `utils/plan.generatePlanDays`
|
||||||
@@ -474,6 +522,8 @@ module.exports = {
|
|||||||
getProfile,
|
getProfile,
|
||||||
saveProfile,
|
saveProfile,
|
||||||
getStreak,
|
getStreak,
|
||||||
|
getCircuitConfig,
|
||||||
|
saveCircuitConfig,
|
||||||
validateStreak,
|
validateStreak,
|
||||||
recomputeStreak,
|
recomputeStreak,
|
||||||
updateStreak,
|
updateStreak,
|
||||||
|
|||||||
+124
@@ -54,6 +54,126 @@ const formatDate = (date) => {
|
|||||||
/** Strip the time portion of a formatDate string → "YYYY-MM-DD". */
|
/** Strip the time portion of a formatDate string → "YYYY-MM-DD". */
|
||||||
const _dateOnly = (s) => (typeof s === 'string' && s.length >= 10) ? s.substring(0, 10) : ''
|
const _dateOnly = (s) => (typeof s === 'string' && s.length >= 10) ? s.substring(0, 10) : ''
|
||||||
|
|
||||||
|
const _normalizeDuration = (value) => Math.max(0, Math.floor(Number(value) || 0))
|
||||||
|
|
||||||
|
const _formatTrendDuration = (seconds) => {
|
||||||
|
const duration = _normalizeDuration(seconds)
|
||||||
|
if (duration >= 3600) return `${Math.floor(duration / 60)}m`
|
||||||
|
return formatTime(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build daily or monthly duration totals from one training-record snapshot. */
|
||||||
|
const getTrendData = (recordGroups, mode, now = new Date()) => {
|
||||||
|
const dateTotals = {}
|
||||||
|
const groups = recordGroups && typeof recordGroups === 'object' ? recordGroups : {}
|
||||||
|
|
||||||
|
Object.values(groups).forEach((records) => {
|
||||||
|
if (!Array.isArray(records)) return
|
||||||
|
records.forEach((record) => {
|
||||||
|
const date = _dateOnly(record && record.date)
|
||||||
|
if (!date) return
|
||||||
|
dateTotals[date] = (dateTotals[date] || 0) + _normalizeDuration(record.duration)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const today = new Date(now)
|
||||||
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
|
if (mode === 'month') {
|
||||||
|
const monthTotals = {}
|
||||||
|
Object.keys(dateTotals).forEach((date) => {
|
||||||
|
const month = date.substring(0, 7)
|
||||||
|
monthTotals[month] = (monthTotals[month] || 0) + dateTotals[date]
|
||||||
|
})
|
||||||
|
return Array.from({ length: 6 }, (_, index) => {
|
||||||
|
const offset = 5 - index
|
||||||
|
const date = new Date(today.getFullYear(), today.getMonth() - offset, 1)
|
||||||
|
const monthKey = `${date.getFullYear()}-${pad(date.getMonth() + 1)}`
|
||||||
|
const value = monthTotals[monthKey] || 0
|
||||||
|
return {
|
||||||
|
label: `${date.getMonth() + 1}月`,
|
||||||
|
value,
|
||||||
|
valueText: value > 0 ? _formatTrendDuration(value) : '',
|
||||||
|
highlight: offset === 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from({ length: 7 }, (_, index) => {
|
||||||
|
const offset = 6 - index
|
||||||
|
const date = new Date(today)
|
||||||
|
date.setDate(date.getDate() - offset)
|
||||||
|
const dateKey = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
|
const value = dateTotals[dateKey] || 0
|
||||||
|
return {
|
||||||
|
label: offset === 0 ? '今天' : weekdays[date.getDay()],
|
||||||
|
value,
|
||||||
|
valueText: value > 0 ? _formatTrendDuration(value) : '',
|
||||||
|
highlight: offset === 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBestTrendData = (recordGroups, mode, now = new Date()) => {
|
||||||
|
const peaks = {}
|
||||||
|
const groups = recordGroups && typeof recordGroups === 'object' ? recordGroups : {}
|
||||||
|
Object.values(groups).forEach((records) => {
|
||||||
|
if (!Array.isArray(records)) return
|
||||||
|
records.forEach((record) => {
|
||||||
|
const date = _dateOnly(record && record.date)
|
||||||
|
if (!date) return
|
||||||
|
const key = mode === 'month' ? date.substring(0, 7) : date
|
||||||
|
peaks[key] = Math.max(peaks[key] || 0, _normalizeDuration(record.duration))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const today = new Date(now)
|
||||||
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
const count = mode === 'month' ? 6 : 7
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
const offset = count - 1 - index
|
||||||
|
const date = mode === 'month'
|
||||||
|
? new Date(today.getFullYear(), today.getMonth() - offset, 1)
|
||||||
|
: new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset)
|
||||||
|
const key = mode === 'month'
|
||||||
|
? `${date.getFullYear()}-${pad(date.getMonth() + 1)}`
|
||||||
|
: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
|
const value = peaks[key] || 0
|
||||||
|
return {
|
||||||
|
label: mode === 'month' ? `${date.getMonth() + 1}月` : offset === 0 ? '今天' : weekdays[date.getDay()],
|
||||||
|
value,
|
||||||
|
valueText: value > 0 ? _formatTrendDuration(value) : '',
|
||||||
|
highlight: offset === 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBarPercent = (value, max) => {
|
||||||
|
const amount = _normalizeDuration(value)
|
||||||
|
const maximum = _normalizeDuration(max)
|
||||||
|
return maximum > 0 ? amount / maximum * 100 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTrendSummary = (trendData, mode) => {
|
||||||
|
const items = Array.isArray(trendData) ? trendData : []
|
||||||
|
const total = items.reduce((sum, item) => sum + _normalizeDuration(item && item.value), 0)
|
||||||
|
const activeItems = items.filter(item => _normalizeDuration(item && item.value) > 0)
|
||||||
|
const peak = activeItems.reduce((current, item) => {
|
||||||
|
return !current || _normalizeDuration(item.value) > _normalizeDuration(current.value) ? item : current
|
||||||
|
}, null)
|
||||||
|
const isMonth = mode === 'month'
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalText: _formatTrendDuration(total),
|
||||||
|
activeCount: activeItems.length,
|
||||||
|
activeLabel: isMonth ? '活跃月份' : '训练天数',
|
||||||
|
peakTitle: isMonth ? '最高训练月' : '最高训练日',
|
||||||
|
peakText: peak ? `${peak.label} · ${_formatTrendDuration(peak.value)}` : '暂无训练数据'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a local file path (typically `wxfile://...` from chooseAvatar) to a
|
* Convert a local file path (typically `wxfile://...` from chooseAvatar) to a
|
||||||
* base64 data URI. Used to make temporary file URLs survive the WeChat
|
* base64 data URI. Used to make temporary file URLs survive the WeChat
|
||||||
@@ -101,5 +221,9 @@ module.exports = {
|
|||||||
getMonthCalendar,
|
getMonthCalendar,
|
||||||
formatDate,
|
formatDate,
|
||||||
dateOnly: _dateOnly,
|
dateOnly: _dateOnly,
|
||||||
|
getTrendData,
|
||||||
|
getBestTrendData,
|
||||||
|
getTrendSummary,
|
||||||
|
getBarPercent,
|
||||||
fileToDataURI
|
fileToDataURI
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-3
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Voice prompt utility for the timer.
|
* Voice prompt utility for the timer.
|
||||||
*
|
*
|
||||||
* 4 fixed, polished prompts live in `cloudfunctions/tts`. We call that
|
* A fixed set of polished prompts lives in `cloudfunctions/tts` (PROMPTS
|
||||||
|
* below must stay in sync with the cloud function's copy). We call that
|
||||||
* cloud function with a key, get back an audio URL and permanent fileID,
|
* cloud function with a key, get back an audio URL and permanent fileID,
|
||||||
* and play it via createInnerAudioContext.
|
* and play it via createInnerAudioContext.
|
||||||
*
|
*
|
||||||
@@ -25,9 +26,19 @@ const PROMPTS = {
|
|||||||
last30: '最后30秒,保持呼吸,稳住姿势!',
|
last30: '最后30秒,保持呼吸,稳住姿势!',
|
||||||
last10: '最后10秒,再加把劲!',
|
last10: '最后10秒,再加把劲!',
|
||||||
goal: '目标达成,继续挑战!',
|
goal: '目标达成,继续挑战!',
|
||||||
complete: '太棒了!今天的目标已完成,继续加油!'
|
complete: '太棒了!今天的目标已完成,继续加油!',
|
||||||
|
// --- 循环训练(circuit)阶段切换专用 ---
|
||||||
|
// 刻意不含组号数字,这样固定 3 条就能覆盖任意组数,无需按 N 合成 N 份音频。
|
||||||
|
restStart: '休息一下,调整呼吸',
|
||||||
|
nextSet: '下一组,准备开始',
|
||||||
|
lastSet: '最后一组,全力冲刺!'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 单组(hold)与循环(circuit)各自需要的词条。preload 按模式取,避免
|
||||||
|
// 单组训练白白预载 3 条循环语音(每条一次云函数调用 + 一次下载)。
|
||||||
|
const HOLD_KEYS = ['start', 'halfway', 'last30', 'last10', 'goal', 'complete']
|
||||||
|
const CIRCUIT_KEYS = ['start', 'halfway', 'last30', 'last10', 'complete', 'restStart', 'nextSet', 'lastSet']
|
||||||
|
|
||||||
const FILEID_CACHE_KEY = 'tts_fileid_cache'
|
const FILEID_CACHE_KEY = 'tts_fileid_cache'
|
||||||
const LOCAL_CACHE_KEY = 'tts_local_cache' // { cacheKey: savedFilePath } - 持久本地文件,跨重启
|
const LOCAL_CACHE_KEY = 'tts_local_cache' // { cacheKey: savedFilePath } - 持久本地文件,跨重启
|
||||||
|
|
||||||
@@ -238,4 +249,4 @@ const destroy = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { play, preload, stop, destroy, PROMPTS }
|
module.exports = { play, preload, stop, destroy, PROMPTS, HOLD_KEYS, CIRCUIT_KEYS }
|
||||||
|
|||||||
Reference in New Issue
Block a user