Compare commits

..

12 Commits

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

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

修复:4 个 tab 页 onShow 里 tb.setData({selected:X}) 后加
tb.updateTheme(),显式同步主题。updateTheme 内部有样式未变
则跳过的优化,不会多余渲染。
2026-08-14 08:45:19 +08:00
lc b6d94b21cd fix(timer): 分段弧光点居中置顶 + 拉大与进度弧间距
- 光点移到段中点(am=(a0+a1)/2),两端粗细对称;重构为两遍绘制,
  光点统一后绘浮于所有弧之上,不再被相邻圆头吞掉;加大加亮成珠状
- glowPad 6→18 与 _drawSetArcs pad 同步,arcR 外移 3→14,
  分段弧与进度弧间距 -1 → +10dpr(size=420),呼吸光环不重叠
2026-08-12 10:34:54 +08:00
lc f1ad3eeb95 feat(timer): 训练时长低于阈值不记录并明确提示
- config.js 新增 minRecordSeconds: 20 阈值配置,改配置即可调整门槛
- timer.js: finishTraining/_onCircuitComplete 低时长不播完成语音;
  _saveAndShowCompletion 兜底判断,任何入口都不会把短时训练落库;
  新增 _showNotRecorded: 不保存记录、不更新连胜,复用完成弹窗展示实际秒数
- timer.wxml/wxss: 完成弹窗新增"未计入记录"提示行(门槛说明),
  未记录时隐藏"查看记录/分享"按钮,只留"再来一次+回到首页"
- 修复状态残留: 未记录后点"再来一次",下次正常完成不会误显示"未计入记录"
2026-08-12 10:20:40 +08:00
lc 178a51d261 docs(memory): 记录 GitHub 推送 HTTP/1.1 解法 2026-08-12 09:41:27 +08:00
lc 05c4763e49 feat(timer): 循环训练组进度改为圆环分段弧 + 信息气泡三行合一
- progress-ring 新增 sets/currentSet/resting/successColor 属性,
  圆环外圈绘制分段弧(已完成=主色/当前=呼吸光点/休息下一组=绿),单组模式零渲染
- timer 删除顶部 circuit-bar 进度面板,布局骨架三模式统一
- 呼吸环淡化(描边 0.55→0.20,柔光渐变强度减半)
- 目标卡与提示气泡合并为 info-bubble,状态文案(撑住/休息中/已暂停)并入气泡顶部
2026-08-12 09:39:41 +08:00
lc 0464eeaba6 feat(timer): 训练页改版 hero 风格 + 进度环刻度 + 醒目姿态提示气泡
- progress-ring: canvas 新增秒刻度层(四分位加粗), 环内加 subText, 移除组件内 statusText
- timer.wxml: 环下方状态词 + 目标信息卡(今日目标·第N天›静态箭头) + 运行提示; 进度环传 ticks/subText
- timer.js: 派生 ringTicks/ringSubText/statusText/goalLine/runningTip/tipType/tipIcon, 各生命周期计算下发
- timer.wxss: 按钮贴底(flex-start+margin-top:auto), 新增 .timer-status/.goal-card/.timer-tip 气泡卡片(主色左边框+小三角+tipPulse 呼吸), 文字放大加粗
- 姿态提示分语义: posture/phase(主色) / done(成功色), 图标用 peopleFill/successFill
- 状态机/计时/循环FSM/呼吸环/彩带/弹窗/语音震动常亮全部保留
- 另含 index 进度地图右上角奖杯图标删除(quest-trophy 样式清理)
2026-08-11 15:38:37 +08:00
lc 6f89494c0b feat(index): 首页改版 hero + 进度点地图 + 计时环 dial + CTA 脉冲
- 全宽橙渐变 hero:头像+昵称+按小时问候+「时长」敢不敢来挑战+连胜(昵称同行)
- 进度点地图 quest-card 上浮叠 hero,圆点+连线 done/now/final 三态,与 CTA 等宽
- 计时环 dial 虚线刻度环+呼吸动画,整体放大(320rpx/数字96rpx)
- CTA「立即挑战+时长」带脉冲,保留自由/循环入口
- 千卡按 4 千卡/分钟估算(激励参考);删除「今天还没开始」整段与连胜前图标
- 循环训练秒数步进 ±10;取消每日语录模块
2026-08-11 14:32:25 +08:00
lc 938a7d1276 feat: 首页顶部加头像昵称问候 + 循环训练秒数步进改为±10 2026-08-11 11:46:51 +08:00
24 changed files with 1673 additions and 774 deletions
+45
View File
@@ -0,0 +1,45 @@
// 一次性脚本:清除小程序/云函数中的调试 console.* 输出
// 安全策略:
// 1) 箭头函数体 `const x = (e) => console.xxx(...)` → 替换为 `const x = () => {}`
// 2) 独立 console 语句整行删除(含无分号情况)
// 3) 收敛多余空行(>=3 个连续换行压成 2 个)
// 删除后由调用方逐个 node --check 验证语法。
const fs = require('fs')
const files = [
'/Users/liubleed/Documents/wx_pbzc/utils/cloud.js',
'/Users/liubleed/Documents/wx_pbzc/utils/storage.js',
'/Users/liubleed/Documents/wx_pbzc/utils/voice.js',
'/Users/liubleed/Documents/wx_pbzc/utils/theme.js',
'/Users/liubleed/Documents/wx_pbzc/app.js',
'/Users/liubleed/Documents/wx_pbzc/pages/leaderboard/leaderboard.js',
'/Users/liubleed/Documents/wx_pbzc/pages/timer/timer.js',
'/Users/liubleed/Documents/wx_pbzc/pages/settings/settings.js',
'/Users/liubleed/Documents/wx_pbzc/cloudfunctions/leaderboard/index.js',
]
const INDEP = /^\s*console\.(log|warn|error|debug)\(.*\)\s*;?\s*$/
const ARROW = /^(\s*const\s+\w+\s*=\s*\([^)]*\)\s*=>\s*)console\.(log|warn|error|debug)\(/
let total = 0
for (const f of files) {
if (!fs.existsSync(f)) { console.log('SKIP (missing):', f); continue }
const lines = fs.readFileSync(f, 'utf8').split('\n')
const out = []
let removed = 0
for (const line of lines) {
const a = line.match(ARROW)
if (a) {
out.push(a[1].replace(/=>\s*$/, '=> {}'))
removed++
continue
}
if (INDEP.test(line)) { removed++; continue }
out.push(line)
}
const cleaned = out.join('\n').replace(/\n{3,}/g, '\n\n')
fs.writeFileSync(f, cleaned)
total += removed
console.log(`cleaned ${removed} ${f.replace('/Users/liubleed/Documents/wx_pbzc/', '')}`)
}
console.log('TOTAL removed:', total)
+20
View File
@@ -0,0 +1,20 @@
# 2026-08-06
## 记录页新增「里程碑」tab — 设计已锁定(尚未写代码)
用户要求:在 records 页「记录日历 / 趋势」后加第三个 tab「里程碑」,展示用户锻炼旅程中的里程碑事件。
**关键决策(用户逐项确认):**
- 语义=**「第一次完成任务」**,不做累计/极值(明确否决"累计X小时/累计N次""单次最久""最长连续N天"这类历史最佳)。
- 排序=**倒序**(最新里程碑在最上)。
- 范围=**5 家族克制版+模式首秀**:
1. `first_train` 第一次训练 → min(record.date)
2. `badge` ×7 勋章首解锁(复用 config.trainingDayBadges 的 name/iconWhite)→ 第 N 个去重训练日=解锁日
3. `first_duration` 时长首突破,门槛 config.milestoneFirsts.durations=[60,180,300,600]s → 正序第一条 duration≥T 的记录日期
4. `first_streak` 连续首突破,门槛 config.milestoneFirsts.streaks=[7,14,30] → 正序扫去重日期,连段首次达 N 的当天
5. 模式首秀:`first_circuit`(首条 mode==='circuit')、`first_plan`(首条 day>=1 的计划训练)
- 节点**不可点**(纯展示)。
- 视觉:竖向时间线(实色圆+白图标+右侧卡),顶部汇总卡+底部"下一个目标"条;空态复用页面级 totalSessions===0 引导。
**架构(待实现):** 新建 `utils/milestones.js` 纯函数 `computeMilestones(records)` → 倒序事件数组;改 records.js/wxml/wxsscontentMode 增 'milestone');config 增 milestoneFirsts。全部从现有 training_records 推导,**零存储/云函数改动**。
**图标:** 节点用 *White 变体(playWhite/boltWhite/formWhite/各 badgeWhite 等);timeWhite、repeatWhite 实现时各加一行或复用现有白图标。注意组件 style isolation 陷阱——图标色烤死在 SVG,必须用 *White。
**状态:用户选择「再调整一下」,暂未批准写代码。** 下一步:与用户敲定文案/门槛/配色后实现。
+22
View File
@@ -0,0 +1,22 @@
# 2026-08-07
## 里程碑 tab 设计校正:时长维度改为「单次最长时间」
用户纠正先前理解偏差:时长里程碑要的是**单次最长时间(个人最佳)**,不是"首次突破 X 分钟"的阈值事件。
这其实与用户最初原话"什么时间单次坚持最长"一致;之前被带偏成 `first_duration` 阈值家族。
**校正后的家族(6 类):**
1. `first_train` 第一次训练 → min(record.date)
2. `badge` ×7 勋章首解锁(复用 config.trainingDayBadges)→ 第 N 个去重训练日=解锁日
3. `longest_plank` **单次坚持最久**(个人最佳)→ argmax(duration),取该记录日期+时长;**含循环**circuit 的 record.duration=workTotal 已排除休息,公平)。属「个人最佳」类,与其余「第一次」类并列。
4. `first_streak` 第一次连续打卡 N 天 → 门槛 config.milestoneFirsts.streaks=[7,14,30]
5. 模式首秀:`first_circuit``first_plan`
**config 变化:** `milestoneFirsts` 去掉 `durations`(不再有时长阈值);仅保留 `streaks:[7,14,30]`
`longest_plank` 无需阈值配置,直接取全量记录 max(duration)。
**待定细节(已向用户提出):** `longest_plank` 是「1 个节点=当前历史最长(若被刷新则更新日期)」,还是「每次刷新纪录都记一个节点(PR 进阶时间线)」?默认按前者(最贴合"单次最长时间"语义)。
## 顺带诊断:模拟器 vs 正式版连续打卡不一致
streak 是本地缓存计数器(`current_streak`),`validateStreak` 普通启动只信任旧 count、不重算;`recomputeStreak` 仅删记录/云端恢复时跑。模拟器与真机是两套隔离存储沙箱,且 streak 不随记录并集自动对齐 → 4天 vs 2天属预期差异。根治=启动即 `recomputeStreak()`。用户尚未批准改代码。
+102
View File
@@ -0,0 +1,102 @@
# 2026-08-11
## 首页顶部问候区加入用户头像与昵称
-`pages/index` 顶部问候区("准备好了吗")左侧新增圆形头像 + 昵称(昵称默认"运动达人")。
- 数据来自 `storage.getProfile()`{ nickname, avatarUrl }),在 `refresh()` 中读取,每次 onShow 刷新(settings 改完回首页即更新)。
- 头像圆形裁剪用双保险:外层 `overflow:hidden` + 内层 `image border-radius:50%`;无头像回退 `icons.peopleFill` 人形占位。
- 改动文件:index.wxmlgreeting-row 结构)、index.wxss(横向 flex 布局 + 头像样式)、index.jsdata 加 nickName/avatarUrl、refresh 读取 profile)。`node --check` 通过。
## 首页问候区样式调整(同一轮)
- 删除原来的 emoji/success 状态小图标(greeting-icon),昵称嵌入问候句前置并加逗号:`{昵称},准备好了吗?` / `{昵称},太棒了!`(完成态)。
- 昵称默认"运动达人"。删除 .greeting-nickname / .greeting-text / .greeting-icon 旧样式,标题与昵称同行同字号(40rpx 加粗)。头像仍在左、副标题在下。grep 确认无旧类名残留。
## 循环训练秒数步进改为 ±10 秒
- `pages/index/index.wxml` 循环训练弹窗:「每组时长」(hold) 与「组间休息」(rest) 的 stepper `data-delta` 由 ±5 改为 ±10;组数、每周目标(非秒数)不变。
- JS `onCircuitStep` 的 boundshold 下限5 / rest 下限0)无需改,越界自动 clamp。
## 首页 index 整体改版(参考 plank-training.html,方案 B 橙色系)
- 结构重构(纯视觉/布局,不动训练/记录/设置逻辑):全宽橙渐变 `hero`(头像+昵称+按小时时间问候+「时长」敢不敢来挑战+🔥真实连胜)→ `quest-card` 进度点地图(白卡负 margin 上浮叠 hero,圆点+连线,done/now/final 三态,末节点🏆)→ `dial` 虚线刻度环+呼吸动画(目标时长+预计千卡)→ CTA「立即挑战+时长」带脉冲 → 次级入口自由/循环。
- 取消每日语录模块(用户要求)。
- 配色:方案 Bhero 渐变用 `var(--primary)→var(--primary-light)`,随主题切换变色,保持橙色品牌。
- hero 用 `margin:-24rpx -24rpx 0` 抵消 container 内边距实现全宽;opacity-only 动画避免 transform 破坏弹窗定位。节点地图自适应:计划天数<=8 画每天节点,>8 退化为 5 里程碑节点。
- JS 新增 `greetText/streakText/questNodes/questTip/estKcal` 字段 + `_greetByHour()`/`_buildQuestMap()`;移除 `useSegments/planDays/planProgress``node --check` 通过;grep 确认旧类名(greeting-/header-card/streak-/segment-/milestone-/target-/useSegments 等)零残留。
- 注意:改版前已先 `git commit`(938a7d1)。本次改版尚未提交,待用户预览确认后再提交。
## 首页改版微调(同一轮)
- 千卡估算修正:从 4 千卡/分钟 降至保守 2.2 千卡/分钟(1分30秒≈3千卡),仅作激励参考非精确值。
- 删除「预计消耗」后「· 核心力量 +1」文案;整行放大(24→28rpx,数字 30rpx)。
- 连胜文案「🔥 已连续 N 天打卡」从 hero 底部独立行移到顶部头像+昵称行内(昵称下方),新增 `.hero-user` 纵向堆叠容器。
## 首页改版微调(第2轮)
- 千卡恢复 4 千卡/分钟(用户确认)。
- 删除「今天还没开始,现在就稳住核心」整段(含已完成态「今日已完成 N 秒」),同步删除 `.today-status`/`.done-text`/`.pending-text` 样式;CTA 按钮仍用 `todayDone` 切换「立即挑战/再战一次」文案。
- 连胜文案从「昵称下方」改为「昵称同一行后面」:`.hero-user``flex-direction: row` + `align-items:center` + `gap:14rpx``.hero-social` 上间距归零。
## 首页改版微调(第3轮)
- 计时环整体放大:`.dial` 280→320rpx;计时数字设计字号 84→96(`index.js` 数据默认值 + `_readFontScale` 的 DESIGN 常量,clamp 上限同步 96),`.dial-time` max-width 240→280rpx 防溢出。
- 删除连胜前 🔥 图标(WXML `hero-social` 改为纯文案)。
- 连胜字号与昵称对齐:`.hero-social` 26→28rpx(与 `.hero-greet` 一致)。
## 首页改版微调(第4轮)
- 进度地图(`quest-card`)与「立即挑战」CTA 按钮等宽:`.quest-card` 左右 margin 由 24rpx 改为 0(容器自身 24rpx 内边距已让两者贴合内容区边缘),保留 `margin-top:-56rpx` 上浮叠 hero。
## 训练页(timer)改版(参照用户截图,方案:var(--primary) 主题色 + 无顶部栏)
- 顶部栏按用户要求取消(不画 sound/pause 图标行)。
- `components/progress-ring` 扩展:canvas `_draw()` 新增秒刻度层(ticks 属性=秒数,cap 60;四分位加粗、其余细灰);环内新增 `subText`(副标题,如"目标 30秒");移除组件内 statusText 渲染(状态词改由 timer 页在环下方显示)。
- timer.wxmlprogress-ring 传 `ticks="{{ringTicks}}"` `subText="{{ringSubText}}"`;原 hint-section 替换为「环下方状态词 timer-status + 目标信息卡 goal-card(✅今日目标 X · 第N天 ›,静态箭头) + 运行提示 timer-tip」。
- timer.js 新增派生字段 ringTicks/ringSubText/statusText/goalLine/runningTip,在 _init/_initCircuit/onStart/onPause/onStopCancel/onTrainAgain/各 onTick/onGoalReached 中计算下发;新增 `_durationText(sec)` 语音化时长(45→"45秒",90→"1分30")。
- timer.wxss:新增 .timer-status/.goal-card/.timer-tip 样式;.timer-page 改 justify-content flex-start + .controls margin-top:auto(按钮贴底);删除旧 hint-section/hint-row/hint-text/running-pulse/completed-bounce 样式。
- 状态机(idle/running/paused/completed/circuit/free)、计时逻辑、循环 FSM、呼吸环、彩带、完成弹窗、结束确认弹窗全部保留。node --check 通过;grep 确认 hint-*/status-text/running-pulse/completed-bounce 零残留。改版尚未提交。
## 训练页姿态提示醒目化(气泡卡片)
- 用户要求训练中的姿态提示更醒目。原 `timer-tip` 仅为 26rpx 灰字无修饰。
- 改为「气泡卡片」:白底 + 主色左边框(8rpx) + 圆角 + 轻投影 + 顶部小三角指向圆环 + 左侧人物主题色图标(peopleFill) + 文字放大(26→30rpx 加粗);持续 tipPulse 呼吸动画(scale 1↔1.03)。
- 语义分级:tipType=posture(默认/主色)phase(循环阶段,主色)done(目标达成,成功色+successFill 图标)。timer.js 在 _init/_initCircuit/onTrainAgain(默认 posture+peopleFill)、single onTick(goalReached→done/successFill 否则 posture/peopleFill)、onGoalReached(done)、circuit onTick(phase/peopleFill) 设置 tipType+tipIcon。
- 图标用现成 `peopleFill`(主题色人物,白底醒目),done 用 `successFill`;不手画新增 path(规避 SVG data-URI 色烤死/渲染风险)。node --check 通过;字段引用一致。改版(含前次 timer 重构)于 commit 0464eea 提交。
## 训练页圆环放大 + 环内间距调整
- 用户反馈圆环与中间时钟太贴近。timer.wxml 中 progress-ring `size` 360→420、ringWidth 保持 22(环整体放大、环内边到时钟间隙约 23→53rpx 提升)。
- timer.wxss 呼吸光晕同步放大(breathe-ring-1/2/3520/440/380 → 620/540/460rpx),确保光晕仍罩在更大环外侧。
## 训练页呼吸光环 + 光晕增强(明显可见)
- 用户要求光晕与呼吸明显可见。新增 `.breath-aura` 呼吸光环:进度环外侧 488rpx 直径、6rpx 主色描边(0.55 alpha) + 40rpx 外辉光 + inset 28rpx 内辉光;@keyframes auraBreathe 2.8s 慢呼吸(scale 0.9↔1.06, opacity 0.5↔0.95)running 切 auraBreatheFast 1.3s。timer.wxml 在 ring-wrapper 内 progress-ring 前新增该 view(带 fast 类切换,completed 态 wx:if 隐藏)。
- 现有径向光晕透明度大幅调高:base 0.10→0.20、ring-2 0.14→0.26、ring-3 0.18→0.34,淡出半径 65%→62~68%。
- 纯 CSS transform/opacity 动画,不碰 canvas/进度环组件/状态机;z-index:0 在 progress-ring 之下、可见带在环外侧无遮挡;transform 仅作用于光环自身,不影响 fixed 弹窗(现有 breathe-ring 同机制已验证)。
- 已渲染动画预览 widget 演示可见度。未提交,待用户预览确认。
## 训练页呼吸光环柔和化 + 圆环下方整体下移
- 光环柔和化:scale 幅度 0.9↔1.06 缩至 0.96↔1.04、opacity 峰值 0.95/1.0 降到 0.85/0.9、节奏放慢(idle 2.8s→4s、running 1.3s→2.4s)。
- 圆环下方元素(状态词/目标卡/提示)整体下移:.ring-wrapper 加 margin-bottom:44rpx.controls margin-top:auto 贴底不受影响。
## progress-ring 刻度四分位对齐修复(bug)
- 现象:ticks(=训练时长,cap 60)不被 4 整除时,长刻度按 `i % round(ticks/4)===0` 判定落点,只有顶部(12点,i=0)正中,其余 3 根偏到 96°/102°/198° 等。
- 修复:长刻度改精确画在 12/3/6/9 点 4 个正方向(不依赖 tick 索引整除);短刻度在靠近四分位(角度差<一格 stepDeg=360/ticks)处跳过,避免与长刻度双线重叠。ticks 是否整除 4 都对齐正中。
- node --check 通过。未提交。
## 首页时间圆环改为大字流光时间(无圆环)
- 用户认为 index 计时环用处不大,去掉圆环,目标时长改用大字「渐变流光动感数字」显示(选中的风格)。
- index.wxml:删 `.dial` 圆环整块,改 `<text class="time-display">{{todayTargetText}}</text>` + `<text class="time-label">今日目标时长</text>`,保留「预计消耗 X 千卡」。
- index.wxss:删 `.dial/.dial::before/.dial-time/.dial-label/@keyframes ringBreathe`;新增 `.time-display`(120rpx/900/letter-spacing -2rpx/tabula-nums + 渐变 background-clip:text + timeShimmer 3s 流光, 跟随 var(--primary)/var(--primary-light) 主题色) 与 `.time-label`。含 `color: var(--primary)` 兜底。
- index.js:删 `targetTimeFontSize` 字段与 `_readFontScale()` 方法(onLoad 调用一并移除)——那是给圆环内数字防系统字号缩放溢出的专用逻辑,无圆环不需要。
- grep 确认无 `dial` 类残留(仅 hero::after 的 radial-gradient 误匹配"dial"子串、与圆环无关);node --check 通过。未提交。
## 训练页布局:时钟居中 + 按钮贴底留白
- 用户要求:把底部 3 个按钮(开始/暂停·结束/继续·结束)下移到屏幕底部(以底部 toolbar 为基础),留出空间,时钟(进度环)挪到中间区域。
- 确认 timer 不是 tabBar 页面(tab 仅 index/records/leaderboard/settings),无需额外预留 tabBar 高度;"底部 toolbar"指屏幕底部区域。
- 结构改动 timer.wxml:将 `ring-wrapper` + `timer-status` + `goal-card` + `timer-tip` 整体包进新 `<view class="middle-area">`,controls 仍在外部独立贴底。
- 样式 timer.wxss:新增 `.middle-area { flex:1; width:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:0; }`,使整组(进度环+状态/目标/提示)在剩余高度内垂直居中,与底部按钮之间自然留白;.controls 仍 `margin-top:auto` 贴底。circuit-bar 保持顶部。
- 纯布局重构,未动进度环组件/状态机/计时逻辑/动画。未提交。
## 首页布局:时钟居中 + 3 按钮贴底(修正)
- 误判:上轮把同样的"中间展示区"思路套到了训练页(timer)。用户截图与反馈(`@image#1`)是首页 index,把 3 个按钮(再战一次/自由训练/循环训练)推到屏幕底部(贴近 tab bar)、时钟(00:30)挪到中间区域。
- 撤销对 timer 的误改:timer.wxml 移除 `middle-area` 开闭标签,timer.wxss 删除 `.middle-area` 规则;grep 确认 timer 无 `middle-area` 残留。
- index 改动:index.wxml 根容器加 `index-page` 类(不动全局 `.container`,仅本页面升级为 flex 列)。index.wxss 新增 `.index-page{display:flex;flex-direction:column;}``.timer-wrap` 改为 `flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:0;text-align:center`(垂直居中"00:30 + 今日目标时长 + 预计消耗 千卡");`.action-buttons``margin-top:auto`(贴底)。hero 的负 margin 与 quest-card 的 -56rpx 上浮叠在 flex 列里仍生效。
- 效果:hero+quest 在顶 → 中间留白 + 时钟居中 → 留白 → 3 按钮贴底紧挨 tab bar 上方。
- 未提交。
## 首页时间字体改为 Saira Condensed 900base64 内联)
- 用户认为 Orbitron 不够硬朗,从 6 款硬朗字体对比中选择 Saira Condensed 900。
- **关键修复**:微信小程序 `@font-face``src:url('./local.woff2')` 本地相对路径**不可靠**(devtools 可能假性生效、真机回退系统字体)。之前 Orbitron 用本地路径加载,用户反馈"区别很大"=字体实际没加载。**治本方案**:把 woff2 转 base64 data URI 直接内联到 `@font-face``src`,不依赖任何外部文件/网络,devtools 和真机一致。
- app.wxss `@font-face``src:url('data:font/woff2;charset=utf-8;base64,<24KB>')`font-family:'Saira Condensed', font-weight:900。原 woff2 文件(17.7KB)已删除,不再随包冗余。
- index.wxss `.time-display``font-family:'Saira Condensed','Arial Narrow',sans-serif``font-weight:900``letter-spacing:2rpx`(Saira Condensed 偏窄,小间距即可)。渐变流光 + drop-shadow 阴影不变。
- node --check 通过。未提交。
+108
View File
@@ -0,0 +1,108 @@
# 2026-08-12 工作日志
## 循环训练页遮挡问题分析与 redesign 规划
- 读取 `pages/timer/timer.wxml/wxss/js`,定位循环训练态(circuit mode)布局。
- 问题根因:`.timer-page``flex column``min-height: 100vh` 但无滚动,中间圆环 420rpx + 多层呼吸环 488/620rpx + 顶部卡片 + 状态文字 + 目标卡 + 提示气泡的总高度在小屏/大字体下容易超过视口;底部 `.controls``margin-top: auto` 贴底,结果会被内容顶出可视区域或被安全区截断。
- 规划方案(未改代码):
1. 圆环从 420rpx 缩到 340rpx,呼吸环同步缩小。
2. 顶部卡片内嵌组进度条,组数/阶段/配置摘要合一,减少一层垂直空间。
3. 状态文字与圆环内副标题、目标卡与姿态提示错位排布,避免信息重复。
4. 底部控件固定高度 + `padding-bottom: calc(32rpx + env(safe-area-inset-bottom))`,保证始终在安全区上方可见。
5. 整体改为 `justify-content: space-between` 的 flex 布局,顶部-中部-底部三大块按可用空间分配。
- 已输出 SVG 线框图,待确认后再落地代码。
## 方案修正(用户反馈:timer 页是公用页面,仅循环模式有组进度条)
- 用户指出原规划把页面当"恒有顶部卡片"设计,切换模式重心会跳。
- 修订方案(未改代码):**组进度从独立顶部面板改为「圆环外圈分段弧」**——
-`progress-ring` canvas 里加 sets/currentSet/isResting 属性,track 环外侧画一圈分段弧(已完成=主色、进行中=主色+端点光点、未开始=浅灰、休息时下一段=success 绿)。
- 单组/自由模式不传 sets → 不渲染分段弧,页面结构与现状完全一致,零影响。
- 圆环内副标题循环模式显示「第 X/Y 组」,单组显示「目标 X」。
- 组数 >15 时退化为圆环下方细线进度条。
- 顶部 circuit-bar 面板(wxml 20-53 行、wxss 15-225 行)整个删除,垂直空间全部还给圆环区与底部控件,从根上解决遮挡。
- 已确认 `components/progress-ring` 为 canvas 2d 实现(_draw() 内 ctx.arc/stroke),扩分段弧同套 API,可行。
## 落地:循环训练组进度改造(用户批准后实施)
- **`components/progress-ring/progress-ring.js`**
- 新增属性 `sets/currentSet/resting/successColor`sets<=0 时零渲染,单组/自由模式不受影响)。
- 新增 `_drawSetArcs(pulse)`:track 环外侧画分段弧(arcR = radius + ringWidth/2 + 3rpx,弧宽 8rpx,段间隔 2.5°)。三态:已完成=primaryColor、进行中=primaryColor+端点光点、休息时下一段=successColor('#00B578'),未开始=trackColor。
- 呼吸动画用 `canvas.requestAnimationFrame` 循环(周期 1.6s,sin 相位),只清外圈环带(clip 环形避免碰内圈倒计时弧);paused 时冻结为静态;`detached` 时 cancel。
- `_draw()` 末尾补调 `_drawSetArcs(_setPulsePhase || 0.6)`,保证每秒全量重绘后分段弧仍在。
- **`pages/timer/timer.wxml`**:删除整个 `circuit-bar` 面板(原 18-53 行);`<progress-ring>` 新增 `sets="{{totalSets}}" currentSet="{{currentSet}}" resting="{{isResting}}"`
- **`pages/timer/timer.js`**:删除 `circuitDots/circuitShowDots/circuitTotalWork/dotsDone/dotsActive/dotsNext` 字段与 DOTS_MAX 点阵构建;onTick 由 dots* 改为 `ringSubText: 第 X/Y 组`_initCircuit 的 ringSubText 改为 `共 X 组 · 休息 Y 秒`rest=0 省略休息段),goalLine 保持 `每组 X · 共 Y 组`
- **`pages/timer/timer.wxss`**:删除全部 circuit 样式(.circuit-bar/.circuit-dots/.circuit-track/.circuit-meta/.circuit-count/.circuit-phase/.circuit-sub 等)。
- **落地时的设计决策**:组数上限 50(_int clamp),环形 360° 空间充裕,50 段也不挤线(旧 DOTS_MAX=15 是横向卡片宽度受限),故**不做数量降级**;原方案"组数>15 退化线性条"在环形下无必要。
- 验证:node --check 通过;grep 无残留代码引用(仅注释说明文字)。
## 呼吸环淡化(用户反馈"圆线条太明显")
- `timer.wxss``.breath-aura`border 6rpx/0.55 → 4rpx/0.20box-shadow 外层 40rpx/0.45 → 30rpx/0.22、内层 28rpx/0.22 → 20rpx/0.10auraBreathe 透明度 0.55↔0.85 → 0.30↔0.50fast 版 0.6↔0.9 → 0.34↔0.55。
- `.breathe-ring` 三档径向渐变强度:0.20/0.26/0.34 → 0.13/0.17/0.22,柔光更淡雅,不与倒计时环抢视觉。
## 目标卡与提示气泡轻量化(用户反馈"两个提示框不好看")
- 问题:`.goal-card` 是白底卡片+右箭头,看起来像可点按钮但无 tap;`.timer-tip` 是白底气泡+左侧绿条+上方小三角+呼吸阴影,与目标卡叠在一起视觉噪音大。
- `timer.wxml`:删除 `.goal-card` 中的右箭头 `goal-arrow`
- `timer.wxss`
- `.goal-card` 改为轻量胶囊标签:`inline-flex`、主色 8% 淡底、无边框无阴影、圆角 999rpxpadding 24→14rpx/24rpx;图标 40→32rpx;文字 28→26rpx、权重 600→500、主色。
- `.timer-tip` 改为药丸标签:去掉 `border-left`、小三角、box-shadow、tipPulse 呼吸动画;背景主色 8% 淡底(done 态 success 10%);padding 20/32→12/24rpx;圆角 999rpx;图标 40→30rpx;文字 30→26rpx、权重 600→500。
- 验证:grep 无 `goal-arrow/tipPulse/timer-tip::before/border-left: 8rpx` 残留。
## 目标卡 + 提示气泡合并为单气泡(用户反馈"字体小了、能否合并、页面三模式公用")
- 新结构 `.info-bubble`timer.wxml/wxss):一张主色 8% 淡底圆角卡,两行——
-`info-line--goal`target 图标 + goalLine26rpx/500/主色(静态摘要,常驻)
- 分隔线 `info-divider`(1rpx 主色 14% 淡线,仅提示出现时渲染)
-`info-line--tip`tipIcon + runningTip30rpx/600/`--text`(动态提示,比目标行大一号突出);`--done` 态 success 绿
- 三模式共用同一容器:单组/自由/循环都走 info-bubble;循环休息/暂停时 runningTip='' → 分隔线与提示行整体消失,气泡自动收缩只留目标行,布局不跳。
-`.goal-card/.goal-icon/.goal-text/.timer-tip/.timer-tip-icon/.timer-tip-text` 全部删除,grep 无残留。
## 状态文案并入信息气泡(用户反馈"撑住"等硬编码文案能否整合进气泡)
- 背景:环下方 `.timer-status`statusText:准备开始/撑住/休息中/已暂停)是独立一行,用户希望圆环下只留一张卡。
- `timer.wxml`:删除 `.timer-status` 行,气泡改为三行结构——上 `info-line--status`(小圆点 + statusText+ 恒显分隔线 + 中 `info-line--goal` + 提示行(条件渲染)。
- `timer.wxss``.timer-status` 样式删除,新增 `.info-line--status/.info-status-dot/.info-status-text`
- 状态行 32rpx/700,小圆点 14rpx 用 `background: currentColor` 跟随状态色(纯 CSS,规避 SVG 图标烤死问题)。
- 配色:`status--running`=主色(圆点呼吸 1.6s);休息时 `status` 字段仍是 running,靠 `isResting` 追加 `info-line--rest` 覆盖为 success 绿(呼吸 1.8s);`status--paused/idle`=text-secondary 灰,paused 圆点冻结动画。
- `.info-bubble` margin-top 24→40rpx(承接原 timer-status 间距)、padding 20→24rpx。
- 关键机制备忘:**resting 时 status 字段值仍是 'running'**,状态行转绿必须靠 `isResting` 单独加类,不能只看 status。
- 验证:grep 无 `timer-status` 代码残留(仅注释文字),js 未改动。
## 进度弧"0 点对齐"问题:两轮修复被否定,已全部回滚(待用户澄清)
- 用户反馈(两次截图):训练时粗进度弧起点与 0 点/顶部起点差一点没对齐,运动过程中更明显(循环与单组模式都出现过)。
- 尝试过两轮修复(progress-ring.js `_drawSetArcs` 半径/清除区微调),用户判定"完全理解错了",**要求撤销**。
- 已回滚:`git diff` 确认 progress-ring.js 与 HEAD`05c4763` 分段弧初始实现:glowPad=6、arcR=radius+lw/2+3、arcW=8、清除区 clip 内缘 arcR-band)完全一致,`node --check` 通过。
- 教训:对齐类问题不要凭数值推理直接改半径,先让用户明确"0 点"的参照物(刻度环/呼吸环/分段弧?)与出现模式,再定位。
- **最终定位与修复(用户澄清"所有运动中,进度弧零点与表盘 0 点没对齐")**:
- 根因确凿:`_drawSetArcs` 每段起点 `+gap/2`(1.25°),**外圈分段弧第一段从 12 点偏右 1.25° 开始**(半径 ~207dpr 处 ≈ 4.5dpr ≈ 1.5px),而内圈进度弧/12 点长刻度都从精确 -π/2 开始 → 循环模式外圈与内圈 0 点肉眼错开。
- 修复:`a0 = startAngle + (i-1)*segAngle`(第一段精确 -π/2 起),`a1 = a0 + segAngle - gap`(段尾留 gap)。段间空隙均匀(2.5°×3),收尾空隙落在 12 点左侧,环带以 12 点为起点。node 验算:段起点 270°/30°/150°,空隙 2.5° 均匀,首段 12 点 ✓。
- 单组/自由模式无分段弧,canvas 内刻度/进度弧同基准(-π/2),数学对齐;若用户仍反馈错位需再查(可能涉及 canvas 布局/参照物)。
- 注意:`gap/2` 对称偏移是"段内居中空隙"的常见写法,环形进度环带与刻度基准对齐时必须从 -π/2 起算。
- **用户叫停("算了,这个问题先不修复了")**:零点对齐问题整体搁置。该轮 progress-ring.js 的角度修复(仅影响循环模式、覆盖不了单组模式现象)已 `git checkout` 回退,与 HEAD 一致。等用户想继续时再排查,方向可能是 canvas 布局/参照物而非半径。
## 新功能:训练时长低于阈值不记录(config.minRecordSeconds,默认 20 秒)
- 需求:训练有效时长 < 20 秒(可配置)不写入记录,并给用户明确提示;阈值收进 config.js。
- **`config.js`**:新增 `minRecordSeconds: 20`(带注释:不写记录、不更新连胜;循环按有效撑持秒数=各组时长之和判断)。
- **`pages/timer/timer.js`**
- `finishTraining()``elapsed >= minSec` 才播完成语音(低于阈值不庆祝)。
- `_onCircuitComplete()`:循环自然完成路径同样按 `workTotal >= minSec` 播语音。
- `_saveAndShowCompletion(elapsed)` 开头加**阈值兜底**`elapsed < minSec``_showNotRecorded(elapsed, minSec)` 直接 return。手动结束与循环自然完成两条路径都汇聚到这里,任何入口都不会把短时训练落库。
- 新增 `_showNotRecorded(elapsed, minSec)`:复用完成弹窗(避免系统原生 modal 在 WebView stacking context 下 hit-test 不稳),不保存记录/连胜、无 confetti/streak/科学提示,大数字照常 countUp 到 elapsed;置 `completionNotRecorded: true``minRecordSeconds: minSec`、标题"未达最低记录时长"。
- data 新增初始字段 `completionNotRecorded: false``minRecordSeconds: 20`
- **状态残留坑(已修)**`onTrainAgain`(再来一次)与 `_saveAndShowCompletion` 正常分支都必须显式 `completionNotRecorded: false`,否则上轮未记录后重练,下次正常完成弹窗会误显示"未计入记录"且隐藏分享/记录按钮。
- **`timer.wxml`**:完成弹窗新增 `.completion-not-recorded` 提示块(infoFill 图标 + "本次训练未计入记录" + 副行"撑满 X 秒以上才会记录并计入连续打卡");未记录态 `wx:if="{{!completionNotRecorded}}"` 隐藏"查看记录/分享"(没有成绩可看/可晒),仅留"再来一次 + 回到首页"。
- **`timer.wxss`**:新增 `.completion-not-recorded` 主色淡底信息块(与 completion-tip 同风格,tipIn 延迟 0.5s 入场)。
- 保留逻辑:`elapsed < 3` 秒仍走 toast「训练时间太短」+ navigateBack(几乎未开始,不弹完成弹窗)。
- 验证:node --check 通过;grep 确认 minRecordSeconds/completionNotRecorded/_showNotRecorded 三处字段在 config/js/wxml 引用一致。
## 分段弧两端粗细不一致(已修)
- 用户反馈:循环训练时分段弧"两端粗细不一致"。
- 根因(progress-ring.js `_drawSetArcs`):发光光点坐标固定取段尾角 `a1``dx=cx+arcR*cos(a1)`),当前段(active)/休息预告段(next)的尾端 = 光点+8dpr 发光晕(直径≈弧宽+),首端却只是 `lineCap:'round'` 普通圆头 → 同段弧尾粗头细。已完成/未开始段两端都是普通圆头,粗细一致,所以现象集中在运动中那一段。
- 修复:光点移到**段中点** `am=(a0+a1)/2`(3 行),两端自然对称,光点仍作"当前组"标记。node --check 通过。
- 注意:若以后想给分段弧加"当前组跟随倒计时移动"的进度感,光点可改为随剩余时间在段内插值,但需先确认用户是否要这个效果。
## 分段弧间距 + 光点置顶(用户反馈"两弧太近、弧压着光点")
- 用户反馈两点:①分段弧与进度弧距离太近;②光点被分段弧压住(视觉上弧盖着光点)。
- **间距修复**progress-ring.jssize=420 时验证):
- 根因:间距 = gap arcW/2 = 34 = **−1dpr(重叠 1)**。因为分段弧 arcR 仅比进度弧外缘靠外 3dpr,而弧半宽 4dpr。
- `_draw()``glowPad` 6→14dpr(进度弧半径内缩 8,给外圈腾空间);`_drawSetArcs()` 的 radius pad 6→14**两处必须同步**,已加注释)且 arcR 间距 `+3→+9dpr`。结果:进度弧外缘 204→196、分段弧内缘 203→201,间距 −1→**+5dpr**。副作用:单组/自由模式的进度环也内缩 ~8dpr(约 4%),中心数字区略小;进度弧尾端发光点 shadowBlur=12 阴影反而从"被 canvas 裁"变完整。
- 段间空隙 gap=2.5° 是段间距,**与环间距无关,不动**。
- **光点置顶**:原实现光点在逐段循环内、每段弧 stroke 之后画,理论上应在弧上,但视觉上被 round cap 相邻段边缘"吞掉"。重构为**两遍绘制**:pass1 只画所有弧并收集 emphasized 段光点(x/y/color),pass2 统一在所有弧之上画光点。同时光点加大加亮:dotR 从 `0.85~1.2×弧半宽``1.15~1.4×`alpha `0.55~1.0``0.7~1.0`,白芯 0.85→0.9,读起来像"珠珠骑在弧上"。
- 光点仍居段中点(沿用上轮两端对称修复);外缘 209dpr 不超 canvas 半宽 210,与 breath-aura(内缘 ~232)无重叠。
- 验证:node --check 通过;数值全部核算。未提交 git。
- **间距仍不够(用户反馈"还是太近")→ 再翻倍**canvas 半宽 210 是硬上限,剩余空间仅 ~5dpr,要明显拉开只能继续内缩进度弧。glowPad 14→18、arcR 外移量 9→14,间距 **5→10dpr**(进度弧外缘 196→192,分段弧内缘 201→202,光点外缘 211.6 超画布 0.8px 亚像素无感)。进度环直径累计缩 ~6%(408→384)。约束备忘:**间距 = arcR外移量 arcW/2,且 ≤ glowPad arcW margin**;想再拉开只能继续缩环或减弧宽。
+36
View File
@@ -0,0 +1,36 @@
# 2026-08-14 工作日志
## 修复:设置页切换暗黑模式后其他 tab 的 tabBar 不跟随变暗
- **现象**:设置页选暗黑模式后,只有设置 tab 的底栏变暗,切到训练/记录/排行 tab 底栏仍是白色,完全退出重进才正常。
- **根因**:自定义 tabBar 组件的 `pageLifetimes.show()` 对自定义 tabBar **不触发**(微信框架特殊行为——自定义 tabBar 由框架管理,不走普通组件的页面生命周期)。各 tab 页 `onShow` 里只做了 `tb.setData({selected:X})` 设高亮,没有同步主题;`onSelectDarkMode``getCurrentPages()` 只返回当前 tab 页(其他 tab 不在页面栈里),所以 `tb.updateTheme()` 只刷新了设置页的 tabBar 实例。
- **修复**4 个 tab 页 `onShow` 里获取 tabBar 后,在 `setData({selected:X})` 之后加一行 `tb.updateTheme()``updateTheme()` 内部调 `applyThemeToPage(this)`(有"样式未变则跳过"的优化),不会产生多余渲染。涉及 `pages/index/index.js``pages/records/records.js``pages/leaderboard/leaderboard.js``pages/settings/settings.js`
- **教训**:自定义 tabBar 不能依赖 `pageLifetimes` 做状态同步,必须在各 tab 页 `onShow` 里显式调 `this.getTabBar()` 更新。
## 修复:循环训练偶数组数休息语音被「一半」提示吞掉
- **现象**:循环训练设 2 组时,第 1 组练完进入休息,本该播 `restStart`「休息一下」,实际播的是 `halfway`「已完成一半啦」,休息语音不响。
- **根因**:偶数 set 数时 `sets×hold/2` 正好落在某组练完→休息切换的那一 tick。`_circuitCue` 先播 halfway 并设 `_voiceBusyUntil=now+2600` 占麦;紧接着 `_advance()``_circuitPhase('resting')` 检查麦占用,`restStart` 被静音。原代码注释有意让"进度提示优先于阶段提示",但偶数组数下两者撞车。
- **修复**`_circuitCue` 里先算 `atRestBoundary`(phaseRemaining<=0 && rest>0 && setIndex<sets) 与 `restWillSpeak`(hold>=10 && rest>=5)halfway/last30/last10 在"落在休息边界且 restStart 会播"时跳过自身,让休息语音优先。奇数组数一半点在中段不会撞车,不动;短组配置 rest 不播也保持原 halfway 行为,无回归。改 `pages/timer/timer.js``_circuitCue`
- 顺带:last30/last10 在更长偶数组数(如 4 组×30s 的 set3→rest)也会撞同一问题,一并按同规则让位,行为一致。
## 新增:训练完成分享海报(canvas 绘制 + 小程序码)
- **现象**:训练完成后点分享,微信默认截取当前页面,因完成弹窗垂直居中,卡片只截到上半部分(绿色星星+"完成!"),大数字和按钮被切掉。
- **方案**:用隐藏 canvas 2d 绘制一张 5:4(500×400 逻辑像素)的专用分享海报,在 `onShareAppMessage` 里作为 `imageUrl` 返回;提前在 `_saveAndShowCompletion` 弹窗数据就绪后生成,避免分享时现场绘制延迟。
- **海报内容(初版)**:浅灰底 + 顶部品牌 + 白色圆角成绩卡 + 绿色渐变星星图标 + 状态文案 + 大数字时长 + 连续打卡(如有)+ 底部"扫码一起打卡"引导 + 右下角小程序二维码。
- **文件改动**
- `pages/timer/timer.wxml`:添加隐藏 `<canvas type="2d" id="sharePoster">`
- `pages/timer/timer.wxss``.share-poster-canvas` 移出视口、不响应点击
- `pages/timer/timer.js`:新增 `_drawSharePoster()``_formatShareDuration()``_saveAndShowCompletion()` 里 setData 后预生成;`onShareAppMessage()` 返回 `imageUrl: this._shareImageUrl``onTrainAgain()` 清空旧图防止复用
- `static/share-qr.jpg`:加入用户提供的小程序二维码资源
- **注意**:导出用 jpg quality 0.9、dest 500×400,控制文件在 128KB 以内;二维码路径 `/static/share-qr.jpg` 为相对小程序根目录;若二维码加载失败仍导出无二维码的海报,保证分享入口不灰。
## 调整:分享海报(实测反馈)
- **反馈**:① 大数字(分钟数)压住了上方"完成"状态文案;② 顶部小程序名称多余;③ 要删二维码。
- **根因**:初版状态文案 `y=172`(字号22)、大数字基线 `y=230` 且字号最大 90px,数字顶部≈165 落入状态文案区域(150~172),多位数分钟(如纯分钟 "6"→90px)直接压字。
- **修复**(仅改 `_drawSharePoster` 布局):删除顶部"平板的支撑"品牌名;删除右下角二维码及依赖它的"扫码一起打卡"引导(连带 `static/share-qr.jpg` 也删,不再引用);重排垂直节奏——卡片放大(42~358)、图标 `circleY=104`、状态文案 `y=184`(24px)、大数字基线 `y=286`(上限84px,顶部≈226,与状态文案下沿 192 间距 34 安全)、连续打卡 `y=332`;导出改为绘制完直接 `canvasToTempFilePath`,无需等图片加载。
- **结论**:海报现为"成绩卡 + 图标 + 状态 + 大数字 + 连续打卡"的极简版,无品牌名无二维码。
## 新增:新版本更新通知(What's New)弹窗
- **需求**:新版本发布后用户首次进入弹一个"更新了什么"通知弹窗(纯通知,不是微信"点击更新→重启"流程)。用户澄清要的是 What's New,不是 applyUpdate 重启。
- **实现**:复用 `ui-modal`(position:center) 居中弹窗 + `ui-btn`,不新建组件;`config.js``releaseNotes` 数组;首页 `index.js``_checkUpdateNote()``onShow` 检测 `storage.lastUpdateNoteVersion` vs `config.version`,不一致则显示并写回 storage 防重复;顶部绿色渐变徽章 + `sparkleWhite` 图标 + 版本标题 + 要点列表 + "知道了"按钮。
- **文件**`config.js`(releaseNotes)、`pages/index/index.{js,wxml,wxss}``index.json` 无需改(ui-modal/ui-btn 已注册)。
- **发版约定**:每次发版改 `config.version` 并同步更新 `releaseNotes` 即可。前端改动不发云函数。
+4
View File
@@ -8,9 +8,11 @@
- **系统字体缩放致文字溢出固定盒**(真机重现、模拟器不重现):`getAppBaseInfo().host.env==='devtools'` 时跳过补偿,真机/PC 才把字号 `÷factor` 并 clamp。凡文字进固定圆/方盒且靠 `overflow:hidden` 裁切,都要考虑。 - **系统字体缩放致文字溢出固定盒**(真机重现、模拟器不重现):`getAppBaseInfo().host.env==='devtools'` 时跳过补偿,真机/PC 才把字号 `÷factor` 并 clamp。凡文字进固定圆/方盒且靠 `overflow:hidden` 裁切,都要考虑。
- **云函数写库必须 `{ data: {...} }` 包裹**wx-server-sdk 2.6.3):`set/update/add` 漏包裹报 `parameter.data should be object`。读操作 `get()` 无此限制。 - **云函数写库必须 `{ data: {...} }` 包裹**wx-server-sdk 2.6.3):`set/update/add` 漏包裹报 `parameter.data should be object`。读操作 `get()` 无此限制。
- **自定义组件宿主默认 inline**:页面给 `<ui-card>` 等标签加 `border`/`transform`/`box-shadow` 会碎裂或静默失效(仅 `ui-card` 已修 `:host{display:block}`)。描边优先用 `box-shadow:0 0 0 Nrpx` 并补 `border-radius`,别用 `border`。跨组件边界的 `nth-child` 选择器一律失效,交错延迟由页面传 `index` 驱动。 - **自定义组件宿主默认 inline**:页面给 `<ui-card>` 等标签加 `border`/`transform`/`box-shadow` 会碎裂或静默失效(仅 `ui-card` 已修 `:host{display:block}`)。描边优先用 `box-shadow:0 0 0 Nrpx` 并补 `border-radius`,别用 `border`。跨组件边界的 `nth-child` 选择器一律失效,交错延迟由页面传 `index` 驱动。
- **@font-face 本地路径不可靠**:微信小程序 `@font-face``src:url('./local.woff2')` 本地相对路径在真机上不生效(devtools 可能假性生效),字体静默回退系统字体。**唯一可靠方案**:把 woff2 转 base64 data URI 内联到 `src:url('data:font/woff2;charset=utf-8;base64,...')`,不依赖外部文件/网络。`wx.loadFontFace` 需配 downloadFile 域名白名单且网络不稳,同样不推荐。
- **隐私协议在 MP 后台配置,不在 app.json**`chooseAvatar` 属隐私接口,需在小程序管理后台「设置→服务内容声明→用户隐私保护指引」声明「用户信息(头像/昵称)」。代码里(app.json)看不到隐私配置是正常的;判断隐私合规**先确认后台指引状态**,别只看代码就下"审核风险"结论。已上架运行 = 后台已配好。`__usePrivacyCheck__:true` 仅本地调试用,正式版非必须。 - **隐私协议在 MP 后台配置,不在 app.json**`chooseAvatar` 属隐私接口,需在小程序管理后台「设置→服务内容声明→用户隐私保护指引」声明「用户信息(头像/昵称)」。代码里(app.json)看不到隐私配置是正常的;判断隐私合规**先确认后台指引状态**,别只看代码就下"审核风险"结论。已上架运行 = 后台已配好。`__usePrivacyCheck__:true` 仅本地调试用,正式版非必须。
- **App.onLaunch 的 async/await 不阻塞首屏**:微信框架是「非阻塞生命周期调度」——onLaunch 被调用但页面 onLoad/onShow 并行推进,不等 onLaunch 内 Promise resolve。所以 app.js 里 `await cloud.pullAll()` 不会卡首屏,只是 await 之后的同步逻辑延迟执行。判断启动性能**别把 onLaunch 的 await 当阻塞**。 - **App.onLaunch 的 async/await 不阻塞首屏**:微信框架是「非阻塞生命周期调度」——onLaunch 被调用但页面 onLoad/onShow 并行推进,不等 onLaunch 内 Promise resolve。所以 app.js 里 `await cloud.pullAll()` 不会卡首屏,只是 await 之后的同步逻辑延迟执行。判断启动性能**别把 onLaunch 的 await 当阻塞**。
- **代码审查避免过度判断**:给本项目做问题分析时,勿把"合理设计选择/优化建议"拔高成"问题/风险"。已被纠错的判断:①隐私协议缺失(实为后台配置,代码看不到)②onLaunch await 阻塞启动(机制理解错)③组件默认 isolated 不一致(实为合理分层)。下结论前先核实机制事实,区分"真 bug / 可优化 / 合理设计"。 - **代码审查避免过度判断**:给本项目做问题分析时,勿把"合理设计选择/优化建议"拔高成"问题/风险"。已被纠错的判断:①隐私协议缺失(实为后台配置,代码看不到)②onLaunch await 阻塞启动(机制理解错)③组件默认 isolated 不一致(实为合理分层)。下结论前先核实机制事实,区分"真 bug / 可优化 / 合理设计"。
- **自定义 tabBar 的 `pageLifetimes` 不触发**:微信框架的自定义 tabBar 由框架管理,不走普通自定义组件的页面生命周期钩子(`pageLifetimes.show/hide` 不触发)。因此主题/暗黑模式等状态同步**不能依赖 `pageLifetimes.show`**,必须在各 tab 页 `onShow` 里显式调 `this.getTabBar()` 更新(如 `tb.updateTheme()`)。`getCurrentPages()` 也只返回当前 tab 页(其他 tab 不在页面栈),跨 tab 通知无效。
## 排行榜架构(2026-07-28+,云函数 `cloudfunctions/leaderboard` ## 排行榜架构(2026-07-28+,云函数 `cloudfunctions/leaderboard`
- **预计算快照**:定时触发器(每 4min)`_rebuildSnapshots()``plank_data` 算 day/month/year/endurance 四榜写 `leaderboard_snapshot`;客户端 99% 走 `_serveFromSnapshot()`(≈50ms),仅训练后 `force=true` 那次实时重算+回写。**改后须重新部署云函数**(定时器随部署注册)。 - **预计算快照**:定时触发器(每 4min)`_rebuildSnapshots()``plank_data` 算 day/month/year/endurance 四榜写 `leaderboard_snapshot`;客户端 99% 走 `_serveFromSnapshot()`(≈50ms),仅训练后 `force=true` 那次实时重算+回写。**改后须重新部署云函数**(定时器随部署注册)。
@@ -26,5 +28,7 @@
## 约定 ## 约定
- 页面叫法:index=训练首页,timer=训练页,leaderboard=排行榜,records=记录,settings=设置。 - 页面叫法:index=训练首页,timer=训练页,leaderboard=排行榜,records=记录,settings=设置。
- 美化按"见效快优先"分批,每轮 `node --check` + grep 残留再交付。 - 美化按"见效快优先"分批,每轮 `node --check` + grep 残留再交付。
- **发版更新通知(What's New)**`config.js` 维护 `version`+`releaseNotes`(数组,每条一行短句);首页 `index.js``_checkUpdateNote()``onShow` 检测 `wx.getStorageSync('lastUpdateNoteVersion')``config.version` 不一致→弹 `ui-modal`(position:center) 居中通知,并写回 storage 防重复。纯通知不重启。发版时**改 version 同步更新 releaseNotes** 即可,前端改动无需动云函数。
- **git push 须 `dangerouslyDisableSandbox:true`**(沙箱隔离致 github SSL 超时);无 SSH,仅 HTTPS remote。 - **git push 须 `dangerouslyDisableSandbox:true`**(沙箱隔离致 github SSL 超时);无 SSH,仅 HTTPS remote。
- **GitHub 链路不稳**:国内环境到 `github.com:443` 常 SSL 重置/HTTP2 framing 失败(非代码问题),`git.soao.net`(soao) 国内远程稳定。策略:soao 为主远程、实时同步;`origin`(GitHub) 失败则延后推或本地代理(`127.0.0.1:7890` 等)临时 `git -C <repo> config http.proxy` 推完 `config --unset` 撤销。代码已 commit 则零丢失风险。 - **GitHub 链路不稳**:国内环境到 `github.com:443` 常 SSL 重置/HTTP2 framing 失败(非代码问题),`git.soao.net`(soao) 国内远程稳定。策略:soao 为主远程、实时同步;`origin`(GitHub) 失败则延后推或本地代理(`127.0.0.1:7890` 等)临时 `git -C <repo> config http.proxy` 推完 `config --unset` 撤销。代码已 commit 则零丢失风险。
- **GitHub 推送有效解法(2026-08-12 实测)**:报 `LibreSSL SSL_connect: SSL_ERROR_SYSCALL` 时,若 `curl 直连 https://github.com` 能 200(慢 30s 但通),说明是 git 默认 HTTP/2 framing 问题(curl 默认 HTTP/1.1 所以通)→ 执行 `git -C <repo> config http.version HTTP/1.1` 后再 push 即可成功,推完 `config --unset http.version` 撤销。7890 端口进程可能空转(curl -x 走它返回 000),别依赖该代理。
+45
View File
@@ -0,0 +1,45 @@
# 文风 DNA(默认模板)
适用:产品功能更新介绍 / 终端用户向
来源:无历史文章,使用默认模板兜底(2026-08-04)
## 1. 语气温度
温暖、像朋友安利,不端着、不官方腔。
## 2. 人称
第二人称「你」为主;「我们」指开发团队。
## 3. 句式节奏
短句为主(15 字内),复杂处拆成 2-3 句。
## 4. 段落长度
每段 2-4 行,移动端一眼扫完,不堆大段。
## 5. 标题风格
利益点 / 数字前置,如「这次更新,给你 3 个新惊喜」。
## 6. 开头方式
直接抛痛点或好处,1 句切入,不寒暄、不写「大家好」。
## 7. 小标题
动作 / 利益导向,前加 emoji(✨🏆📈 等)增强扫读。
## 8. 列表呈现
功能点用短列表,每点按「做了什么 + 对你意味着什么」展开。
## 9. 用词
口语化,少术语;必要术语用一句话白话解释。
## 10. 结尾
行动召唤:引导更新 / 体验,留一句鼓励收尾。
## 11. emoji 使用
适度点缀小标题与关键句,不堆砌、不喧宾夺主。
## 12. 故事感
轻量:可加一句用户场景(如「坚持打卡第 7 天,想看看自己进步了吗?」)。
## 13. 证据 / 可信度
用具体功能名 + 真实使用场景,避免过度吹捧。
## 14. 节奏结构
先总(一句话更新概览)→ 分(功能块)→ 总(召唤行动)。
+10
View File
File diff suppressed because one or more lines are too long
+253 -10
View File
@@ -14,27 +14,47 @@ Component({
trackColor: { type: String, value: '#EEEEEE' }, trackColor: { type: String, value: '#EEEEEE' },
// Total elapsed seconds (since start). Shown in the ring center once the // Total elapsed seconds (since start). Shown in the ring center once the
// countdown hits 0 (overtime mode) - replaces the old +Xs badge. // countdown hits 0 (overtime mode) - replaces the old +Xs badge.
elapsed: { type: Number, value: 0 } elapsed: { type: Number, value: 0 },
// Tick count. Caller passes the duration (one tick per second, capped at
// 60 to avoid clutter). 0 = no ticks drawn. Quarter ticks are emphasized.
ticks: { type: Number, value: 0 },
// Small secondary label under the big time (e.g. "目标 30 秒"). Empty = hidden.
subText: { type: String, value: '' },
// ---- Circuit (循环训练) set progress ----
// An outer band of arc segments, one per set, wrapped around the countdown
// ring. sets <= 0 (default) disables the band entirely so single/free mode
// renders exactly as before (zero extra draw cost).
sets: { type: Number, value: 0 },
// 1-based index of the set the CircuitTimer is currently on. While resting it
// still points at the just-finished set (same contract as the old dots panel):
// working -> sets before it are done, itself is active;
// resting -> itself is done, the next set is highlighted green.
currentSet: { type: Number, value: 0 },
resting: { type: Boolean, value: false },
// Highlight color for the "next set" preview during rest.
successColor: { type: String, value: '#00B578' }
}, },
data: { data: {
displayTime: '00:00', displayTime: '00:00'
statusText: '准备开始'
}, },
observers: { observers: {
'remaining, status, duration, elapsed'() { 'remaining, status, duration, elapsed'() {
const texts = { idle: '准备开始', running: '坚持住', paused: '已暂停', completed: '完成!' }
// 倒计时阶段显示剩余;达标后 remaining 归零,改显总训练时长(overtime 模式) // 倒计时阶段显示剩余;达标后 remaining 归零,改显总训练时长(overtime 模式)
const shown = this.data.remaining > 0 ? this.data.remaining : (this.data.elapsed || 0) const shown = this.data.remaining > 0 ? this.data.remaining : (this.data.elapsed || 0)
this.setData({ this.setData({
displayTime: util.formatTime(shown), displayTime: util.formatTime(shown)
statusText: texts[this.data.status] || ''
}) })
}, },
'remaining, duration, size, ringWidth, primaryColor, primaryLightColor, trackColor'() { 'remaining, duration, size, ringWidth, primaryColor, primaryLightColor, trackColor, ticks'() {
this._draw() this._draw()
},
// Set-progress band: re-render and (re)arm / stop the breathing loop.
'sets, currentSet, resting, status'() {
this._syncSetPulse()
} }
}, },
@@ -52,8 +72,14 @@ Component({
canvas.height = displaySize * dpr canvas.height = displaySize * dpr
this._ctx = canvas.getContext('2d') this._ctx = canvas.getContext('2d')
this._dpr = dpr this._dpr = dpr
this._canvas = canvas
this._draw() this._draw()
this._syncSetPulse()
}) })
},
detached() {
this._stopSetPulse()
} }
}, },
@@ -70,7 +96,7 @@ Component({
const ctx = this._ctx const ctx = this._ctx
if (!ctx) return if (!ctx) return
const { size, ringWidth, duration, remaining, primaryColor, primaryLightColor, trackColor } = this.data const { size, ringWidth, duration, remaining, primaryColor, primaryLightColor, trackColor, ticks } = this.data
const dpr = this._dpr const dpr = this._dpr
const w = size * dpr const w = size * dpr
@@ -79,8 +105,11 @@ Component({
const cx = w / 2 const cx = w / 2
const cy = h / 2 const cy = h / 2
// reserve room for the glowing end dot so its shadow isn't clipped // Reserve room for the glowing end dot's shadow AND the outer set-progress
const glowPad = 6 * dpr // band (circuit mode). _drawSetArcs derives its band radius from this same
// pad, so increasing it here also widens the gap between the countdown
// arc and the set band. 18dpr gives a ~10dpr visible gap (size=420).
const glowPad = 18 * dpr
const radius = (size - ringWidth) / 2 * dpr - glowPad const radius = (size - ringWidth) / 2 * dpr - glowPad
const lw = ringWidth * dpr const lw = ringWidth * dpr
@@ -92,6 +121,43 @@ Component({
ctx.lineCap = 'round' ctx.lineCap = 'round'
ctx.stroke() ctx.stroke()
// ---- tick marks (inside the band) ----
if (ticks > 0) {
const innerR = radius - lw / 2 - (4 * dpr) // just inside the band
const shortLen = 6 * dpr
const longLen = 12 * dpr
const stepDeg = 360 / ticks
// 短刻度:均匀铺满;但跳过靠近四方位(12/3/6/9 点)的,让位给长刻度,避免双线重叠
for (let i = 0; i < ticks; i++) {
const deg = (i / ticks) * 360
const nearestQuarter = Math.round(deg / 90) * 90
if (Math.abs(deg - nearestQuarter) < stepDeg) continue
const ang = -Math.PI / 2 + (deg * Math.PI) / 180
const cos = Math.cos(ang)
const sin = Math.sin(ang)
ctx.beginPath()
ctx.moveTo(cx + innerR * cos, cy + innerR * sin)
ctx.lineTo(cx + (innerR - shortLen) * cos, cy + (innerR - shortLen) * sin)
ctx.lineWidth = 1.5 * dpr
ctx.lineCap = 'round'
ctx.strokeStyle = 'rgba(0,0,0,0.12)'
ctx.stroke()
}
// 四分位长刻度:精确落在 12/3/6/9 点(无论 ticks 是否整除 4,都对齐正中)
for (let k = 0; k < 4; k++) {
const ang = -Math.PI / 2 + k * (Math.PI / 2)
const cos = Math.cos(ang)
const sin = Math.sin(ang)
ctx.beginPath()
ctx.moveTo(cx + innerR * cos, cy + innerR * sin)
ctx.lineTo(cx + (innerR - longLen) * cos, cy + (innerR - longLen) * sin)
ctx.lineWidth = 3 * dpr
ctx.lineCap = 'round'
ctx.strokeStyle = 'rgba(0,0,0,0.28)'
ctx.stroke()
}
}
// progress arc (clockwise from 12 o'clock) // progress arc (clockwise from 12 o'clock)
const ratio = duration > 0 ? Math.max(0, Math.min(1, remaining / duration)) : 0 const ratio = duration > 0 ? Math.max(0, Math.min(1, remaining / duration)) : 0
if (ratio > 0.001) { if (ratio > 0.001) {
@@ -143,6 +209,183 @@ Component({
ctx.fill() ctx.fill()
ctx.restore() ctx.restore()
} }
// set-progress band (circuit mode only; no-op when sets <= 0)
this._drawSetArcs(this._setPulsePhase || 0.6)
},
/**
* Map currentSet/resting onto the three segment states, mirroring the old
* dots panel's contract (see the property doc for the semantics).
*/
_setArcState() {
const { sets, currentSet, resting } = this.data
let done = 0
let active = 0
let next = 0
if (sets > 0 && currentSet > 0) {
if (resting) {
done = Math.min(currentSet, sets)
if (currentSet < sets) next = currentSet + 1
} else {
done = Math.max(0, currentSet - 1)
active = currentSet
}
}
return { done, active, next }
},
/**
* Draw (or re-draw) the outer set-progress band. `pulse` is the breathing
* phase 0..1 used for the active / next segment; pass a fixed value for a
* static render (idle / paused).
*
* Only the band itself is cleared (clipped to a ring) so the inner
* countdown arc is never touched — the rAF loop below re-renders this
* band every frame without interfering with the per-second _draw().
*/
_drawSetArcs(pulse) {
const ctx = this._ctx
if (!ctx) return
const { size, ringWidth, sets, primaryColor, trackColor, successColor, status } = this.data
if (!sets || sets <= 0) return
const dpr = this._dpr
const cx = (size / 2) * dpr
const cy = (size / 2) * dpr
// Same radius math as _draw(): the countdown arc sits at `radius`; the
// set band wraps just outside it so the two read as concentric layers.
// NOTE: the -18*dpr pad MUST stay in sync with glowPad in _draw().
const radius = ((size - ringWidth) / 2) * dpr - 18 * dpr
const arcR = radius + (ringWidth / 2) * dpr + 14 * dpr
const arcW = 8 * dpr
const band = arcW / 2 + 3 * dpr
const { done, active, next } = this._setArcState()
// Clear only the outer band (ring clip -> inner arc untouched).
ctx.save()
ctx.beginPath()
ctx.arc(cx, cy, arcR + band, 0, Math.PI * 2)
ctx.arc(cx, cy, arcR - band, 0, Math.PI * 2, true)
ctx.clip()
ctx.clearRect(cx - arcR - band, cy - arcR - band, (arcR + band) * 2, (arcR + band) * 2)
ctx.restore()
const p = pulse == null ? 0.6 : Math.max(0, Math.min(1, pulse))
const paused = status === 'paused'
const segAngle = (Math.PI * 2) / sets
const gap = (2.5 * Math.PI) / 180
const startAngle = -Math.PI / 2
// Pass 1: draw every segment arc. Breathing dots are collected and painted
// in pass 2, AFTER all arcs, so a dot always floats ON TOP of its segment
// instead of being partially buried under neighbouring round caps.
const dots = []
for (let i = 1; i <= sets; i++) {
const a0 = startAngle + (i - 1) * segAngle + gap / 2
const a1 = startAngle + i * segAngle - gap / 2
let color = trackColor
if (i <= done || i === active) color = primaryColor
else if (i === next) color = successColor
const emphasized = i === active || i === next
const alpha = emphasized ? (paused ? 0.7 : 0.45 + 0.55 * p) : 1
ctx.globalAlpha = alpha
ctx.beginPath()
ctx.arc(cx, cy, arcR, a0, a1)
ctx.strokeStyle = color
ctx.lineWidth = arcW
ctx.lineCap = 'round'
ctx.stroke()
ctx.globalAlpha = 1
if (emphasized) {
const am = (a0 + a1) / 2
dots.push({
x: cx + arcR * Math.cos(am),
y: cy + arcR * Math.sin(am),
color: i === active ? primaryColor : successColor
})
}
}
// Pass 2: glowing dot on each emphasized segment, drawn over every arc.
// Placed at the segment MIDDLE (not the a1 end): a dot pinned to the
// trailing end made that end look visibly thicker than the round-cap
// start, so both ends of the arc stay symmetric. Slightly larger than
// the band width so it reads as a bead riding on top of the arc.
for (const dot of dots) {
const dotR = (arcW / 2) * (paused ? 1.3 : 1.15 + 0.25 * p)
ctx.save()
ctx.shadowColor = dot.color
ctx.shadowBlur = 8 * dpr
ctx.globalAlpha = paused ? 0.95 : 0.7 + 0.3 * p
ctx.beginPath()
ctx.arc(dot.x, dot.y, dotR, 0, Math.PI * 2)
ctx.fillStyle = dot.color
ctx.fill()
ctx.restore()
// white core for a "light bulb" feel
ctx.beginPath()
ctx.arc(dot.x, dot.y, dotR * 0.45, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.fill()
}
},
_startSetPulse() {
if (this._setPulseRAF != null) return
const canvas = this._canvas
if (!canvas || typeof canvas.requestAnimationFrame !== 'function') {
// Older canvas implementations without rAF: render one static frame.
this._drawSetArcs(0.6)
return
}
const loop = () => {
if (!this._ctx) return
const t = Date.now() / 1000
this._setPulsePhase = 0.5 + 0.5 * Math.sin((t * Math.PI * 2) / 1.6)
this._drawSetArcs(this._setPulsePhase)
this._setPulseRAF = canvas.requestAnimationFrame(loop)
}
this._setPulseRAF = canvas.requestAnimationFrame(loop)
},
_stopSetPulse() {
if (this._setPulseRAF == null) return
const canvas = this._canvas
if (canvas && typeof canvas.cancelAnimationFrame === 'function') {
canvas.cancelAnimationFrame(this._setPulseRAF)
}
this._setPulseRAF = null
},
/**
* (Re)arm or stop the breathing loop based on the current state.
* - sets <= 0 -> nothing to draw (single/free mode)
* - idle / completed -> static render (no active/next segment)
* - paused -> static render (pausing freezes all motion)
* - working / resting -> breathe the active / next segment
*/
_syncSetPulse() {
if (!this._ctx) return
const { sets, status } = this.data
if (sets <= 0) {
this._stopSetPulse()
return
}
const state = this._setArcState()
const wantsPulse = (state.active > 0 || state.next > 0) && status !== 'paused'
if (wantsPulse) {
this._startSetPulse()
} else {
this._stopSetPulse()
this._setPulsePhase = 0.6
this._drawSetArcs(0.6)
}
} }
} }
}) })
+1 -1
View File
@@ -2,6 +2,6 @@
<canvas type="2d" id="ringCanvas" class="ring-canvas" style="width: {{size}}rpx; height: {{size}}rpx;"></canvas> <canvas type="2d" id="ringCanvas" class="ring-canvas" style="width: {{size}}rpx; height: {{size}}rpx;"></canvas>
<view class="ring-center"> <view class="ring-center">
<text class="time-text">{{displayTime}}</text> <text class="time-text">{{displayTime}}</text>
<text class="status-text">{{statusText}}</text> <text class="sub-text" wx:if="{{subText}}">{{subText}}</text>
</view> </view>
</view> </view>
+4 -4
View File
@@ -21,7 +21,7 @@
} }
.time-text { .time-text {
font-size: 88rpx; font-size: 96rpx;
font-weight: 800; font-weight: 800;
color: var(--text); color: var(--text);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -29,9 +29,9 @@
line-height: 1; line-height: 1;
} }
.status-text { .sub-text {
font-size: 28rpx; font-size: 26rpx;
color: var(--text-secondary); color: var(--text-secondary);
margin-top: 10rpx; margin-top: 14rpx;
line-height: 1; line-height: 1;
} }
+25 -3
View File
@@ -4,10 +4,23 @@
*/ */
module.exports = { module.exports = {
/** 应用版本号,外显在设置页「关于」 */ /** 应用版本号,外显在设置页「关于」 */
version: 'v3.2', version: 'v3.3',
/** 最后更新日期,外显在设置页「关于」 */ /** 最后更新日期,外显在设置页「关于」 */
updatedAt: '2026-08-04', updatedAt: '2026-08-14',
/**
* 新版本更新通知(What's New)要点。首页冷启动检测 config.version 与本地
* 记录版本不一致时,弹一次居中通知弹窗展示这些要点。每次发版改 version
* 的同时更新本数组即可(措辞尽量短,每条一行)。
*/
releaseNotes: [
'循环训练分段弧优化:光点居中、与进度弧间距拉大,看得更清楚',
'暗黑模式切换后,各页面底部栏同步变暗',
'循环训练偶数组数:休息语音不再被「完成一半」提示覆盖',
'训练完成新增分享海报,成绩展示更完整',
'训练时长不足 20 秒不计入记录'
],
/** 开发者名称 / 微信号,设置页点击可复制 */ /** 开发者名称 / 微信号,设置页点击可复制 */
developer: '三口一瓶', developer: '三口一瓶',
@@ -25,7 +38,7 @@ module.exports = {
* 耐力榜领奖台顶部文案。哲理注脚,点出"撑得越久不一定越好"的价值观, * 耐力榜领奖台顶部文案。哲理注脚,点出"撑得越久不一定越好"的价值观,
* 与耐力榜(比谁单次撑最久)形成反差。仅耐力榜领奖台顶部显示,改这里即可。 * 与耐力榜(比谁单次撑最久)形成反差。仅耐力榜领奖台顶部显示,改这里即可。
*/ */
leaderboardPodiumSlogan: '平板支撑不是一场和时间的恋爱,撑得越久,不一定越好。真正重要的,是每一秒都没有辜负身体。', leaderboardPodiumSlogan: '平板支撑不是计时器比赛,是肌肉的精细控制。宁做30秒的钢板,不做5分钟的烂泥。',
/** /**
* 语音合成(TTS)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。 * 语音合成(TTS)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。
@@ -67,6 +80,15 @@ module.exports = {
*/ */
caloriesPerSecond: 0.068, caloriesPerSecond: 0.068,
/**
* 最短记录时长(秒)。本次训练有效时长低于该值时:
* - 不写入训练记录、不更新连续打卡天数(避免"撑几秒也算一天"的无效记录)
* - 完成弹窗会明确提示"本次未计入记录",并隐藏查看记录/分享入口
* 循环训练按"有效撑持秒数"(各组时长之和,不含休息)判断。
* 改这里即可调整门槛,无需动逻辑代码。
*/
minRecordSeconds: 15,
/** /**
* 完成弹窗科学提示(按本次训练时长分段)。 * 完成弹窗科学提示(按本次训练时长分段)。
* 完成时按实际撑的秒数命中第一段 maxSeconds 大于它的配置, * 完成时按实际撑的秒数命中第一段 maxSeconds 大于它的配置,
+105 -60
View File
@@ -15,25 +15,31 @@ Page({
todayDone: false, todayDone: false,
todayDuration: 0, todayDuration: 0,
todayTargetText: '', todayTargetText: '',
// Ring number font size in rpx. WeChat auto-scales text by the user's // 顶部问候区的用户资料(头像 + 昵称),来自 storage.getProfile()。
// font-size setting (fontSizeScaleFactor) but does NOT scale rpx box // 空则回退占位(人形图标 + "运动达人"),settings 改完回首页即刷新。
// dimensions — so on a real device with enlarged system font the 84rpx nickName: '',
// number grows past the fixed-size circular ring and gets clipped. We avatarUrl: '',
// cancel that scale here so the number renders at a constant visual size
// (matching the devtools/simulator) on every device. See _readFontScale().
targetTimeFontSize: 84,
streakCount: 0, streakCount: 0,
planName: '', planName: '',
planDay: 1, planDay: 1,
planTotal: 7, planTotal: 7,
planProgress: 0, // hero / 进度地图 / 时长展示相关字段(由 refresh 生成)
useSegments: false, greetText: '你好',
planDays: [], streakText: '今天开启坚持之旅',
questNodes: [],
questTip: '',
estKcal: 0,
showPicker: false, showPicker: false,
pickerInputFocus: false, pickerInputFocus: false,
presets: [30, 60, 90, 120, 180, 300], presets: [30, 60, 90, 120, 180, 300],
customDuration: 60, customDuration: 60,
customInput: '', customInput: '',
// --- 新版本更新通知(What's New) ---
showUpdateNote: false,
updateNoteVersion: '',
updateNoteItems: [],
updateNoteDate: '',
// --- Circuit (循环训练) config panel (persisted via storage.saveCircuitConfig) --- // --- Circuit (循环训练) config panel (persisted via storage.saveCircuitConfig) ---
showCircuitPicker: false, showCircuitPicker: false,
cHold: 30, cHold: 30,
@@ -45,55 +51,18 @@ Page({
onLoad() { onLoad() {
themeMod.applyThemeToPage(this) themeMod.applyThemeToPage(this)
this._readFontScale()
this._firstShow = true this._firstShow = true
}, },
/**
* Read the device's WeChat font-size scale and compensate the ring number
* so it never overflows the fixed circular clip on real devices.
*
* WeChat auto-applies `fontSizeScaleFactor` to ALL text (including rpx font
* sizes) but leaves rpx box dimensions untouched. A user who bumps up the
* system font therefore sees enlarged text inside a non-enlarged ring.
*
* CRITICAL asymmetry: the DevTools simulator reports the SAME
* `fontSizeScaleFactor` as a real device (the user's actual WeChat setting)
* but does NOT actually scale rendered text. So if we compensate there, the
* number ends up too small in the simulator. We therefore SKIP the
* compensation under DevTools (host.env === 'devtools') and keep the design
* size — matching what the simulator renders literally.
*
* On real devices, dividing our design size by the factor cancels the
* auto-scale, keeping the number a constant 84rpx-equivalent everywhere.
* Bounded so a bad reading can only ever make the number smaller (safe),
* never larger (overflow).
*/
_readFontScale() {
let factor = 1
try {
const info = wx.getAppBaseInfo ? wx.getAppBaseInfo() : wx.getSystemInfoSync()
const isDevtools = info.host && info.host.env === 'devtools'
if (!isDevtools) {
if (typeof info.fontSizeScaleFactor === 'number' && info.fontSizeScaleFactor > 0) {
factor = info.fontSizeScaleFactor
} else if (typeof info.fontSizeSetting === 'number' && info.fontSizeSetting > 0) {
// fontSizeScaleFactor == currentFontSize / standardFontSize(17px)
factor = info.fontSizeSetting / 17
}
}
} catch (e) {}
const DESIGN = 84
let size = Math.round(DESIGN / factor)
size = Math.max(48, Math.min(96, size))
this.setData({ targetTimeFontSize: size })
},
onShow() { onShow() {
themeMod.applyThemeToPage(this) themeMod.applyThemeToPage(this)
this._checkUpdateNote()
try { try {
const tb = this.getTabBar() const tb = this.getTabBar()
if (tb) tb.setData({ selected: 0 }) if (tb) {
tb.setData({ selected: 0 })
tb.updateTheme()
}
} catch (e) {} } catch (e) {}
// Skip the first onShow to avoid double-refresh; subsequent tab switches still refresh. // Skip the first onShow to avoid double-refresh; subsequent tab switches still refresh.
if (this._firstShow) { if (this._firstShow) {
@@ -130,6 +99,28 @@ Page({
} catch (e) {} } catch (e) {}
}, },
/**
* 新版本首次启动的 What's New 通知。复用 config.version 作为版本号,
* 用 storage 记录已提示过的版本,避免每次冷启动重复弹出。纯通知,不重启应用。
*/
_checkUpdateNote() {
try {
const shown = wx.getStorageSync('lastUpdateNoteVersion')
if (shown === config.version) return
this.setData({
showUpdateNote: true,
updateNoteVersion: config.version,
updateNoteItems: config.releaseNotes || [],
updateNoteDate: config.updatedAt || ''
})
wx.setStorageSync('lastUpdateNoteVersion', config.version)
} catch (e) {}
},
onCloseUpdateNote() {
this.setData({ showUpdateNote: false })
},
onPullDownRefresh() { onPullDownRefresh() {
this.refresh() this.refresh()
wx.stopPullDownRefresh() wx.stopPullDownRefresh()
@@ -148,11 +139,17 @@ Page({
const target = planMod.getTodayTarget(settings.planId, clampedDay, customPlans) const target = planMod.getTodayTarget(settings.planId, clampedDay, customPlans)
const theme = themeMod.getCurrentTheme() const theme = themeMod.getCurrentTheme()
// 防御:totalDays 为 0(极端数据损坏)时避免除以 0 得到 NaN const profile = storage.getProfile()
const planProgress = plan.totalDays > 0 ? Math.round((clampedDay / plan.totalDays) * 100) : 0 const greetText = this._greetByHour()
// 分段格子(每天一格)只在计划天数 <= 10 时用;更长则退化为里程碑连续条,避免格子过密 const streakText = streak.count > 0
const useSegments = plan.totalDays > 0 && plan.totalDays <= 10 ? `已连续 ${streak.count} 天打卡`
const planDays = useSegments ? Array.from({ length: plan.totalDays }, (_, i) => i) : [] : '今天开启坚持之旅'
const questNodes = this._buildQuestMap(plan.totalDays, clampedDay)
const questTip = clampedDay >= plan.totalDays
? '🎉 本期计划已完成,去记录页看看战绩'
: `⚡ 再坚持 ${plan.totalDays - clampedDay} 天,完成本期计划`
// 千卡粗算:按约 4 千卡/分钟估算(1分30秒≈6千卡)。仅作激励性参考,非精确值。
const estKcal = Math.max(1, Math.round((target / 60) * 4))
this.setData({ this.setData({
theme, theme,
icons: iconsMod.build(theme), icons: iconsMod.build(theme),
@@ -164,12 +161,60 @@ Page({
planName: plan.name, planName: plan.name,
planDay: clampedDay, planDay: clampedDay,
planTotal: plan.totalDays, planTotal: plan.totalDays,
planProgress, greetText,
useSegments, streakText,
planDays questNodes,
questTip,
estKcal,
nickName: profile.nickname || '',
avatarUrl: profile.avatarUrl || ''
}) })
}, },
_greetByHour() {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 12) return '早上好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
if (h < 22) return '晚上好'
return '夜深了'
},
/**
* 生成闯关进度地图节点。计划天数 <= 8 时画每天节点;> 8 时退化为
* 5 个里程碑节点(0/25/50/75/100%),避免节点过密。state: done|now|todo。
*/
_buildQuestMap(total, day) {
const nodes = []
if (total > 0 && total <= 8) {
for (let i = 1; i <= total; i++) {
nodes.push({
label: i === total ? '🏆' : String(i),
state: i < day ? 'done' : (i === day ? 'now' : 'todo'),
final: i === total
})
}
return nodes
}
const marks = [0, 0.25, 0.5, 0.75, 1]
let nowAssigned = false
marks.forEach((m, idx) => {
const dayAt = Math.round(m * total)
const isFinal = idx === marks.length - 1
let state
if (dayAt <= day) state = 'done'
else if (!nowAssigned) { state = 'now'; nowAssigned = true }
else state = 'todo'
nodes.push({
label: isFinal ? '🏆' : (m === 0 ? '起' : String(dayAt)),
state,
final: isFinal
})
})
return nodes
},
onStartTrain() { onStartTrain() {
wx.navigateTo({ url: '/pages/timer/timer' }) wx.navigateTo({ url: '/pages/timer/timer' })
}, },
+61 -79
View File
@@ -1,95 +1,59 @@
<view class="container page-enter" style="{{themeStyle}}"> <view class="container page-enter index-page" style="{{themeStyle}}">
<!-- 顶部问候 --> <!-- 全宽渐变头图 hero -->
<view class="greeting-row"> <view class="hero">
<view class="greeting-text"> <view class="hero-top">
<image class="greeting-icon" src="{{todayDone ? icons.successFill : icons.emojiFill}}" mode="aspectFit"></image> <!-- 圆形头像:无头像时白底 + peopleFill 占位(橙图标在白底清晰);
<text class="greeting-title">{{todayDone ? '太棒了!' : '准备好了吗?'}}</text> 有头像时透明描边,远程(cloud://)头像靠外层 overflow:hidden 裁圆 -->
<view class="hero-avatar {{avatarUrl ? 'has-avatar' : ''}}">
<image
class="hero-avatar-img"
src="{{avatarUrl || icons.peopleFill}}"
mode="{{avatarUrl ? 'aspectFill' : 'aspectFit'}}"
></image>
</view>
<view class="hero-user">
<text class="hero-greet">{{greetText}}{{nickName || '运动达人'}}</text>
<text class="hero-social">{{streakText}}</text>
</view>
</view>
<view class="hero-challenge">
<text class="hero-time">「{{todayTargetText}}」</text>
<text class="hero-cta-text">敢不敢来挑战?</text>
</view> </view>
<text class="greeting-sub">{{todayDone ? '继续保持这个势头' : '今天的平板支撑等着你'}}</text>
</view> </view>
<!-- 连续打卡卡片:火焰连胜 + 分段进度 --> <!-- 闯关进度地图(上浮叠在 hero 下) -->
<ui-card variant="gradient in" index="0" class="{{justCompleted ? 'just-completed' : ''}}"> <ui-card variant="in" index="0" class="quest-card {{justCompleted ? 'just-completed' : ''}}">
<view class="header-card"> <view class="quest-head">
<!-- 上区:连胜视觉重心 + 计划徽章 --> <text class="quest-title">{{planName}}<text class="quest-sub">第 {{planDay}} / {{planTotal}} 天</text></text>
<view class="streak-row">
<view class="streak-section">
<view class="streak-icon-wrap">
<view class="streak-halo"></view>
<image class="streak-icon" src="{{icons.hotWhite}}" mode="aspectFit"></image>
</view> </view>
<view class="streak-text"> <view class="quest-map">
<text class="streak-num">{{streakCount}}</text> <block wx:for="{{questNodes}}" wx:key="label">
<text class="streak-label">连续打卡</text> <view class="q-node {{item.state}} {{item.final ? 'final' : ''}}">{{item.label}}</view>
</view> <view wx:if="{{index < questNodes.length - 1}}" class="q-link {{item.state === 'done' ? 'done' : ''}}"></view>
</view>
<view class="plan-badge">
<image class="plan-badge-icon" src="{{icons.formWhite}}" mode="aspectFit"></image>
<text class="plan-badge-text">{{planName}}</text>
</view>
</view>
<!-- 下区:分段格子(<=10天)或里程碑连续条(>10天) -->
<view class="segment-progress">
<view class="segment-track" wx:if="{{useSegments}}">
<view
wx:for="{{planDays}}"
wx:key="*this"
class="segment-cell {{index < planDay - 1 ? 'done' : ''}} {{index === planDay - 1 ? 'current' : ''}}"
></view>
</view>
<view class="milestone-track" wx:else>
<view class="milestone-bar">
<view class="milestone-fill" style="width: {{planProgress}}%;"></view>
<view class="milestone-node" style="left: 0%;"></view>
<view class="milestone-node" style="left: 25%;"></view>
<view class="milestone-node" style="left: 50%;"></view>
<view class="milestone-node" style="left: 75%;"></view>
<view class="milestone-node" style="left: 100%;"></view>
</view>
</view>
<text class="progress-label">第 {{planDay}} / {{planTotal}} 天</text>
</view>
</view>
</ui-card>
<!-- 今日目标卡片 -->
<ui-card variant="in" index="1">
<view class="target-section">
<view class="target-header">
<image class="section-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
<text class="section-title">今日目标</text>
</view>
<view class="target-display">
<view class="target-ring">
<text class="target-time" style="font-size: {{targetTimeFontSize}}rpx;">{{todayTargetText}}</text>
</view>
</view>
<view class="today-status">
<block wx:if="{{todayDone}}">
<view class="done-badge">
<image class="done-icon" src="{{icons.successFill}}" mode="aspectFit"></image>
<text class="done-text">今日已完成 {{todayDuration}} 秒</text>
</view>
</block>
<block wx:else>
<image class="pending-icon" src="{{icons.time}}" mode="aspectFit"></image>
<text class="pending-text">还未开始训练</text>
</block> </block>
</view> </view>
</view> <text class="quest-tip">{{questTip}}</text>
</ui-card> </ui-card>
<!-- 今日目标时长(无圆环, 大字流光时间) -->
<view class="timer-wrap">
<text class="time-display">{{todayTargetText}}</text>
<text class="time-label">今日目标时长</text>
<view class="benefit">⚡ 预计消耗 <text class="benefit-hl">{{estKcal}}</text> 千卡</view>
</view>
<!-- 操作按钮区 --> <!-- 操作按钮区 -->
<view class="action-buttons"> <view class="action-buttons">
<ui-btn <ui-btn
variant="primary" variant="primary"
size="xl" size="xl"
block block
text="{{todayDone ? '再次训练' : '开始训练'}}" text="{{todayDone ? '再战一次 ' : '立即挑战 '}}{{todayTargetText}}"
icon-src="{{icons.playWhite}}" icon-src="{{icons.playWhite}}"
bindtap="onStartTrain" bindtap="onStartTrain"
custom-style="border-radius:16rpx;" custom-style="border-radius:16rpx;box-shadow:0 8rpx 20rpx rgba(var(--primary-rgb),0.35);animation:ctaPulse 2s ease-in-out infinite;"
></ui-btn> ></ui-btn>
<view class="action-row"> <view class="action-row">
@@ -172,9 +136,9 @@
<view class="circuit-row"> <view class="circuit-row">
<text class="circuit-row-label">每组时长</text> <text class="circuit-row-label">每组时长</text>
<view class="stepper"> <view class="stepper">
<view class="stepper-btn" data-field="hold" data-delta="-5" bindtap="onCircuitStep"></view> <view class="stepper-btn" data-field="hold" data-delta="-10" bindtap="onCircuitStep"></view>
<text class="stepper-val">{{cHold}}秒</text> <text class="stepper-val">{{cHold}}秒</text>
<view class="stepper-btn" data-field="hold" data-delta="5" bindtap="onCircuitStep">+</view> <view class="stepper-btn" data-field="hold" data-delta="10" bindtap="onCircuitStep">+</view>
</view> </view>
</view> </view>
@@ -190,9 +154,9 @@
<view class="circuit-row"> <view class="circuit-row">
<text class="circuit-row-label">组间休息</text> <text class="circuit-row-label">组间休息</text>
<view class="stepper"> <view class="stepper">
<view class="stepper-btn" data-field="rest" data-delta="-5" bindtap="onCircuitStep"></view> <view class="stepper-btn" data-field="rest" data-delta="-10" bindtap="onCircuitStep"></view>
<text class="stepper-val">{{cRest}}秒</text> <text class="stepper-val">{{cRest}}秒</text>
<view class="stepper-btn" data-field="rest" data-delta="5" bindtap="onCircuitStep">+</view> <view class="stepper-btn" data-field="rest" data-delta="10" bindtap="onCircuitStep">+</view>
</view> </view>
</view> </view>
@@ -215,4 +179,22 @@
></ui-btn> ></ui-btn>
<text class="picker-cancel" bindtap="onCloseCircuitPicker">取消</text> <text class="picker-cancel" bindtap="onCloseCircuitPicker">取消</text>
</ui-modal> </ui-modal>
<!-- 新版本更新通知(What's New),首次进入当期版本弹一次 -->
<ui-modal visible="{{showUpdateNote}}" position="center" bind:close="onCloseUpdateNote">
<view class="update-note">
<view class="update-note__badge">
<image class="update-note__icon" src="{{icons.sparkleWhite}}" mode="aspectFit"></image>
</view>
<text class="update-note__title">版本更新 {{updateNoteVersion}}</text>
<text class="update-note__date" wx:if="{{updateNoteDate}}">更新于 {{updateNoteDate}}</text>
<view class="update-note__list">
<view class="update-note__item" wx:for="{{updateNoteItems}}" wx:key="*this">
<text class="update-note__dot">·</text>
<text class="update-note__text">{{item}}</text>
</view>
</view>
<ui-btn variant="primary" size="lg" block text="知道了" bindtap="onCloseUpdateNote" custom-style="border-radius:16rpx;margin-top:28rpx;"></ui-btn>
</view>
</ui-modal>
</view> </view>
+281 -255
View File
@@ -1,316 +1,267 @@
/* ---- greeting ---- */ /* ===== 全宽渐变头图 hero ===== */
.greeting-row { /* 负 margin 抵消 container 的 24rpx 内边距,让头图左右贯穿并贴到导航栏底边;
padding: 16rpx 8rpx 24rpx; 底部留大内边距给下方 quest-card 负 margin 上浮叠住。文字统一白色,图标用
animation: cardIn 0.4s ease both; *White 变体(若有)。hero 用 opacity-only 动画,避免 transform 把 .container
变成 containing block 而破坏弹窗定位(同 app.wxss pageIn 思路)。 */
/* index-page:仅本页面把 .container 升级为 flex 列,
时钟区 flex:1 居中,操作按钮 margin-top:auto 贴底(不动全局 container)。 */
.index-page {
display: flex;
flex-direction: column;
} }
.greeting-text { .hero {
margin: -24rpx -24rpx 0;
padding: 24rpx 24rpx 72rpx;
background: linear-gradient(160deg, var(--primary) 0%, var(--primary-light) 100%);
color: #fff;
position: relative;
overflow: hidden;
animation: heroFade 0.4s ease both;
}
.hero::after {
content: '';
position: absolute;
right: -60rpx;
top: -60rpx;
width: 240rpx;
height: 240rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.18), transparent 70%);
pointer-events: none;
}
@keyframes heroFade {
from { opacity: 0; }
to { opacity: 1; }
}
/* 顶部行:圆形头像 + 时间问候 */
.hero-top {
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 8rpx; gap: 18rpx;
} }
.greeting-icon { .hero-avatar {
width: 44rpx; width: 84rpx;
height: 44rpx; height: 84rpx;
flex-shrink: 0;
border-radius: 50%;
overflow: hidden;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
border: 3rpx solid rgba(255, 255, 255, 0.6);
}
.hero-avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
display: block;
}
/* 有头像时去掉白底,只留浅描边,避免深色头像边缘露白缝 */
.hero-avatar.has-avatar {
background: transparent;
border-color: rgba(255, 255, 255, 0.85);
}
.hero-user {
display: flex;
flex-direction: row;
align-items: center;
gap: 14rpx;
min-width: 0;
}
.hero-greet {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.92);
font-weight: 500;
}
/* 挑战主句:时长强调色 + 「敢不敢」白字 */
.hero-challenge {
margin-top: 28rpx;
display: flex;
align-items: baseline;
flex-wrap: wrap;
}
.hero-time {
font-size: 48rpx;
font-weight: 800;
color: #FFE3C2;
letter-spacing: 1rpx;
margin-right: 12rpx; margin-right: 12rpx;
font-variant-numeric: tabular-nums;
} }
.greeting-title { .hero-cta-text {
font-size: 40rpx; font-size: 44rpx;
font-weight: 700; font-weight: 800;
color: var(--text); color: #fff;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.15);
line-height: 1.2; line-height: 1.2;
} }
.greeting-sub { .hero-social {
font-size: 26rpx; margin-top: 0;
color: var(--text-secondary); font-size: 28rpx;
margin-left: 60rpx; color: rgba(255, 255, 255, 0.9);
line-height: 1;
} }
/* ---- header card ---- */ /* ===== 闯关进度地图(上浮卡片) ===== */
.header-card { .quest-card {
display: flex; margin-top: -56rpx;
flex-direction: column; margin-left: 0;
gap: 28rpx; margin-right: 0;
padding: 4rpx 0; position: relative;
z-index: 2;
} }
/* 上区:左连胜视觉重心,右计划徽章 */ .quest-head {
.streak-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 24rpx;
} }
.streak-section { .quest-title {
font-size: 32rpx;
font-weight: 700;
color: var(--text);
}
.quest-sub {
font-size: 24rpx;
font-weight: 400;
color: var(--text-secondary);
margin-left: 12rpx;
}
.quest-map {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 8rpx 4rpx;
} }
/* 火焰图标 + 光晕:光晕脉动让火焰"活"起来 */ .q-node {
.streak-icon-wrap { width: 48rpx;
position: relative; height: 48rpx;
width: 80rpx; border-radius: 50%;
height: 80rpx; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-right: 16rpx; font-size: 24rpx;
flex-shrink: 0; font-weight: 700;
} background: var(--bg-soft);
.streak-halo {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
background: radial-gradient(circle at center,
rgba(var(--primary-rgb), 0.45) 0%,
rgba(var(--primary-rgb), 0.18) 50%,
rgba(var(--primary-rgb), 0) 75%);
animation: streakHalo 2s ease-in-out infinite;
}
.streak-icon {
position: relative;
width: 64rpx;
height: 64rpx;
z-index: 1;
animation: float 2.5s ease-in-out infinite;
}
.streak-text {
display: flex;
flex-direction: column;
}
.streak-num {
font-size: 72rpx;
font-weight: 800;
line-height: 1;
/* On the gradient hero card --text cascades to white; on any plain card it
stays dark, so var(--text) reads correctly in both contexts. We must NOT
use var(--primary) here — that would be orange-on-orange and invisible. */
color: var(--text);
text-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.15);
font-variant-numeric: tabular-nums;
}
.streak-label {
font-size: 22rpx;
color: var(--text-secondary); color: var(--text-secondary);
margin-top: 4rpx;
line-height: 1;
} }
@keyframes streakHalo { .q-node.done {
0%, 100% { transform: scale(0.85); opacity: 0.7; } background: var(--primary);
50% { transform: scale(1.1); opacity: 1; } color: #fff;
} }
/* 计划徽章:移到上区右侧,半透明白底药丸,与火焰渐变卡片区分 */ .q-node.now {
.plan-badge { background: linear-gradient(135deg, var(--primary), var(--primary-light));
display: flex; color: #fff;
align-items: center; box-shadow: 0 0 0 6rpx rgba(var(--primary-rgb), 0.18);
background: rgba(255, 255, 255, 0.18); animation: qPulse 1.6s ease-in-out infinite;
padding: 8rpx 18rpx;
border-radius: 999rpx;
flex-shrink: 0;
} }
.plan-badge-icon { .q-node.final {
width: 28rpx; background: #FFF3E0;
height: 28rpx; font-size: 30rpx;
margin-right: 8rpx;
} }
.plan-badge-text { .q-link {
font-size: 26rpx;
font-weight: 600;
color: var(--text);
line-height: 1;
}
/* 下区:分段进度 */
.segment-progress {
display: flex;
flex-direction: column;
gap: 10rpx;
}
/* planTotal <= 10:按天分段格子 */
.segment-track {
display: flex;
gap: 8rpx;
}
.segment-cell {
flex: 1; flex: 1;
height: 12rpx; height: 4rpx;
border-radius: 6rpx; background: var(--bg-soft);
background: transparent; margin: 0 2rpx;
border: 2rpx solid rgba(255, 255, 255, 0.30);
box-sizing: border-box;
transition: background 0.3s ease, border-color 0.3s ease;
} }
.segment-cell.done { .q-link.done {
background: var(--on-primary, #FFFFFF); background: var(--primary);
border-color: var(--on-primary, #FFFFFF);
} }
.segment-cell.current { .quest-tip {
background: var(--on-primary, #FFFFFF); display: block;
border-color: var(--on-primary, #FFFFFF); margin-top: 20rpx;
animation: segmentPulse 1.2s ease-in-out infinite; font-size: 24rpx;
} font-weight: 600;
/* planTotal > 10:连续条 + 里程碑节点,避免格子过密 */
.milestone-track {
width: 100%;
}
.milestone-bar {
position: relative;
width: 100%;
height: 12rpx;
background: rgba(0, 0, 0, 0.12);
border-radius: 6rpx;
overflow: visible;
}
.milestone-fill {
height: 100%;
background: var(--on-primary, linear-gradient(90deg, var(--primary), var(--primary-light)));
border-radius: 6rpx;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.milestone-node {
position: absolute;
top: 50%;
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: var(--card-bg);
transform: translate(-50%, -50%);
border: 2rpx solid var(--border);
}
.progress-label {
font-size: 22rpx;
color: var(--text-secondary);
line-height: 1;
}
@keyframes segmentPulse {
0%, 100% { box-shadow: 0 0 4rpx rgba(var(--primary-rgb), 0.4); }
50% { box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.9); }
}
/* ---- target card ---- */
.target-section {
text-align: center;
}
.target-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8rpx;
}
.target-header .section-title {
margin-bottom: 0;
}
.section-icon {
width: 32rpx;
height: 32rpx;
margin-right: 8rpx;
}
.target-display {
margin: 20rpx 0;
}
.target-ring {
display: inline-flex;
align-items: center;
justify-content: center;
width: 280rpx;
height: 280rpx;
border-radius: 50%;
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.10), rgba(var(--primary-rgb), 0.03));
border: 6rpx solid rgba(var(--primary-rgb), 0.18);
box-shadow: 0 0 0 14rpx rgba(var(--primary-rgb), 0.05);
overflow: hidden;
}
.target-time {
font-size: 84rpx;
font-weight: 800;
color: var(--primary); color: var(--primary);
font-variant-numeric: tabular-nums;
letter-spacing: -1rpx;
line-height: 1;
max-width: 240rpx;
text-align: center;
white-space: nowrap;
overflow: hidden;
} }
.today-status { @keyframes qPulse {
margin-top: 16rpx; 0% { box-shadow: 0 0 0 4rpx rgba(var(--primary-rgb), 0.28); }
70% { box-shadow: 0 0 0 12rpx rgba(var(--primary-rgb), 0); }
100% { box-shadow: 0 0 0 4rpx rgba(var(--primary-rgb), 0); }
}
/* ===== 今日目标时长(无圆环, 大字流光) =====
在 index-page 这条 flex 列里 .timer-wrap 充当"中间时钟区":flex:1 占满
hero+quest 与底部按钮之间的剩余高度,内部再垂直居中把"00:30 / 今日目标
时长 / 预计消耗 千卡"这组文字真正落到屏幕中部(原来只是默认排在卡片下方) */
.timer-wrap {
flex: 1;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8rpx; text-align: center;
min-height: 0;
} }
.done-badge { .time-display {
display: inline-flex; display: block;
align-items: center; font-family: 'Saira Condensed', 'Arial Narrow', sans-serif;
background: rgba(var(--success-rgb), 0.08); font-size: 200rpx;
padding: 10rpx 24rpx; font-weight: 900;
border-radius: 24rpx; line-height: 1.1;
letter-spacing: 6rpx;
font-variant-numeric: tabular-nums;
color: var(--primary);
text-shadow: 0 8rpx 6rpx rgba(var(--primary-rgb), 0.35);
} }
.done-icon { .time-label {
width: 28rpx; display: block;
height: 28rpx;
margin-right: 6rpx;
}
.done-text {
font-size: 26rpx;
color: var(--success);
line-height: 1;
}
.pending-icon {
width: 28rpx;
height: 28rpx;
margin-right: 6rpx;
}
.pending-text {
font-size: 26rpx; font-size: 26rpx;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1; margin-top: 8rpx;
} }
/* ---- action buttons ---- */ .benefit {
margin-top: 18rpx;
font-size: 28rpx;
color: var(--text-secondary);
}
.benefit-hl {
color: var(--primary);
font-weight: 700;
font-size: 30rpx;
}
/* ===== action buttons ===== */
/* 在 index-page flex 列里,3 个按钮(rechallenge + free + circuit)靠
margin-top:auto 推到列底、紧贴底部 toolbar 上方,与上方居中的时钟
区之间自然留白;gap 保持按钮自身间距不变 */
.action-buttons { .action-buttons {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24rpx; gap: 24rpx;
margin: 8rpx 0 8rpx; margin: 8rpx 0 8rpx;
margin-top: auto;
} }
/* 自由训练 / 循环训练 并排一行 */ /* 自由训练 / 循环训练 并排一行 */
@@ -324,7 +275,13 @@
min-width: 0; /* 防止图标+文字在窄屏撑破 flex 项 */ min-width: 0; /* 防止图标+文字在窄屏撑破 flex 项 */
} }
/* ---- picker ---- */ /* 主按钮脉冲发光(加在 ui-btn 宿主上,__usePrivacyCheck__ 不影响) */
@keyframes ctaPulse {
0%, 100% { box-shadow: 0 8rpx 20rpx rgba(var(--primary-rgb), 0.35); }
50% { box-shadow: 0 8rpx 30rpx rgba(var(--primary-rgb), 0.6); }
}
/* ===== picker ===== */
.picker-title { .picker-title {
font-size: 34rpx; font-size: 34rpx;
font-weight: 600; font-weight: 600;
@@ -415,7 +372,7 @@
margin-top: 8rpx; margin-top: 8rpx;
} }
/* ---- circuit (循环训练) config panel ---- */ /* ===== circuit (循环训练) config panel ===== */
.picker-sub { .picker-sub {
display: block; display: block;
text-align: center; text-align: center;
@@ -473,7 +430,7 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* ---- 训练完成高亮 (3 秒后淡出) ---- */ /* ===== 训练完成高亮 (3 秒后淡出) ===== */
/* 这个 class 落在 <ui-card> 宿主节点上(ui-card.wxss 已把宿主设为 display:block /* 这个 class 落在 <ui-card> 宿主节点上(ui-card.wxss 已把宿主设为 display:block
否则 border/box-shadow 只会碎成两根竖条、transform 完全不生效)。 否则 border/box-shadow 只会碎成两根竖条、transform 完全不生效)。
描边用 box-shadow 的 spread 而非 borderborder 会撑大宿主 8rpx,高亮出现/消失 描边用 box-shadow 的 spread 而非 borderborder 会撑大宿主 8rpx,高亮出现/消失
@@ -489,3 +446,72 @@
0%, 100% { transform: scale(1); } 0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); } 50% { transform: scale(1.02); }
} }
/* ===== 新版本更新通知(What's New) ===== */
/* slot 内容由页面 wxss 控制;ui-modal--center 的 body 已居中且用 --card-bg,
这里只管内部排版。徽章用负 margin 上探出卡片顶,制造浮动感。 */
.update-note {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.update-note__badge {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
margin: -76rpx auto 18rpx;
background: linear-gradient(135deg, #4CAF50, #81C784);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(76, 175, 80, 0.35);
}
.update-note__icon {
width: 52rpx;
height: 52rpx;
}
.update-note__title {
font-size: 36rpx;
font-weight: 700;
color: var(--text);
line-height: 1.4;
}
.update-note__date {
font-size: 24rpx;
color: var(--text-secondary);
margin-top: 6rpx;
}
.update-note__list {
width: 100%;
margin: 26rpx 0 4rpx;
text-align: left;
box-sizing: border-box;
}
.update-note__item {
display: flex;
align-items: flex-start;
margin-bottom: 16rpx;
}
.update-note__dot {
color: var(--primary);
font-weight: 700;
margin-right: 12rpx;
line-height: 1.5;
flex-shrink: 0;
}
.update-note__text {
flex: 1;
font-size: 27rpx;
color: var(--text);
line-height: 1.5;
text-align: left;
}
+4 -1
View File
@@ -36,7 +36,10 @@ Page({
this.setData({ theme, icons: iconsMod.build(theme) }) this.setData({ theme, icons: iconsMod.build(theme) })
try { try {
const tb = this.getTabBar() const tb = this.getTabBar()
if (tb) tb.setData({ selected: 2 }) if (tb) {
tb.setData({ selected: 2 })
tb.updateTheme()
}
} catch (e) {} } catch (e) {}
// 训练完设的强刷标志:下次进排行榜跳过云函数缓存,立刻拿到含新记录的数据 // 训练完设的强刷标志:下次进排行榜跳过云函数缓存,立刻拿到含新记录的数据
let force = false let force = false
+4 -1
View File
@@ -50,7 +50,10 @@ Page({
themeMod.applyThemeToPage(this) themeMod.applyThemeToPage(this)
try { try {
const tb = this.getTabBar() const tb = this.getTabBar()
if (tb) tb.setData({ selected: 1 }) if (tb) {
tb.setData({ selected: 1 })
tb.updateTheme()
}
} catch (e) {} } catch (e) {}
if (this._firstShow) { if (this._firstShow) {
this._firstShow = false this._firstShow = false
+4 -1
View File
@@ -114,7 +114,10 @@ Page({
onShow() { onShow() {
try { try {
const tb = this.getTabBar() const tb = this.getTabBar()
if (tb) tb.setData({ selected: 3 }) if (tb) {
tb.setData({ selected: 3 })
tb.updateTheme()
}
} catch (e) {} } catch (e) {}
themeMod.applyThemeToPage(this) themeMod.applyThemeToPage(this)
const theme = themeMod.getCurrentTheme() const theme = themeMod.getCurrentTheme()
+309 -41
View File
@@ -37,14 +37,13 @@ Page({
workTotal: 0, // effective held seconds (excludes rest) workTotal: 0, // effective held seconds (excludes rest)
isResting: false, isResting: false,
phaseTip: '', // 阶段切换提示文本(如"最后一组!"/"休息 15 秒") phaseTip: '', // 阶段切换提示文本(如"最后一组!"/"休息 15 秒")
// 顶部循环进度面板:组进度胶囊点阵 // 组进度改由 progress-ring 外圈分段弧呈现,仅需传 sets/currentSet/resting,
circuitDots: [], // [1..sets],仅用于 wx:for 渲染,组数过多时不渲染 // 页面不再维护点阵渲染数据(circuitDots/dotsDone 等已随旧面板一并删除)。
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
// 训练时长低于 config.minRecordSeconds 时置 true:完成弹窗提示"未计入记录",
// 隐藏查看记录/分享入口;弹窗内展示门槛值给用户明确反馈
completionNotRecorded: false,
minRecordSeconds: 20,
celebrationMessage: '', celebrationMessage: '',
confettiPieces: [], confettiPieces: [],
// Completion modal (full-screen summary after training ends) // Completion modal (full-screen summary after training ends)
@@ -59,6 +58,14 @@ Page({
// 完成弹窗科学提示(config.scienceTips 按本次时长选段) // 完成弹窗科学提示(config.scienceTips 按本次时长选段)
completionTipValue: '', completionTipValue: '',
completionTipRisk: '', completionTipRisk: '',
// --- 计时页改版派生字段(状态文案 / 目标信息卡 / 环刻度 / 环内副标题) ---
ringTicks: 0,
ringSubText: '',
statusText: '准备开始',
goalLine: '',
runningTip: '',
tipType: 'posture',
tipIcon: '',
icons: iconsMod.build() icons: iconsMod.build()
}, },
@@ -120,6 +127,7 @@ Page({
target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays), customPlans) target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays), customPlans)
} }
const durText = this._durationText(target)
this.setData({ this.setData({
duration: target, duration: target,
remaining: target, remaining: target,
@@ -127,7 +135,14 @@ Page({
planDay: isFreeMode ? 0 : planDay, planDay: isFreeMode ? 0 : planDay,
isFreeMode, isFreeMode,
goalReached: false, goalReached: false,
elapsed: 0 elapsed: 0,
goalLine: isFreeMode ? `自由训练 · 目标 ${durText}` : `今日目标 ${durText} · 第 ${planDay}`,
ringTicks: Math.min(target, 60),
ringSubText: `目标 ${durText}`,
statusText: '准备开始',
runningTip: '',
tipType: 'posture',
tipIcon: this.data.icons.peopleFill
}) })
if (this._timer) { if (this._timer) {
@@ -149,7 +164,10 @@ Page({
onTick: (tick) => { onTick: (tick) => {
this.setData({ this.setData({
remaining: tick.remaining > 0 ? tick.remaining : 0, remaining: tick.remaining > 0 ? tick.remaining : 0,
elapsed: tick.elapsed elapsed: tick.elapsed,
runningTip: this.data.goalReached ? '目标达成,继续挑战!' : this.data.hintTip,
tipType: this.data.goalReached ? 'done' : 'posture',
tipIcon: this.data.goalReached ? this.data.icons.successFill : this.data.icons.peopleFill
}) })
this._updateHintTip(tick) this._updateHintTip(tick)
this._remind(tick) this._remind(tick)
@@ -161,7 +179,7 @@ Page({
if (s.voiceGuide !== false) { if (s.voiceGuide !== false) {
voice.play('goal') voice.play('goal')
} }
this.setData({ goalReached: true }) this.setData({ goalReached: true, runningTip: '目标达成,继续挑战!', tipType: 'done', tipIcon: this.data.icons.successFill })
} }
}) })
}, },
@@ -203,8 +221,8 @@ Page({
sets, sets,
restPerSet: rest, restPerSet: rest,
onTick: (t) => { onTick: (t) => {
// 组进度点阵三态。休息阶段 CircuitTimer 的 setIndex 仍指向"刚做完 // 组进度语义与旧点阵一致:休息时 setIndex 仍指向"刚做完的那一组",
// 的那一组",所以 resting 时 setIndex 组算已完成、下一组标为 next // 组件按 resting 自行推导已完成/进行中/下一组三态(外圈分段弧)
const resting = t.phase === 'resting' const resting = t.phase === 'resting'
this.setData({ this.setData({
currentSet: t.setIndex, currentSet: t.setIndex,
@@ -214,14 +232,17 @@ Page({
phaseElapsed: t.phaseElapsed, phaseElapsed: t.phaseElapsed,
workTotal: t.workTotal, workTotal: t.workTotal,
isResting: resting, 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 // Reuse the single-hold display fields so progress-ring / completion
// number keep working: the ring shows the CURRENT set's countdown. // number keep working: the ring shows the CURRENT set's countdown.
remaining: t.phaseRemaining, remaining: t.phaseRemaining,
elapsed: t.workTotal, elapsed: t.workTotal,
duration: t.phase === 'working' ? hold : rest duration: t.phase === 'working' ? hold : rest,
statusText: resting ? '休息中' : '撑住',
runningTip: resting ? '' : this.data.phaseTip,
tipType: 'phase',
tipIcon: this.data.icons.peopleFill,
// 环内副标题:宏观进度(第几组)交给外圈分段弧,这里只报当前组位次。
ringSubText: `${t.setIndex}/${t.sets}`
}) })
this._circuitCue(t) this._circuitCue(t)
}, },
@@ -229,13 +250,10 @@ Page({
onComplete: () => this._onCircuitComplete() onComplete: () => this._onCircuitComplete()
}) })
// 点阵超过 DOTS_MAX 个就挤成头发丝了,退化成一条线性进度条。 // 组数上限 50(见 _int 的 clamp),环形外圈 360° 空间充裕,即使 50 段也
const DOTS_MAX = 15 // 不至于挤成线(旧点阵的 DOTS_MAX=15 是受横向卡片宽度限制,环形无此问题),
const dots = [] // 因此分段弧不做数量降级,页面也无须维护点阵渲染数据。
if (sets <= DOTS_MAX) { const durText = this._durationText(hold)
for (let i = 1; i <= sets; i++) dots.push(i)
}
this.setData({ this.setData({
isCircuitMode: true, isCircuitMode: true,
isFreeMode: false, isFreeMode: false,
@@ -244,12 +262,6 @@ Page({
circuitHold: hold, circuitHold: hold,
circuitRest: rest, circuitRest: rest,
circuitSessions: this._circuitCfg.sessionsPerWeek, circuitSessions: this._circuitCfg.sessionsPerWeek,
circuitDots: dots,
circuitShowDots: sets <= DOTS_MAX,
circuitTotalWork: sets * hold,
dotsDone: 0,
dotsActive: 0,
dotsNext: 0,
currentSet: 0, currentSet: 0,
totalSets: sets, totalSets: sets,
phase: 'idle', phase: 'idle',
@@ -267,7 +279,16 @@ Page({
isRunning: false, isRunning: false,
isPaused: false, isPaused: false,
isCompleted: false, isCompleted: false,
showCompletion: false showCompletion: false,
goalLine: `每组 ${durText} · 共 ${sets}`,
ringTicks: Math.min(hold, 60),
// idle 时环内副标题承载配置摘要(组数 + 休息时长),运行时由 onTick
// 换成"第 X/Y 组"。休息时长为 0 时不展示"休息"段。
ringSubText: `${sets}${rest > 0 ? ' · 休息 ' + rest + ' 秒' : ''}`,
statusText: '准备开始',
runningTip: '',
tipType: 'posture',
tipIcon: this.data.icons.peopleFill
}) })
}, },
@@ -277,6 +298,18 @@ Page({
return Math.max(min, Math.min(max, n)) return Math.max(min, Math.min(max, n))
}, },
/**
* 把秒数转成口语化时长文本:45 -> "45秒", 90 -> "1分30", 120 -> "2分钟"。
* 用于目标信息卡与环内副标题,比 MM:SS 更贴近自然语言。
*/
_durationText(sec) {
const s = Math.floor(sec || 0)
if (s < 60) return s + '秒'
const m = Math.floor(s / 60)
const rem = s % 60
return rem === 0 ? m + '分钟' : m + '分' + rem
},
/** /**
* Fire a haptic buzz, tracked so pause/stop/unload can cancel pending ones. * Fire a haptic buzz, tracked so pause/stop/unload can cancel pending ones.
* Mirrors the vibrate helper inside _remind(). * Mirrors the vibrate helper inside _remind().
@@ -321,21 +354,31 @@ Page({
const s = storage.getSettings() const s = storage.getSettings()
const voiceOn = s.voiceGuide !== false const voiceOn = s.voiceGuide !== false
// Even set counts land a progress threshold (halfway / last30 / last10)
// exactly on a work→rest transition. If that prompt speaks it grabs the
// mic for ~2.6s (see _playProgressVoice) and mutes the "休息一下" line
// fired on the same tick. Yield to rest whenever restStart will actually
// play (chatty + rest≥5); short-set configs keep the progress cue as
// before. Odd set counts never hit this because the halfway point sits
// mid-set, so their progress prompts are unaffected.
const atRestBoundary = t.phaseRemaining <= 0 && t.restPerSet > 0 && t.setIndex < t.sets
const restWillSpeak = this._circuitCfg.holdPerSet >= 10 && this._circuitCfg.restPerSet >= 5
// Same duration gates as _remind(), so short circuits (e.g. 3 sets × 5s) // Same duration gates as _remind(), so short circuits (e.g. 3 sets × 5s)
// don't fire "最后30秒" on the very first tick. `<=` rather than `===` // don't fire "最后30秒" on the very first tick. `<=` rather than `===`
// survives a skipped second when the app returns from background. // survives a skipped second when the app returns from background.
if (total >= 10 && !this._halfwaySignaled && done >= Math.floor(total / 2)) { if (total >= 10 && !this._halfwaySignaled && done >= Math.floor(total / 2)) {
this._halfwaySignaled = true this._halfwaySignaled = true
if (voiceOn) this._playProgressVoice('halfway') if (!(atRestBoundary && restWillSpeak) && voiceOn) this._playProgressVoice('halfway')
this._vibrateOnce(2, 150) this._vibrateOnce(2, 150)
} }
if (total > 60 && left <= 30 && !this._last30Signaled) { if (total > 60 && left <= 30 && !this._last30Signaled) {
this._last30Signaled = true this._last30Signaled = true
if (voiceOn) this._playProgressVoice('last30') if (!(atRestBoundary && restWillSpeak) && voiceOn) this._playProgressVoice('last30')
} }
if (total > 20 && left <= 10 && !this._last10Signaled) { if (total > 20 && left <= 10 && !this._last10Signaled) {
this._last10Signaled = true this._last10Signaled = true
if (voiceOn) this._playProgressVoice('last10') if (!(atRestBoundary && restWillSpeak) && voiceOn) this._playProgressVoice('last10')
} }
// Countdown buzz over the last 5s of the FINAL set (the real finish line). // Countdown buzz over the last 5s of the FINAL set (the real finish line).
@@ -406,7 +449,9 @@ Page({
_onCircuitComplete() { _onCircuitComplete() {
this._clearVibrateTimers() this._clearVibrateTimers()
const s = storage.getSettings() const s = storage.getSettings()
if (s.voiceGuide !== false) voice.play('complete') // 有效撑持秒数未达最低记录时长时不播完成语音(弹窗走"未计入记录"分支)
const minSec = config.minRecordSeconds || 20
if (this.data.workTotal >= minSec && s.voiceGuide !== false) voice.play('complete')
this._saveAndShowCompletion(this.data.workTotal) this._saveAndShowCompletion(this.data.workTotal)
}, },
@@ -624,7 +669,7 @@ Page({
// Re-assert: if the user backgrounded the app while paused, WeChat has // Re-assert: if the user backgrounded the app while paused, WeChat has
// already cleared the flag for us. // already cleared the flag for us.
this._keepScreenOn(true) this._keepScreenOn(true)
this.setData({ isRunning: true, isPaused: false, status: 'running' }) this.setData({ isRunning: true, isPaused: false, status: 'running', statusText: '撑住', runningTip: '' })
return return
} }
if (this.data.isRunning) return if (this.data.isRunning) return
@@ -640,7 +685,7 @@ Page({
this.setData({ remaining: this.data.duration }) this.setData({ remaining: this.data.duration })
} }
this._timer.start(this.data.duration) this._timer.start(this.data.duration)
this.setData({ isRunning: true, status: 'running' }) this.setData({ isRunning: true, status: 'running', statusText: '撑住', runningTip: '' })
const s = storage.getSettings() const s = storage.getSettings()
if (s.voiceGuide !== false) voice.play('start') if (s.voiceGuide !== false) voice.play('start')
}, },
@@ -650,7 +695,7 @@ Page({
this._timer.pause() this._timer.pause()
voice.stop() voice.stop()
this._clearVibrateTimers() this._clearVibrateTimers()
this.setData({ isPaused: true, status: 'paused' }) this.setData({ isPaused: true, status: 'paused', statusText: '已暂停', runningTip: '' })
}, },
onStop() { onStop() {
@@ -673,7 +718,7 @@ Page({
// 弹窗前若正在计时(被 onStop 暂停),取消后恢复 // 弹窗前若正在计时(被 onStop 暂停),取消后恢复
if (!this._stopWasPaused && this.data.isRunning) { if (!this._stopWasPaused && this.data.isRunning) {
this._timer.resume() this._timer.resume()
this.setData({ isRunning: true, isPaused: false, status: 'running' }) this.setData({ isRunning: true, isPaused: false, status: 'running', statusText: '撑住', runningTip: '' })
} }
}, },
@@ -696,8 +741,10 @@ Page({
return return
} }
// Completion voice (replaces the old auto-onComplete cue). No vibration. // Completion voice (replaces the old auto-onComplete cue). No vibration.
// 未达最低记录时长时不播完成语音——弹窗会走"未计入记录"分支,不庆祝。
const s = storage.getSettings() const s = storage.getSettings()
if (s.voiceGuide !== false) { const minSec = config.minRecordSeconds || 20
if (elapsed >= minSec && s.voiceGuide !== false) {
voice.play('complete') voice.play('complete')
} }
this._saveAndShowCompletion(elapsed) this._saveAndShowCompletion(elapsed)
@@ -730,6 +777,15 @@ Page({
// Re-entry guard: protects the finishTraining path so the save never // Re-entry guard: protects the finishTraining path so the save never
// runs twice even if the user double-taps end. // runs twice even if the user double-taps end.
if (this._saving) return if (this._saving) return
// 未达 config.minRecordSeconds:不写入记录/连胜,弹"未计入记录"提示。
// 兜底判断——手动结束(finishTraining)与循环自然完成(_onCircuitComplete)
// 两条路径最终都汇聚到这里,保证任何入口都不会把短时训练落库。
const minSec = config.minRecordSeconds || 20
if (elapsed < minSec) {
this._showNotRecorded(elapsed, minSec)
return
}
this._saving = true this._saving = true
const { level, message } = this._detectCelebrationLevel(elapsed) const { level, message } = this._detectCelebrationLevel(elapsed)
@@ -768,6 +824,9 @@ Page({
// Background effects keep playing behind the modal // Background effects keep playing behind the modal
status: 'completed', status: 'completed',
isCompleted: true, isCompleted: true,
// 重置未记录标记(上一轮若走了 _showNotRecorded,这里必须回 false,
// 否则下次正常完成的弹窗会误显示"未计入记录"且隐藏分享/记录按钮)
completionNotRecorded: false,
celebrationLevel: level, celebrationLevel: level,
celebrationMessage: message, celebrationMessage: message,
confettiPieces, confettiPieces,
@@ -787,6 +846,9 @@ Page({
completionTipRisk: scienceTip.risk completionTipRisk: scienceTip.risk
}) })
// 预生成分享海报,点"分享"时 onShareAppMessage 直接复用,避免现场绘制延迟
this._drawSharePoster()
// Animate the big number from 0 to elapsed // Animate the big number from 0 to elapsed
if (this._completionCountUp) this._completionCountUp.cancel() if (this._completionCountUp) this._completionCountUp.cancel()
this._completionCountUp = countUp({ this._completionCountUp = countUp({
@@ -798,6 +860,46 @@ Page({
}) })
}, },
/**
* 训练时长低于 config.minRecordSeconds:不保存记录、不更新连胜,
* 复用完成弹窗(避免系统原生 modal 在 WebView stacking context 下
* hit-test 不稳),弹窗内明确提示"本次未计入记录"。
* 仍显示本次实际撑持秒数(大数字 countUp),但不放 confetti/连胜/科学提示,
* 并隐藏"查看记录/分享"入口(没有记录可看)。
*/
_showNotRecorded(elapsed, minSec) {
if (this._saving) return
this._saving = true
this.setData({
status: 'completed',
isCompleted: true,
completionNotRecorded: true,
minRecordSeconds: minSec,
showCompletion: true,
completionElapsed: 0,
completionActualElapsed: elapsed,
completionTarget: this.data.duration,
completionOvertime: 0,
completionStreak: 0,
completionLevel: 'normal',
completionMessage: '未达最低记录时长',
completionTipValue: '',
completionTipRisk: '',
confettiPieces: []
})
// Animate the big number from 0 to elapsed (same as the normal modal)
if (this._completionCountUp) this._completionCountUp.cancel()
this._completionCountUp = countUp({
from: 0,
to: elapsed,
duration: 1200,
onUpdate: (v) => this.setData({ completionElapsed: v }),
onComplete: () => { this._completionCountUp = null }
})
},
onCloseCompletion() { onCloseCompletion() {
if (this._completionCountUp) { if (this._completionCountUp) {
this._completionCountUp.cancel() this._completionCountUp.cancel()
@@ -829,6 +931,8 @@ Page({
this._completionCountUp.cancel() this._completionCountUp.cancel()
this._completionCountUp = null this._completionCountUp = null
} }
// 清空上一轮分享图,避免"再来一次"后立即分享时复用旧图
this._shareImageUrl = ''
// Circuit mode: rebuild the FSM (start() only resets from idle, and a // Circuit mode: rebuild the FSM (start() only resets from idle, and a
// finished/paused timer is no longer idle, so a fresh init is the clean // finished/paused timer is no longer idle, so a fresh init is the clean
// reset; _initCircuit also closes the completion modal). // reset; _initCircuit also closes the completion modal).
@@ -853,9 +957,15 @@ Page({
status: 'idle', status: 'idle',
isRunning: false, isRunning: false,
isPaused: false, isPaused: false,
// 复位未记录标记:若上一轮走了 _showNotRecorded,重练前必须回 false
completionNotRecorded: false,
remaining: this.data.duration, remaining: this.data.duration,
elapsed: 0, elapsed: 0,
goalReached: false goalReached: false,
statusText: '准备开始',
runningTip: '',
tipType: 'posture',
tipIcon: this.data.icons.peopleFill
}) })
}, },
@@ -864,6 +974,163 @@ Page({
// taps on the completion modal). // taps on the completion modal).
onNoop() { /* swallow */ }, onNoop() { /* swallow */ },
/**
* 绘制训练完成分享海报并导出为临时图片。
* 在 _saveAndShowCompletion 里弹窗数据就绪后调用,提前生成,
* 用户点"分享"时 onShareAppMessage 直接取 _shareImageUrl。
*/
_drawSharePoster() {
const query = wx.createSelectorQuery().in(this)
query.select('#sharePoster')
.fields({ node: true, size: true })
.exec((res) => {
if (!res || !res[0] || !res[0].node) return
const canvas = res[0].node
const ctx = canvas.getContext('2d')
const dpr = wx.getSystemInfoSync().pixelRatio || 1
// 5:4 比例,控制导出文件<128KB;画布逻辑像素 500x400,实际按 dpr 放大
const W = 500
const H = 400
canvas.width = W * dpr
canvas.height = H * dpr
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, W, H)
const theme = this.data.theme || themeMod.getCurrentTheme()
const primary = theme.primary || '#FF6B35'
// 与完成弹窗视觉一致的成绩数据
const elapsed = this.data.completionActualElapsed || this.data.completionElapsed || 0
const streak = this.data.completionStreak || 0
const message = this.data.completionMessage || '完成!'
// helpers
const roundRect = (x, y, w, h, r) => {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
ctx.lineTo(x + w, y + h - rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
ctx.lineTo(x + rr, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
const drawStar = (cx, cy, outerR, innerR, points) => {
ctx.beginPath()
for (let i = 0; i < points * 2; i++) {
const r = i % 2 === 0 ? outerR : innerR
const a = (Math.PI / points) * i - Math.PI / 2
const x = cx + Math.cos(a) * r
const y = cy + Math.sin(a) * r
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.closePath()
}
// --- background ---
ctx.fillStyle = '#F7F8FA'
ctx.fillRect(0, 0, W, H)
// --- main card shadow ---
ctx.save()
ctx.fillStyle = 'rgba(0,0,0,0.06)'
roundRect(38, 44, 424, 320, 22)
ctx.fill()
ctx.restore()
// --- main card ---
ctx.save()
ctx.fillStyle = '#FFFFFF'
roundRect(35, 42, 430, 316, 20)
ctx.fill()
ctx.restore()
// --- gradient circle icon (same feel as completion-emoji-wrap) ---
const circleX = W / 2
const circleY = 104
const circleR = 40
const grad = ctx.createLinearGradient(circleX - circleR, circleY - circleR, circleX + circleR, circleY + circleR)
grad.addColorStop(0, '#4CAF50')
grad.addColorStop(1, '#81C784')
ctx.fillStyle = grad
ctx.beginPath()
ctx.arc(circleX, circleY, circleR, 0, Math.PI * 2)
ctx.fill()
// --- white star ---
ctx.fillStyle = '#FFFFFF'
drawStar(circleX, circleY, 20, 8, 5)
ctx.fill()
// --- status text (kept well above the big number so a multi-digit
// minute count never overlaps it) ---
ctx.fillStyle = '#333333'
ctx.font = 'normal 600 24px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
ctx.fillText(message, W / 2, 184)
// --- big number + unit ---
const fmt = this._formatShareDuration(elapsed)
const numLen = fmt.num.length
const numSize = numLen <= 2 ? 84 : numLen <= 3 ? 70 : numLen <= 4 ? 56 : 44
ctx.font = `normal 800 ${numSize}px sans-serif`
ctx.textAlign = 'right'
ctx.textBaseline = 'alphabetic'
ctx.fillStyle = primary
const numX = W / 2 + 4
const numY = 286
ctx.fillText(fmt.num, numX, numY)
ctx.fillStyle = '#666666'
ctx.font = 'normal 600 22px sans-serif'
ctx.textAlign = 'left'
ctx.fillText(fmt.unit, numX + 8, numY - 6)
// --- streak ---
if (streak > 0) {
ctx.fillStyle = primary
ctx.font = 'normal 500 18px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`连续打卡 ${streak}`, W / 2, 332)
}
// --- export (no QR; draw immediately) ---
wx.canvasToTempFilePath({
canvas,
width: W,
height: H,
destWidth: W,
destHeight: H,
fileType: 'jpg',
quality: 0.9,
success: (r) => {
this._shareImageUrl = r.tempFilePath
},
fail: (err) => {
console.warn('[sharePoster] export failed', err)
}
})
})
},
/**
* 分享图专用时长格式化(与弹窗 WXS fmt 口径一致)。
*/
_formatShareDuration(s) {
s = Math.floor(s || 0)
if (s < 60) return { num: String(s), unit: '秒' }
const m = Math.floor(s / 60)
const sec = s % 60
return sec === 0 ? { num: String(m), unit: '分钟' } : { num: `${m}${sec}`, unit: '秒' }
},
/** /**
* 分享给朋友。训练完成弹窗里的分享按钮会走到这里,标题里拼上用户 * 分享给朋友。训练完成弹窗里的分享按钮会走到这里,标题里拼上用户
* 刚才撑了多久,转化率更高;右上角"···"菜单也共用同一份文案。 * 刚才撑了多久,转化率更高;右上角"···"菜单也共用同一份文案。
@@ -873,7 +1140,8 @@ Page({
const s = config.share.timer const s = config.share.timer
return { return {
title: s.titleTemplate.replace('{elapsed}', elapsed), title: s.titleTemplate.replace('{elapsed}', elapsed),
path: s.path path: s.path,
imageUrl: this._shareImageUrl || ''
} }
}, },
+41 -63
View File
@@ -15,44 +15,9 @@
<text class="achievement-text">{{celebrationMessage}}</text> <text class="achievement-text">{{celebrationMessage}}</text>
</view> </view>
<!-- 循环训练进度面板:组点阵 + 第 X/Y 组 + 阶段徽章 + 累计秒数。 <!-- 呼吸引导环 + 进度条。循环训练的组进度不再占一块独立面板,
层次分工:这里管「第几组」(宏观),中间的进度环管「本组还剩几秒」(微观)。 --> 而是由 progress-ring 在圆环外圈画一圈分段弧(见组件的 sets/currentSet/resting 属性),
<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' : ''}}"
wx:if="{{status !== 'completed'}}"></view> wx:if="{{status !== 'completed'}}"></view>
@@ -60,18 +25,26 @@
wx:if="{{status !== 'completed'}}"></view> wx:if="{{status !== 'completed'}}"></view>
<view class="breathe-ring breathe-ring-3 {{status === 'running' ? 'fast' : ''}}" <view class="breathe-ring breathe-ring-3 {{status === 'running' ? 'fast' : ''}}"
wx:if="{{status !== 'completed'}}"></view> wx:if="{{status !== 'completed'}}"></view>
<!-- 呼吸光环:进度环外侧一圈可见的描边辉光,随呼吸收放(idle 慢/running 快) -->
<view class="breath-aura {{status === 'running' ? 'fast' : ''}}"
wx:if="{{status !== 'completed'}}"></view>
<progress-ring <progress-ring
wx:if="{{status !== 'completed'}}" wx:if="{{status !== 'completed'}}"
duration="{{duration}}" duration="{{duration}}"
remaining="{{isCompleted ? 0 : remaining}}" remaining="{{isCompleted ? 0 : remaining}}"
elapsed="{{elapsed}}" elapsed="{{elapsed}}"
size="{{360}}" size="{{420}}"
ringWidth="{{22}}" ringWidth="{{22}}"
status="{{status}}" status="{{status}}"
primaryColor="{{theme.primary}}" primaryColor="{{theme.primary}}"
primaryLightColor="{{theme.primaryLight}}" primaryLightColor="{{theme.primaryLight}}"
trackColor="{{isCircuitMode ? '#EEEEEE' : (elapsed >= duration ? '#00B578' : '#EEEEEE')}}" trackColor="{{isCircuitMode ? '#EEEEEE' : (elapsed >= duration ? '#00B578' : '#EEEEEE')}}"
ticks="{{ringTicks}}"
subText="{{ringSubText}}"
sets="{{totalSets}}"
currentSet="{{currentSet}}"
resting="{{isResting}}"
></progress-ring> ></progress-ring>
<!-- 完成庆祝粒子 --> <!-- 完成庆祝粒子 -->
@@ -85,31 +58,22 @@
</view> </view>
</view> </view>
<!-- 状态提示 --> <!-- 信息气泡:状态 + 目标摘要 + 运行提示 三行合一(三模式公用)。
<view class="hint-section"> 环下方原 statusText(撑住/休息中/已暂停)并入气泡顶部,圆环下只留一张卡。 -->
<view class="hint-row" wx:if="{{status === 'idle'}}"> <view class="info-bubble" wx:if="{{status !== 'completed'}}">
<image class="hint-icon" src="{{icons.targetFill}}" mode="aspectFit"></image> <view class="info-line info-line--status info-line--{{status}} {{isResting ? 'info-line--rest' : ''}}">
<text class="hint-text"> <view class="info-status-dot"></view>
<block wx:if="{{isCircuitMode}}">循环训练 · 共需撑 {{circuitTotalWork}} 秒</block> <text class="info-status-text">{{statusText}}</text>
<block wx:elif="{{isFreeMode}}">自由训练 · 目标 {{duration}} 秒</block>
<block wx:else>今日目标 {{duration}} 秒 · 第 {{planDay}} 天</block>
</text>
</view> </view>
<view class="hint-row" wx:elif="{{status === 'running' && goalReached}}"> <view class="info-divider"></view>
<image class="hint-icon running-pulse" src="{{icons.hotFill}}" mode="aspectFit"></image> <view class="info-line info-line--goal">
<text class="hint-text running">目标达成,继续挑战!</text> <image class="info-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
<text class="info-text">{{goalLine}}</text>
</view> </view>
<view class="hint-row" wx:elif="{{status === 'running'}}"> <view class="info-divider" wx:if="{{runningTip}}"></view>
<image class="hint-icon running-pulse" src="{{icons.likeFill}}" mode="aspectFit"></image> <view class="info-line info-line--tip info-line--{{tipType}}" wx:if="{{runningTip}}">
<text class="hint-text running">{{isCircuitMode ? phaseTip : hintTip}}</text> <image class="info-icon" src="{{tipIcon}}" mode="aspectFit"></image>
</view> <text class="info-text">{{runningTip}}</text>
<view class="hint-row" wx:elif="{{status === 'paused'}}">
<image class="hint-icon" src="{{icons.notificationForbidFill}}" mode="aspectFit"></image>
<text class="hint-text paused">已暂停</text>
</view>
<view class="hint-row completed-hint" wx:elif="{{status === 'completed'}}">
<image class="hint-icon completed-bounce" src="{{icons.roundCheckFill}}" mode="aspectFit"></image>
<text class="hint-text completed">目标完成! 继续坚持!</text>
</view> </view>
</view> </view>
@@ -208,6 +172,15 @@
<text class="streak-text">连续打卡 {{completionStreak}} 天</text> <text class="streak-text">连续打卡 {{completionStreak}} 天</text>
</view> </view>
<!-- 未达最低记录时长:明确提示本次未计入记录,并说明门槛 -->
<view class="completion-not-recorded" wx:if="{{completionNotRecorded}}">
<view class="completion-not-recorded-main">
<image class="completion-not-recorded-icon" src="{{icons.infoFill}}" mode="aspectFit"></image>
<text class="completion-not-recorded-title">本次训练未计入记录</text>
</view>
<text class="completion-not-recorded-sub">撑满 {{minRecordSeconds}} 秒以上才会记录并计入连续打卡</text>
</view>
<!-- 科学提示:价值行 + 弱化风险行(config.scienceTips 按时长选段) --> <!-- 科学提示:价值行 + 弱化风险行(config.scienceTips 按时长选段) -->
<view class="completion-tip" wx:if="{{completionTipValue}}"> <view class="completion-tip" wx:if="{{completionTipValue}}">
<text class="completion-tip-value">{{completionTipValue}}</text> <text class="completion-tip-value">{{completionTipValue}}</text>
@@ -221,13 +194,15 @@
<view class="completion-btn completion-btn--primary" bindtap="onTrainAgain"> <view class="completion-btn completion-btn--primary" bindtap="onTrainAgain">
<text>再来一次</text> <text>再来一次</text>
</view> </view>
<view class="completion-btn" bindtap="onViewRecords"> <!-- 未记录时没有成绩可看/可晒,隐藏查看记录与分享入口 -->
<view class="completion-btn" wx:if="{{!completionNotRecorded}}" bindtap="onViewRecords">
<text>查看记录</text> <text>查看记录</text>
</view> </view>
<!-- openType="share" 会触发 Page.onShareAppMessage(已实现), <!-- openType="share" 会触发 Page.onShareAppMessage(已实现),
没有这个属性 + onShareAppMessage 时分享按钮就是灰色的。 没有这个属性 + onShareAppMessage 时分享按钮就是灰色的。
ui-btn 透传 openType 到内部原生 button。 --> ui-btn 透传 openType 到内部原生 button。 -->
<ui-btn <ui-btn
wx:if="{{!completionNotRecorded}}"
variant="ghost" variant="ghost"
size="md" size="md"
customStyle="flex:1; height:88rpx;" customStyle="flex:1; height:88rpx;"
@@ -240,4 +215,7 @@
</view> </view>
</view> </view>
</view> </view>
<!-- 隐藏 canvas:用于绘制训练完成分享海报,onShareAppMessage 取它的导出图 -->
<canvas type="2d" id="sharePoster" class="share-poster-canvas"></canvas>
</view> </view>
+184 -251
View File
@@ -2,234 +2,21 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
padding-top: 80rpx; padding-top: 80rpx;
padding-bottom: calc(170rpx + env(safe-area-inset-bottom)); padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
min-height: 100vh; min-height: 100vh;
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;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
/* 圆环与下方(状态词/目标卡/提示)整组拉开间距,按钮靠 margin-top:auto 贴底不受影响 */
margin-bottom: 44rpx;
} }
/* breathing guide rings — soft radial glows instead of hard outline circles, /* breathing guide rings — soft radial glows instead of hard outline circles,
@@ -237,21 +24,53 @@
.breathe-ring { .breathe-ring {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle, rgba(var(--primary-rgb), 0.10) 0%, rgba(var(--primary-rgb), 0) 65%); background: radial-gradient(circle, rgba(var(--primary-rgb), 0.13) 0%, rgba(var(--primary-rgb), 0) 68%);
animation: breathePulse 3.5s ease-in-out infinite; animation: breathePulse 3.5s ease-in-out infinite;
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
.breathe-ring-1 { width: 520rpx; height: 520rpx; animation-delay: 0s; } .breathe-ring-1 { width: 620rpx; height: 620rpx; animation-delay: 0s; }
.breathe-ring-2 { width: 440rpx; height: 440rpx; animation-delay: 0.5s; background: radial-gradient(circle, rgba(var(--primary-rgb), 0.14) 0%, rgba(var(--primary-rgb), 0) 65%); } .breathe-ring-2 { width: 540rpx; height: 540rpx; animation-delay: 0.5s; background: radial-gradient(circle, rgba(var(--primary-rgb), 0.17) 0%, rgba(var(--primary-rgb), 0) 66%); }
.breathe-ring-3 { width: 380rpx; height: 380rpx; animation-delay: 1s; background: radial-gradient(circle, rgba(var(--primary-rgb), 0.18) 0%, rgba(var(--primary-rgb), 0) 65%); } .breathe-ring-3 { width: 460rpx; height: 460rpx; animation-delay: 1s; background: radial-gradient(circle, rgba(var(--primary-rgb), 0.22) 0%, rgba(var(--primary-rgb), 0) 63%); }
.breathe-ring.fast { animation: breathePulseFast 1.4s ease-in-out infinite; } .breathe-ring.fast { animation: breathePulseFast 1.4s ease-in-out infinite; }
.breathe-ring.fast.breathe-ring-1 { animation-delay: 0s; } .breathe-ring.fast.breathe-ring-1 { animation-delay: 0s; }
.breathe-ring.fast.breathe-ring-2 { animation-delay: 0.25s; } .breathe-ring.fast.breathe-ring-2 { animation-delay: 0.25s; }
.breathe-ring.fast.breathe-ring-3 { animation-delay: 0.5s; } .breathe-ring.fast.breathe-ring-3 { animation-delay: 0.5s; }
/* ---- 呼吸光环:进度环外侧一圈淡化的描边辉光 ---- */
.breath-aura {
position: absolute;
/* 直径略大于进度环(420),落在刻度/进度弧外侧,不遮挡中间时钟 */
width: 488rpx;
height: 488rpx;
border-radius: 50%;
/* 细线 + 低透明度:只是隐约的呼吸边界,不跟倒计时环抢视觉 */
border: 4rpx solid rgba(var(--primary-rgb), 0.20);
box-shadow:
0 0 30rpx rgba(var(--primary-rgb), 0.22),
inset 0 0 20rpx rgba(var(--primary-rgb), 0.10);
animation: auraBreathe 4s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
.breath-aura.fast {
animation: auraBreatheFast 2.4s ease-in-out infinite;
}
/* 柔和呼吸:幅度小(scale 0.96↔1.04)、透明度压低(0.3↔0.5),留出呼吸感但始终很淡 */
@keyframes auraBreathe {
0%, 100% { transform: scale(0.96); opacity: 0.30; }
50% { transform: scale(1.04); opacity: 0.50; }
}
@keyframes auraBreatheFast {
0%, 100% { transform: scale(0.97); opacity: 0.34; }
50% { transform: scale(1.05); opacity: 0.55; }
}
/* celebration burst */ /* celebration burst */
.celebrate-burst { .celebrate-burst {
position: absolute; position: absolute;
@@ -281,47 +100,110 @@
100% { opacity: 0; transform: translate(0, -80rpx) scale(0.4); } 100% { opacity: 0; transform: translate(0, -80rpx) scale(0.4); }
} }
/* ---- hint section ---- */ /* ---- 信息气泡·状态行(原环下方 statusText 并入气泡顶部) ----
.hint-section { 状态色:running=主色 / 休息中=绿 / idle、paused=中性灰。
margin-top: 36rpx; resting 时 status 字段仍是 running,额外用 .info-line--rest 覆盖为绿。
text-align: center; 小圆点用 currentColor 跟随状态色,纯 CSS 画(项目 SVG 图标色烤死改不动)。 */
.info-line--status {
gap: 12rpx;
} }
.hint-row { .info-status-dot {
display: inline-flex; width: 14rpx;
align-items: center; height: 14rpx;
justify-content: center; border-radius: 50%;
gap: 10rpx; background: currentColor;
flex-shrink: 0;
} }
.hint-icon { .info-status-text {
width: 36rpx; font-size: 32rpx;
height: 36rpx; font-weight: 700;
}
.running-pulse {
animation: float 1.5s ease-in-out infinite;
}
.completed-bounce {
animation: bounceSoft 0.6s ease infinite;
}
.hint-text {
font-size: 34rpx;
color: var(--text-secondary);
line-height: 1.3; line-height: 1.3;
color: var(--text);
} }
.hint-text.running { color: var(--primary); font-weight: 600; } .info-line--status.status--running { color: var(--primary); }
.hint-text.paused { color: var(--primary-light); } .info-line--status.status--running .info-status-dot {
.hint-text.completed { color: var(--success); font-weight: 600; } animation: statusDotBlink 1.6s ease-in-out infinite;
}
.info-line--status.info-line--rest { color: var(--success); }
.info-line--status.info-line--rest .info-status-dot {
animation: statusDotBlink 1.8s ease-in-out infinite;
}
.info-line--status.status--paused,
.info-line--status.status--idle { color: var(--text-secondary); }
.completed-hint { animation: popIn 0.5s ease; } /* 暂停时冻结呼吸,静止本身就是"已暂停"的信号 */
.info-line--status.status--paused .info-status-dot { animation: none; }
@keyframes statusDotBlink {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.75); }
}
/* ---- 信息气泡:状态 + 目标摘要 + 运行提示 合并为一张卡(三模式公用) ----
三行结构:上=状态(最醒目,状态色) / 中=目标常驻(小字次级) / 下=动态提示。
休息/暂停/待机时提示行为空,分隔线和提示行整体消失,气泡自动收缩。 */
.info-bubble {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: 620rpx;
max-width: 86%;
/* 承接原 timer-status 的 40rpx 间距,圆环与卡片保持呼吸感 */
margin-top: 40rpx;
padding: 24rpx 28rpx;
background: rgba(var(--primary-rgb), 0.08);
border-radius: 26rpx;
}
.info-line {
display: flex;
align-items: center;
}
.info-icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
margin-right: 12rpx;
}
.info-text {
flex: 1;
min-width: 0;
line-height: 1.4;
}
/* 目标行:静态摘要,主色小字 */
.info-line--goal .info-text {
font-size: 26rpx;
font-weight: 500;
color: var(--primary);
}
/* 分隔线:提示行出现时才渲染 */
.info-divider {
height: 1rpx;
background: rgba(var(--primary-rgb), 0.14);
margin: 14rpx 0;
}
/* 提示行:动态信息,比目标行大一号更突出 */
.info-line--tip .info-text {
font-size: 30rpx;
font-weight: 600;
color: var(--text);
}
.info-line--done .info-text {
color: var(--success);
}
/* ---- controls ---- */ /* ---- controls ---- */
.controls { .controls {
margin-top: 60rpx; margin-top: auto;
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -543,6 +425,46 @@
height: 32rpx; height: 32rpx;
} }
/* 未达最低记录时长提示(完成弹窗内):主色淡底信息块,与科学提示同风格 */
.completion-not-recorded {
display: flex;
flex-direction: column;
gap: 10rpx;
width: 100%;
box-sizing: border-box;
background: rgba(var(--primary-rgb), 0.08);
border-radius: 20rpx;
padding: 20rpx 24rpx;
margin-bottom: 28rpx;
animation: tipIn 0.4s ease 0.5s both;
}
.completion-not-recorded-main {
display: flex;
align-items: center;
gap: 10rpx;
}
.completion-not-recorded-icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
}
.completion-not-recorded-title {
flex: 1;
font-size: 26rpx;
font-weight: 600;
line-height: 1.4;
color: var(--primary);
}
.completion-not-recorded-sub {
font-size: 24rpx;
line-height: 1.5;
color: var(--text-secondary);
}
.streak-text { .streak-text {
font-weight: 500; font-weight: 500;
} }
@@ -716,3 +638,14 @@
color: #FFFFFF; color: #FFFFFF;
box-shadow: 0 6rpx 16rpx rgba(var(--primary-rgb), 0.3); box-shadow: 0 6rpx 16rpx rgba(var(--primary-rgb), 0.3);
} }
/* ---- hidden canvas for share poster (drawn on completion, consumed by onShareAppMessage) ---- */
.share-poster-canvas {
position: fixed;
left: -9999px;
top: -9999px;
width: 500px;
height: 400px;
pointer-events: none;
opacity: 0;
}
+3 -2
View File
@@ -23,7 +23,7 @@
"minified": true, "minified": true,
"autoAudits": false, "autoAudits": false,
"newFeature": false, "newFeature": false,
"uglifyFileName": false, "uglifyFileName": true,
"uploadWithSourceMap": false, "uploadWithSourceMap": false,
"useIsolateContext": true, "useIsolateContext": true,
"nodeModules": false, "nodeModules": false,
@@ -49,7 +49,8 @@
"disableUseStrict": false, "disableUseStrict": false,
"useCompilerPlugins": false, "useCompilerPlugins": false,
"swc": false, "swc": false,
"disableSWC": true "disableSWC": true,
"ignoreUploadUnusedFiles": true
}, },
"compileType": "miniprogram", "compileType": "miniprogram",
"lazyCodeLoading": "requiredComponents", "lazyCodeLoading": "requiredComponents",
+1 -1
View File
@@ -3,7 +3,7 @@
"projectname": "wx_pbzc", "projectname": "wx_pbzc",
"condition": {}, "condition": {},
"setting": { "setting": {
"urlCheck": true, "urlCheck": false,
"coverView": true, "coverView": true,
"lazyloadPlaceholderEnable": false, "lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false, "skylineRenderEnable": false,