Compare commits
25 Commits
11d49a26df
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 346d46cb27 | |||
| 9d2c752043 | |||
| 8ca709b86b | |||
| 845a1bfd8d | |||
| aa905b1d09 | |||
| b6d94b21cd | |||
| f1ad3eeb95 | |||
| 178a51d261 | |||
| 05c4763e49 | |||
| 0464eeaba6 | |||
| 6f89494c0b | |||
| 938a7d1276 | |||
| ca1793b4c0 | |||
| 187c426243 | |||
| b52db87ddf | |||
| bce796d893 | |||
| 6ff70971d8 | |||
| 3532d0111b | |||
| 76cd2b6c0f | |||
| deaff72763 | |||
| 1589d6c387 | |||
| fca3e32d04 | |||
| 19b0c4d4c8 | |||
| 1372ec19b8 | |||
| 903951aee0 |
@@ -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)
|
||||
@@ -0,0 +1,72 @@
|
||||
# 平板支撑训练小程序 · 上线前审查报告
|
||||
|
||||
日期:2026-08-04 | 审查范围:上线就绪(合规 / 包体 / 性能 / 云函数部署)
|
||||
|
||||
## 一、总体结论
|
||||
|
||||
代码质量高,主包仅 **540KB**(远低于 2MB 上限),网络请求**全部走云开发 `wx.cloud.*`**(免 request 合法域名白名单配置),隐私合规链路已在上架期验证可行。
|
||||
|
||||
**有 1 个上线阻断项(云函数部署)+ 1 个部署前置(tts 环境变量)必须处理**,其余为建议优化项。未发现功能性 bug、无合规硬伤。
|
||||
|
||||
---
|
||||
|
||||
## 二、🔴 阻断项(上线前必须完成)
|
||||
|
||||
### 1. 云函数必须重新部署(最关键)
|
||||
|
||||
> 注意:**git 提交 ≠ 云函数部署**。云函数代码要生效,必须在微信开发者工具里「上传并部署」。
|
||||
|
||||
| 云函数 | 近期状态 | 动作 |
|
||||
|---|---|---|
|
||||
| `leaderboard` | **本次 b52db87 改了 17 行**(含 totalDays / 全榜勋章逻辑) | **必须重新部署**,否则线上排行榜跑旧逻辑 |
|
||||
| `tts` | 8-03 改(循环训练语音播报) | 需部署 |
|
||||
| `getOpenid` | 基础服务,排行榜定位 + 云同步依赖 | 确认已部署 |
|
||||
| `admin-dedupe` | 一次性清理工具(文档注明部署一次后删除) | 可选 |
|
||||
|
||||
部署方式:微信开发者工具 → 右键云函数 → **「上传并部署:云端安装依赖」**(项目无本地 node_modules,需云端 `npm install`)。`tts` 依赖 `tencentcloud-sdk-nodejs` 体积较大,云端安装需等待。
|
||||
|
||||
### 2. tts 云函数环境变量必须配好(部署前置)
|
||||
|
||||
`cloudfunctions/tts/index.js` 从环境变量读取密钥:
|
||||
|
||||
```
|
||||
TTS_SECRET_ID / TTS_SECRET_KEY / TTS_REGION
|
||||
```
|
||||
|
||||
若未配置,语音合成直接失败(循环训练 / 语音播报失效)。
|
||||
确认路径:云开发控制台 → `tts` 云函数 → 环境变量;并确认腾讯云 **TTS 服务已开通、密钥有效**。
|
||||
|
||||
---
|
||||
|
||||
## 三、🟡 建议项(不阻断,建议上线前做)
|
||||
|
||||
1. **清理调试 `console.log`**:`utils/cloud.js` 14 处 + `storage.js` / `voice.js` / `app.js` / `leaderboard` 云函数等。上线应移除或降级(减小包体、避免泄露内部状态、减少日志噪声)。微信审核不强制。
|
||||
2. **`project.config.json` 上线开关**:`minified: false` → `true`;`uploadWithSourceMap: true` → `false`。防源码泄露、减小包体(开发者工具上传界面也有独立勾选,会覆盖此项)。
|
||||
3. **隐私授权自定义弹窗**:代码无 `wx.onNeedPrivacyAuthorize` 处理。当前能跑(微信自动弹系统默认隐私窗,且后台隐私协议已配、已上架运行一月),但建议加自定义弹窗以更合规、体验更佳。**非阻断**。
|
||||
4. **全局错误上报**:无 `wx.onError` / `onUnhandledRejection` / `onPageNotFound`。建议加全局异常捕获 + 轻量上报(云监控 / 自建),便于线上排查崩溃。
|
||||
5. **清理残留文件**:`cloudfunctions/shared/`(空死目录,重构遗留;`leaderboard` 实际引用 `./data`,不受影响)、根目录 `preview-buttons.html`、`.claude/` / `.codegraph/`(工具目录)。小程序只打包组成文件,不影响包体,但建议加 `packOptions.ignore` 或删除以免误传。
|
||||
6. **确认生产 env**:`utils/cloud.js` 硬编码 `ENV_ID=cloudbase-d1g56kl2q8f4f7d8a`,确认是生产环境(通常单一 env 即可)。
|
||||
|
||||
---
|
||||
|
||||
## 四、✅ 已核实合规 / 无风险项
|
||||
|
||||
- 主包 540KB << 2MB,无需分包
|
||||
- 无硬编码 `http://`,全走 `wx.cloud`(免 request 合法域名配置)
|
||||
- 隐私接口仅 `chooseAvatar`(button `open-type`,后台已声明「用户信息」)
|
||||
- `app.json` 无需 `requiredPrivateInfos`(`chooseAvatar` 不在此列)
|
||||
- `sitemap.json` 标准 allow
|
||||
- `tabBar` custom + `custom-tab-bar` 目录齐全
|
||||
- 云函数依赖声明齐全(`wx-server-sdk` / `tencentcloud-sdk-nodejs`)
|
||||
- 内容安全风险低(昵称 / 头像均为用户自身数据,无实时 UGC 发布链路)
|
||||
|
||||
---
|
||||
|
||||
## 五、上线 Checklist
|
||||
|
||||
- [ ] 部署 `leaderboard` / `tts` / `getOpenid` 云函数(云端安装依赖)
|
||||
- [ ] 配好 `tts` 环境变量并验证 TTS 可用
|
||||
- [ ] 开发者工具「上传」时勾选 **压缩** + **不上传 sourcemap**
|
||||
- [ ] 体验版真机回归:训练 → 记录 → 排行榜(含空榜 / 1-2 名虚拟台)→ 设置改头像
|
||||
- [ ] 确认 MP 后台类目(运动健身类)与隐私协议有效
|
||||
- [ ] 提交审核,准备测试说明(功能无需登录,排行榜可空榜展示)
|
||||
@@ -0,0 +1,11 @@
|
||||
# 2026-07-30 工作日志
|
||||
|
||||
## 朋友提供的勋章体系 PRD 评审(纯分析,未改代码)
|
||||
- 方案为三轴:毅力试炼(连续 streak 天数 3/7/15/21/30/66/100)、力量殿堂(累计时长 30min/3h/10h/50h)、极限时刻(单次时长 1/2/5min)。
|
||||
- 结论:方向合理、数据齐(streak.count / totalDuration / maxDuration 现有 storage 均已有)、不浪费现有工作(现有 trainingDayBadges 可降级为"总天数纪念线")。
|
||||
- 三处必须修:
|
||||
- P0 护盾机制只提名字没设计——streak 线生死线,漏一天归零会劝退,需补"消耗品断签保护"规格。
|
||||
- P0 文案/视觉全用 emoji,与用户既定"不想用 emoji"冲突,须换矢量图标(belt/shield/stopwatch 缺,需新增)。
|
||||
- P1 需拍板"首页 C 位放 streak 还是累计天数"(用户早前确认过累计天数可中断,PRD 把 streak 提为核心线,不矛盾但需决策)。
|
||||
- 落地注意:14 枚超单进度条容量→需勋章墙/分 Tab;config 阈值写秒;勋章同获不退(streak 断了不回收)。
|
||||
- 用户明确要求:本次只分析、不改代码。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 2026-07-31 工作日志
|
||||
|
||||
## 排行榜新增「耐力榜」(endurance)
|
||||
- 需求:全局单次最久 top10,显示该次日期;本人未上榜则在底部 my-bar 显示最佳成绩+产生时间。
|
||||
- 云函数 `cloudfunctions/leaderboard/index.js`:
|
||||
- `_buildFromScan` 新增 `endurance` 分支:遍历每人全部 records 取 `max(duration)`,记下 `bestDate`(该条 r.date);`ranked`/`myEntry` 映射均透传 `bestDate`。
|
||||
- `exports.main` 放行 `endurance`;`_rebuildSnapshots` 循环加入 `endurance`(随定时器每 4 分钟重算,存 top500)。
|
||||
- `bestDate` 经 `publicEntry` 的 `...publicData` 展开自动进快照/响应,data.js 无需改。
|
||||
- 客户端 `pages/leaderboard/leaderboard.js`:
|
||||
- `periods` 加 `{key:'endurance',label:'耐力',maxRank:10}`(日/月/年保持 config.leaderboardMaxRank=50)。
|
||||
- `fetchRank` 按当前周期取 `maxRank`(不再写死)。
|
||||
- `_applyResult` 计算 `subText`(耐力=格式化 bestDate 去秒,其余=`N次`);新增 `_formatBestDate` 辅助。
|
||||
- `_applyLocal` 加耐力兜底分支(本地 records 取 max+日期);分享 map 加 `endurance:'耐力'`。
|
||||
- wxml 5 处 `{{x.sessions}}次` → `{{x.subText}}`(领奖台3/列表1/my-bar1)。
|
||||
- wxss `.podium-base__label` 加 `nowrap`+省略号,防窄屏日期串换行撑破领奖台底座。
|
||||
- 校验:`node --check` 云函数+客户端均通过;grep 确认无 `sessions}}次` 残留。
|
||||
- **待办**:改云函数后须重新部署(定时器随部署注册),否则 endurance 周期不生效。改动尚未提交推送,等用户确认。
|
||||
|
||||
## 领奖台底座日期时间拆两行(耐力榜)
|
||||
- 用户要求领奖台底座的「单次日期」拆成日期+时间两行。
|
||||
- `leaderboard.js`:`_applyResult`/`_applyLocal` 给每条耐力榜数据补 `subDate`(YYYY-MM-DD)、`subTime`(HH:MM) 两字段(新增 `_splitBestDate` 从 bestDate 截断);`subText` 仍保留供列表/my-bar 单行使用。
|
||||
- `leaderboard.wxml`:领奖台 3 处 `<text>` 改为 `<view>` 容器 + `wx:if activePeriod==='endurance'` 渲染两行 `podium-base__date`/`podium-base__time`,否则单行 `subText`。
|
||||
- `leaderboard.wxss`:`.podium-base__label` 由单行绝对定位 text 改为 flex column 居中 view(去掉 nowrap/ellipsis),新增 `.podium-base__date`(20rpx)/`.podium-base__time`(18rpx) 缩小字号以适配最矮的第3名底座(48rpx)。
|
||||
- 校验:`node --check` 通过,grep 确认 subDate/subTime/_splitBestDate 已接入。
|
||||
|
||||
## 领奖台第3名贴顶修复 + 顶部文案
|
||||
- 第3名底座仅 48rpx,两行日期时间文字溢出贴顶。统一抬高三阶底座高度:first 96 / second 64→80 / third 48→64(梯度 96/80/64)。
|
||||
- `.podium-base__label` bottom 8→6rpx;耐力榜两行字号缩小:date 20→18rpx、time 18→16rpx。两级余量足以抗微信字体缩放放大。
|
||||
- 领奖台顶部新增哲理文案(仅耐力榜显示,`wx:if activePeriod==='endurance'`):
|
||||
「平板支撑不是一场和时间的恋爱。撑得越久,不一定越好;真正重要的,是每一秒都没有辜负身体。」
|
||||
- 文案配置进 `config.js` 新增 `leaderboardPodiumSlogan`;`leaderboard.js` data 挂 `podiumSlogan: config.leaderboardPodiumSlogan`;wxml 在 `<block wx:else>` 内 podium 前插入 `.podium-slogan` 块;wxss 新增 `.podium-slogan` 样式(居中、淡色、引用感卡片)。
|
||||
- 校验:`node --check` 客户端 + config 均通过;grep 确认 podiumSlogan / leaderboardPodiumSlogan / podium-slogan 三处链路完整。
|
||||
|
||||
## 顶部文案配色与样式微调
|
||||
- 首版 `.podium-slogan` 为灰底灰字,用户认为不好看 → 改为「极淡橙渐变底 + 主色描边 + 主色微光阴影 + 顶部主色大引号(`::before` `\201C`)点睛」金句卡片质感(用 `--primary-rgb`/`--text` 变量,深色模式自适应)。
|
||||
- 用户再要求:去掉引号、文案改斜体。已删除 `.podium-slogan::before` 块;主块加 `font-style: italic`,并把 padding-top 从 26rpx 收到 22rpx 抵消去掉引号后的顶部留白。纯样式改动,无逻辑、无需重新部署云函数。
|
||||
|
||||
## 首页去掉 toolbar 上方小提示卡片
|
||||
- 删除首页底部随机姿势提示卡片(homeTip):wxml 移除 tip-card 整块、wxss 清理 .tip-card/.tip-icon/.tip-text、js 移除 data.homeTip + 随机取 postureTips 逻辑 + setData 引用。
|
||||
- `config.postureTips` 仍被训练页(timer)复用,保留不动。纯前端,编译即可生效,无需重新部署云函数。
|
||||
- 提交:`deaff72`(含 config.js v3.02→v3.1 升版 + 顶部文案标点修正,均无害)。
|
||||
|
||||
## 三个 P2 小修(体检发现)
|
||||
1. **离线兜底缺"N次"**:`pages/leaderboard/leaderboard.js` `_applyLocal` 非 endurance 分支 entry 漏 `subText`,补 `subText: \`${sessions}次\``,与云端 `_applyResult` 对齐。断网/未部署时底部成绩栏不再留白。
|
||||
2. **同天二次训练文案虚高 1 天**:`pages/timer/timer.js` `_detectCelebrationLevel` 未判断 streak.lastDate 是否已为今天。updateStreak 对同天再次训练不递增,故 `nextStreak` 加 `lastIsToday` 判断:是则取 `streak.count`,否则取 `count+1`。只错文案、存储连胜数本就对。用 `storage.getToday().substring(0,10)` 比 lastDate(timer.js 未引入 util,故未用 util.dateOnly)。
|
||||
3. **热力图日期解析脆弱**:`components/calendar-heatmap/calendar-heatmap.js` line 64 原 `parseInt(r.date.split('-')[2])` 靠 parseInt 侥幸解析 `DD HH:MM:SS`。改为 `parseInt(util.dateOnly(r.date).split('-')[2], 10)`,与全局日期规范统一(该组件已引入 util)。
|
||||
- 校验:`node --check` 三文件均通过;grep 确认三处改动已落实。纯前端,无需重新部署云函数。尚未提交推送,等用户确认。
|
||||
|
||||
## 配置澄清
|
||||
- `config.js` 的 `leaderboardMaxRank: 50` 是**客户端显示行数上限**(日/月/年榜 50、耐力榜 10)。
|
||||
- 云函数 `cloudfunctions/leaderboard/index.js:11` `SNAPSHOT_TOP = 500` 是**快照每周期存储人数上限**(写死在云函数,不读 config)。底部「我的成绩」栏靠 findMyEntry 在存下的完整 500 里找本人,故排 51~500 也能在底部显示;仅走缓存快照时受 500 限制(force 实时重算路径在完整全量榜找人,不受 500 限)。两数字各管一摊。
|
||||
@@ -0,0 +1,212 @@
|
||||
# 2026-08-03 工作日志
|
||||
|
||||
## 规划:分阶段训练(循环训练 circuit)功能
|
||||
|
||||
用户想在平板支撑小程序加入"分阶段训练":自定义 每组时长 / 每次组数 / 每周训练次数。
|
||||
分析现有代码后结论:
|
||||
|
||||
- 现有 `utils/plan.js` 是"单组持续撑"模型(hold 模式),计划按天线性加秒;设置页已能自定义公式参数(totalDays/startTarget/increment/cycleDays),但无"组数/休息/周频次"概念。
|
||||
- 用户要的是"循环训练(circuit)"范式,当前数据模型与计时器都表达不了,但云同步/计划选择/进度条/记录页骨架可复用。
|
||||
|
||||
### 已确认的设计决策(与用户拍板)
|
||||
1. **双模式并存**:保留现有3个预设计划和老用户进度(mode:'hold'),新增 circuit 类型(mode:'circuit')。不破坏榜单/已有数据。
|
||||
2. **弹性训练**:任意一天都能练;sessionsPerWeek 只用于显示「本周 X/N 次」(自然周周一重置),不卡进度(`getPlanDay` 已按训练日推进)。
|
||||
3. **扁平 routine 先上**:整个计划每次 routine 相同(如 4组×30秒),靠"完成次数"和"每周次数"做进度;逐周进阶作为后续增强,MVP 不做。
|
||||
|
||||
### 约定细节
|
||||
- 默认组间休息 15 秒(编辑器可改)。
|
||||
- 循环预设示例:"新手 4组×20秒·休息15秒·3次/周·4周"。
|
||||
- `duration` 始终存"有效撑总秒数 = sets×holdPerSet"(不含休息),保证排行榜/里程碑零改动。
|
||||
|
||||
### 计划改动清单(原方案,较完整,本次未走此路线)
|
||||
- utils/plan.js:加 mode + circuit 目标解析 + 1 个循环预设
|
||||
- utils/storage.js:saveRecord 支持 sets/holdPerSet/restPerSet/mode
|
||||
- pages/timer + 新 utils/circuitTimer.js:circuit 状态机与 UI
|
||||
- pages/index:目标显示"4组×30秒·休息15秒" + 本周进度
|
||||
- pages/settings:编辑器加"循环模式"输入项
|
||||
- pages/records:展示"4组×30秒"
|
||||
- 榜单/云函数:不动
|
||||
|
||||
## 方案简化(用户两次追问"还有没有再简单一点的方案")
|
||||
|
||||
原方案把 circuit 做成完整"计划管理系统"(mode/预设/进度进阶/周频次追踪/设置页编辑器) 过度设计。
|
||||
最终定位为「自由训练」的循环形态,而非新计划体系:
|
||||
|
||||
- 首页加「循环训练」入口:输入 每组时长/组数/休息秒数 → 跳 timer?circuit=1&hold=&sets=&rest=
|
||||
- timer 内用新 utils/circuitTimer.js(类 Timer 接口: start/stop/pause/resume + onTick/onPhaseChange) 跑 工作/休息 状态机;UI 显示「第X/Y组」+ 阶段 + 组内剩余
|
||||
- 首页主「开始训练」仍走原 hold 计划,circuit 不替换每日目标 → 完全不动 index 的计划逻辑
|
||||
- storage.saveRecord 加可选 sets/holdPerSet/restPerSet/mode;duration 仍=有效撑总秒(sets×hold),榜单零改
|
||||
- records 页:circuit 记录显示「4组×30秒」
|
||||
- **砍掉**:plan.js mode、circuit 预设、设置页编辑器、plan-day 进阶、周频次追踪(周频次可仅作为 picker 里可选提示,先不做追踪)
|
||||
- 唯一"真新逻辑"= circuitTimer 状态机;其余皆小 UI。风险与原方案同级(无新权限/域名)。
|
||||
|
||||
用户最终确认:**先做简化版,但循环训练设置要持久化**(下次打开不重填)。
|
||||
|
||||
## 已实现:简化版循环训练 + 配置持久化(2026-08-03 落地)
|
||||
|
||||
### 改动文件
|
||||
- **utils/storage.js**:新增 `CIRCUIT_CONFIG_KEY` / `getCircuitConfig` / `saveCircuitConfig`(本地持久化,含默认值 `{holdPerSet:30, sets:4, restPerSet:15, sessionsPerWeek:3}` + 范围校验,**不进云同步**——纯设备偏好)。
|
||||
- **utils/circuitTimer.js**(新):`CircuitTimer` 状态机 `idle→working→resting→…→done`,类 `Timer` 接口(`start/stop/pause/resume` + `onTick/onPhaseChange/onComplete`),`stop()` 返回 `workTotal`(有效撑总秒,不含休息),复用 `Date.now`+`onAppShow` 前台补偿。
|
||||
- **pages/timer/timer.{js,wxml,wxss}**:`_init` 检测 `options.circuit` → `_initCircuit` 建 FSM + circuit 字段;新增 `_circuitCue/_circuitPhase/_onCircuitComplete/_vibrateOnce/_int`;阶段切换走**震动 + phaseTip 文本**(TTS 无"第N组/休息"词,刻意不堆语音),完成复用 `complete` 语音;`_saveAndShowCompletion` 写 `record.mode='circuit'` + `sets/holdPerSet/restPerSet/sessionsPerWeek`,**`planId` 用 `'circuit'`**(避免 `getPlanDay` 把循环记录算进每日计划进度);`onTrainAgain` 在 circuit 模式重建 FSM;completion overtime 在 circuit 隐藏(每阶段是固定目标,无"超长发挥"概念)。
|
||||
- **pages/index/index.{js,wxml,wxss}**:加「循环训练 · 分组练习」ghost 按钮(`repeatFill` 图标);底部配置弹窗(stepper 调 每组时长/组数/休息/每周目标),确认后 `saveCircuitConfig` + 跳 `timer?circuit=1&hold=&sets=&rest=&sessions=`。
|
||||
- **utils/icons.js**:加 `repeat`/`repeatFill`(Material repeat 路径,build 自动给 gray + primary 两变体)。
|
||||
- **pages/records/records.{js,wxml}**:circuit 记录显示「循环 N组×Ms(·休Rs)」,基于 `r.mode`。
|
||||
|
||||
### 关键不变量(续做时牢记)
|
||||
- `duration` 始终 = 有效撑总秒(组数×每组秒,不含休息)→ 排行榜/里程碑/统计零改动。
|
||||
- 循环记录 `planId:'circuit'`,不污染每日计划 `getPlanDay`;连胜 `updateStreak` 仍计入(用户确实练了)。
|
||||
- 进度环在 circuit 模式轨道恒中性灰(否则每阶段都按"累计≥阶段长"误判变绿)。
|
||||
|
||||
### 验证
|
||||
6 个 JS 全过 `node --check`。未动 plan.js / 云函数 / 榜单 / 云同步。
|
||||
|
||||
### 未做(可选后续)
|
||||
- 周频次硬追踪(仅存 sessionsPerWeek 作显示,未做"本周 X/N 次"计数)
|
||||
- 逐周自动进阶
|
||||
- 设置页统一管理入口
|
||||
|
||||
## 修复+重做:循环训练页顶部进度面板(2026-08-03 二次迭代)
|
||||
|
||||
用户反馈"顶部只有『1/3组,撑住』太简单难看"。**根因:上一轮只写了 `.circuit-bar` 的 WXML 结构,`timer.wxss` 里一行样式都没写**(grep 确认零匹配),所以渲染出来就是两段裸文字。
|
||||
|
||||
### 新设计(信息层次分工)
|
||||
- **顶部面板管宏观「第几组」**,中间进度环管微观「本组还剩几秒」——不再重复展示同一信息。
|
||||
- 结构:组进度胶囊点阵 → 大数字 `2/4 组` + 阶段徽章 → 副行「已撑 42 秒 · 目标 120 秒」。
|
||||
- 点阵三态:`is-done`(实心主色) / `is-active`(加宽+主色渐变+发光呼吸) / `is-next`(休息时高亮下一组,success 绿呼吸)。
|
||||
- 徽章四态 BEM:`--work`(主色渐变实心+白灯白字) / `--rest`(success 绿淡底) / `--idle` / `--pause`(中性灰)。休息态整张卡边框也转绿,视觉降温。
|
||||
- 待机态显示总组数 `4 组` + "准备开始" + "每组30秒·休息15秒";运行态显示 `2/4 组` + 累计秒数。
|
||||
|
||||
### 实现要点(踩坑记录)
|
||||
- **状态指示灯用纯 CSS 圆点,不用 icon**:项目里 SVG 图标颜色烤死在 data URI,CSS 改不动;而这个灯需要随状态换色(白/绿/灰),纯 CSS 是唯一干净解。
|
||||
- **点阵用 `flex:1` + `max-width`**,任意组数都均分不溢出;`is-active` 用 `flex:2` 加宽突出。组数 >15 (`DOTS_MAX`) 退化为线性进度条 `.circuit-track`。
|
||||
- **面板不设固定 height**,靠 padding 撑开——真机大字体档会放大文字但不放大 rpx 盒子,固定高度必裁字(项目老坑)。
|
||||
- **`.circuit-bar` 必须 `position:relative; z-index:1`**:`.breathe-ring`(z-index:0) 在 DOM 里排在面板之后,不抬层橙色光晕会晕染到白卡片上。
|
||||
- 暂停时 `.is-paused` 冻结所有 dot 呼吸动画——静止本身就是"已暂停"信号。
|
||||
- dots 三态在 `onTick` 里算(与 `isResting` 同批 setData,不会状态不同步):resting 时 CircuitTimer 的 `setIndex` 仍指向"刚做完那组",故 `dotsDone=setIndex`、`dotsActive=0`、`dotsNext=setIndex+1`。
|
||||
- `_initCircuit` 新增 `circuitDots`(仅 [1..sets] 供 wx:for) / `circuitShowDots` / `circuitTotalWork`(sets×hold)。
|
||||
- hint-section 的 circuit idle 文案改为「循环训练 · 共需撑 N 秒」,避免与面板副文案重复。
|
||||
|
||||
改动文件:`pages/timer/timer.{js,wxml,wxss}`。`node --check` 通过,无旧类名残留。
|
||||
|
||||
## 微调:组进度点阵改为按组数严格等分铺满(2026-08-03 三次迭代)
|
||||
|
||||
用户反馈"点阵只占顶部一部分,应该按组数平均分配到整行"。
|
||||
|
||||
- **根因**:`.circuit-dot` 有 `max-width: 64rpx` 给每段宽度封顶,`.circuit-dots` 默认 `justify-content:flex-start` → 组数少时(3-4 组)点阵只占左边一截。此外 `is-active` 用 `flex:2` 加宽,本身就破坏了"按组数等分"。
|
||||
- **修法**:`.circuit-dot` 改 `flex:1 1 0; min-width:0`,**删掉 `max-width`**;`.circuit-dots` 加 `width:100%`。当前组/下一组不再靠"加宽"强调,改为 **增高**(12rpx → 18rpx,圆角同步 6→9rpx)+ 渐变/发光/呼吸,宽度恒等分。
|
||||
- `.circuit-dots` 加 `min-height:18rpx` 锁行高,避免 idle(无 active,行高 12rpx)→ running(有 active,18rpx)时整块跳 6rpx。
|
||||
- transition 从 `flex` 改为 `height`(强调维度变了)。
|
||||
- 仅改 `pages/timer/timer.wxss`,无 JS/WXML 改动。
|
||||
|
||||
## 修复:循环训练缺少过程语音(2026-08-03 四次迭代)
|
||||
|
||||
用户反馈"循环训练缺少正常训练时候的那些语音提示"。**根因有两处,都是设计遗漏**:
|
||||
1. **`_circuitCue` 从未调用 `_remind`**:单组模式 `Timer.onTick` 里有 `this._remind(tick)`(负责 halfway/last30/last10 三条语音),circuit 的 `onTick` 只调 `_circuitCue`,里面仅有"最后一组末 5 秒震动"。
|
||||
2. **`_circuitPhase` 当初刻意不放语音**(注释写明"TTS 无第N组/休息词条")。
|
||||
结果整场只剩 2 声:`onStart` 的 `start` + `_onCircuitComplete` 的 `complete`。
|
||||
|
||||
### 修法
|
||||
- **新增 3 个 TTS 词条**(`utils/voice.js` 与 `cloudfunctions/tts/index.js` 的 PROMPTS **必须同步**):`restStart`(休息一下,调整呼吸) / `nextSet`(下一组,准备开始) / `lastSet`(最后一组,全力冲刺!)。**刻意不含组号数字** → 固定 3 条覆盖任意组数,无需按 N 合成 N 份音频。
|
||||
- **`_circuitCue` 加整场进度播报**:基准是 `total = sets × holdPerSet`(**整场有效秒,不是每组**)——否则 4 组会播 4 次"已完成一半啦",语义全错。判据用 `done >= total/2` 和 `left <= 30 / <= 10`(`<=` 而非 `===`,容忍后台回前台跳秒)。只在 `phase==='working'` 求值(休息时 workTotal 冻结,天然不会重复触发)。门槛沿用 `_remind`:halfway 需 total>=10、last30 需 total>60、last10 需 total>20。
|
||||
- **`_circuitPhase` 加阶段语音 + `chatty` 闸门**:`holdPerSet >= 10` 才播 `nextSet`/`restStart`(一句话约 2 秒,短组会变语音连珠炮);`restStart` 另需 `restPerSet >= 5`;`lastSet` 无条件播(唯一值得打断的节点)。第 1 组不播(`onStart` 的 `start` 同刻已响)。
|
||||
- **`_playProgressVoice` 优先窗口(关键时序坑)**:halfway 触发点 = `sets×hold/2`,**组数为偶数时必然落在第 sets/2 组的最后一秒**,与 rest 阶段切换同一 tick;`voice.play` 的 `_playSeq` 会让后播的截断先播的 → 默认 4 组配置下"已完成一半啦"必被 `restStart` 掐断。故进度语音播放时设 `this._voiceBusyUntil = Date.now()+2600`,`_circuitPhase` 在窗口内让位不播。`_initCircuit` 需重置 `_voiceBusyUntil = 0`(否则"再来一次"会误静音首条过场语音)。
|
||||
- **preload 分模式**:`voice.HOLD_KEYS` / `voice.CIRCUIT_KEYS` 导出,`onLoad` 按 `isCircuitMode` 取,避免单组训练白白预载 3 条循环语音。
|
||||
|
||||
### 部署要求
|
||||
**必须重新部署 `tts` 云函数**,否则 3 个新 key 云端返回 `Unknown prompt key`。降级是安全的(`_fetchUrl` 拿到 `success:false` → 返回 null → `play` 静默 return,不报错、不弹 toast),只是这 3 条不出声。首次播放会各触发一次 TTS 合成(一次性费用,之后走 fileID + 本地文件双层缓存)。
|
||||
|
||||
改动文件:`utils/voice.js`、`cloudfunctions/tts/index.js`、`pages/timer/timer.js`。三个 `node --check` 全过,两边词条 grep 核对一致。
|
||||
|
||||
## 分析(未改代码):训练时禁止息屏
|
||||
|
||||
用户问"训练过程中能否禁止屏幕息屏"。结论:能,`wx.setKeepScreenOn({keepScreenOn})`,基础库 1.4.0+,无授权/无域名/无审核风险。项目当前 **零处调用**(grep `setKeepScreenOn` 无匹配)。
|
||||
|
||||
### 为什么这不只是体验优化,而是修 bug
|
||||
息屏/后台后小程序 JS 定时器被系统节流挂起 → 语音播报(halfway/last30/last10、circuit 的 restStart/nextSet/lastSet)与震动**全部丢失**。`Timer`/`CircuitTimer` 有 `Date.now` 前台补偿,秒数不会错,但中段提示是废的。平板支撑用户手撑地上不碰屏幕,60 秒必息屏 → 现状几乎每次训练的过程提示都收不到。
|
||||
|
||||
### 挂钩位置(关键)
|
||||
- `wx.setKeepScreenOn` 作用域是**整个小程序**,跨页面持续生效,只有**退出小程序**才自动失效 → 在 `pages/timer` 打开后,若不手动关,用户返回首页/记录页/排行榜也会一直常亮耗电。
|
||||
- 建议:`onStart` 开启 → `onUnload` 必须关闭(timer.js 现有 `onLoad`/`onShow`/`onUnload`,**无 `onHide`**)。暂停时保持常亮(用户可能只是短暂调整姿势)。完成弹窗期间也保持,离开页面才关。
|
||||
- `wx.setKeepScreenOn` 是异步 API,`fail` 要静默吞掉(部分 Android ROM 省电模式会拒绝),不弹 toast。
|
||||
|
||||
### 已知限制(需向用户说明)
|
||||
- 只阻止**自动**息屏;用户手动按电源键仍会锁屏。
|
||||
- 部分 Android 厂商 ROM 在省电模式/低电量下会忽略该设置。
|
||||
- 不影响亮度,屏幕可能仍会自动调暗(如需要可配 `wx.setScreenBrightness`,但必须在 `onUnload` 恢复,否则污染用户系统亮度——不建议做)。
|
||||
|
||||
### 建议的落地范围
|
||||
约 5 行,只动 `pages/timer/timer.js`;可选在设置页加「训练时屏幕常亮」开关(默认开),与 `voiceGuide` 同样存在 settings 里。
|
||||
|
||||
## 已实现:训练时屏幕常亮(2026-08-03 五次迭代)
|
||||
|
||||
用户确认落地,且明确「先不要加开关」→ 默认恒开,不入 settings。
|
||||
|
||||
### 实现
|
||||
仅改 `pages/timer/timer.js`,新增 `_keepScreenOn(on)` 方法 + 4 个挂钩点:
|
||||
- `_keepScreenOn(on)`:`typeof wx.setKeepScreenOn !== 'function'` 时直接 return(低基础库降级),`fail(){}` 静默吞掉(Android 省电模式会拒绝,弹 toast 只会让用户困惑)。
|
||||
- `onStart()` 正常启动分支 → 开启;**暂停时不关**(用户多半在调整姿势,闪屏更烦);**完成弹窗期间不关**(让用户看成绩)。
|
||||
- `onStart()` 的 `isPaused` resume 分支 → 也调一次(暂停期间若切过后台,标志位已被系统清掉)。
|
||||
- `onShow()` → `if (this.data.isRunning) this._keepScreenOn(true)` **重新武装**。这是最容易漏的一点:退出/切后台会清掉 app 级标志,回来不重设则后半程又静音。完成后 `isRunning` 已是 false(第 831 行 setData),不会误开。
|
||||
- `onUnload()` → **必须关**。标志是 app 级不是页面级,不关则用户返回首页/记录/榜单全程常亮耗电。
|
||||
|
||||
hold 与 circuit 两种模式共用这一套(都走 onStart/onUnload),无需分别处理。`node --check` 通过。
|
||||
|
||||
## 分析(未改代码):首页打卡卡片左上/左下出现两条竖条
|
||||
|
||||
用户反馈"训练结束返回首页,顶部连续打卡卡片左上角和左下角各显示一个竖条,2-3 秒后消失"。
|
||||
|
||||
### 根因
|
||||
`pages/index/index.wxss` 的 `.just-completed`(训练完成高亮)把 `border: 4rpx solid var(--primary) !important` + `box-shadow` 加在了 **`<ui-card>` 宿主节点**上(`index.wxml:13` 的 `class="{{justCompleted ? 'just-completed' : ''}}"`)。而 `components/ui-card/ui-card.wxss` **只给内部 `.ui-card` view 写样式,宿主节点没有任何 `display` 声明** → 宿主是 **inline 盒**。
|
||||
|
||||
inline 盒内包 block 子元素时,inline 盒被打断成"前/后"两个零宽行盒,border 只画在这两个碎片上;左右各 4rpx 贴合 → 视觉上就是 block 上方一条、下方一条竖线,且都贴左侧 = 用户看到的"左上角+左下角两个竖条"。
|
||||
|
||||
时序对上:`index.js:125-129` 在 `onShow` 置 `justCompleted=true`,`setTimeout` 3000ms 后置回 false —— 正是那 2-3 秒。
|
||||
|
||||
### 连带失效(同一根因,用户还没察觉)
|
||||
- `.just-completed` 的 `animation: justCompletedPulse`(`transform: scale`)**从未生效** —— `transform` 对非替换 inline 元素无效。
|
||||
- `box-shadow` 同样只画在两个零宽碎片上,橙色光晕看不见。
|
||||
- 全局 grep `:host` / `display:block` 在 `components/` **零匹配** → 项目里**所有**自定义组件宿主都是 inline,任何在页面侧给组件标签加 border/shadow/transform 的写法都会踩同一个坑。
|
||||
- 附带发现:`ui-card.wxss` 的 `.ui-card.in:nth-child(1..4)` 交错入场延迟**也是失效的** —— `.ui-card` 是组件内部根节点下唯一的第 1 个子元素,永远匹配 `nth-child(1)`,所有卡片延迟恒为 0.05s。要做交错必须由页面侧传 index 或用 `animation-delay` 内联 style。
|
||||
|
||||
### 候选修法(待用户拍板,尚未实施)
|
||||
1. **推荐**:`components/ui-card/ui-card.wxss` 加 `:host { display: block; }`(微信小程序支持 `:host` 选择器)。一处修复所有 ui-card 用法,border/shadow/transform 全部恢复正常。风险:宿主从 inline 变 block 可能影响其他页面既有布局,需回归各页面卡片间距。
|
||||
2. 保守版:只把 `.just-completed` 的高亮样式改成作用于**内部**元素(如页面侧改成给 `.header-card` 加高亮),不动组件。影响面最小但治标不治本。
|
||||
3. 顺带可给其余 6 个自定义组件统一补 `:host { display: block; }`(需逐个回归,尤其 `ui-btn` 若被当行内元素用则不能改)。
|
||||
|
||||
## 已实现:修复竖条 + 恢复交错入场(2026-08-03 六次迭代,用户选方案 A)
|
||||
|
||||
### 改动
|
||||
- **`components/ui-card/ui-card.wxss`**:新增 `:host { display:block; margin-bottom:24rpx; box-sizing:border-box }`,并把 `margin-bottom:24rpx` 从内部 `.ui-card` 移除(**上提到宿主**)——否则页面侧加的描边会把 24rpx 外边距一起圈进去,框比卡片高一截。删掉失效的 `.ui-card.in:nth-child(1..4)` 规则。
|
||||
- **`components/ui-card/ui-card.js`**:加 `index` 属性(Number,默认 0)。
|
||||
- **`components/ui-card/ui-card.wxml`**:`style="animation-delay:{{50 + (index>3?3:(index>0?index:0))*100}}ms;{{customStyle}}"`。**customStyle 必须写在最后**才能覆盖内联 delay。>3 钳到 3,避免设置页第 7 张卡等 0.65s。
|
||||
- **`pages/index/index.wxss`** 的 `.just-completed`:`border: 4rpx solid` → `box-shadow: 0 0 0 4rpx var(--primary), 0 4rpx 24rpx rgba(--primary-rgb,.3)`,加 `border-radius: 24rpx`,去掉两个 `!important`(宿主上已无竞争样式)。
|
||||
- **14 处调用点补 `index`**:index 页 0-1;records 页 0,1,2(trend 分支),2(records 分支,与 trend 互斥故同号),3;settings 页 0-6。
|
||||
|
||||
### 校验与风险
|
||||
- `ui-card.json` 无 `styleIsolation` 覆盖(默认 isolated),`:host` 有效。`customStyle` 从未用在 ui-card 上(只有 ui-btn 在 timer 页用),改动无冲突。
|
||||
- `hidden="{{...}}"`(records 5 张卡)安全:微信内置 `[hidden]{display:none!important}` 带 `!important`,优先级高于 `:host` 的 `display:block`(`:host` 未加 !important)。
|
||||
- 待真机确认:inline→block 会吃掉标签间行内空白,各页卡片间距可能略收紧几 px(属修正,非回归);`transform:scale(1.02)` 脉冲首次真正生效。
|
||||
|
||||
---
|
||||
|
||||
## 提交与推送
|
||||
|
||||
今日全部改动已合为一个提交推送到 `origin/main`:
|
||||
|
||||
- commit `3532d01` `feat: 循环训练模式 + 语音补全 / 屏幕常亮 / 卡片渲染修复`(23 files, +1433 / -44)
|
||||
- 前一个提交是 `76cd2b6`。推送后 `git rev-list --left-right --count origin/main...main` 为 `0 0`,工作区干净。
|
||||
- **未拆分成多个 commit 的原因**:`pages/timer/timer.js` 被循环训练 / 顶部面板 / 语音 / 常亮四轮改动同时触及,按文件粒度无法干净切分,hunk 级拆分成本高且易错。改为一次提交、commit message 分四段写清各自根因。
|
||||
- `.workbuddy/memory/*.md` 一并提交(该目录本就在版本控制内,已有 2026-07-21/22/28 三份历史日志被跟踪)。
|
||||
- 推送按项目惯例走沙箱外(`dangerouslyDisableSandbox`),沙箱内会 SSL 超时。
|
||||
|
||||
### ⚠️ 部署待办
|
||||
`cloudfunctions/tts` **尚未重新部署**。新增的 `restStart / nextSet / lastSet` 三条词条在云端词表白名单里还不存在,线上会返回 `Unknown prompt key` → 客户端静默降级(不报错、不弹窗,只是不出声)。循环训练的过场语音要生效必须先部署这个云函数。
|
||||
|
||||
## 首页按钮改直角(2026-08-03 末尾)
|
||||
|
||||
用户偏好无圆角按钮。把 `pages/index/index.wxml` 中 5 个 `ui-btn` 全加 `custom-style="border-radius:0;"`(主训练按钮 xl-primary、两个 ghost 次按钮、两个弹窗 primary-lg 按钮)。
|
||||
- **实现要点**:`ui-btn` 是全局共用组件且 `style isolation: isolated`,页面 wxss 改不到内部圆角;但组件暴露 `customStyle` 属性(直接挂到内部 `<button>` 的 `style`),内联 `border-radius:0` 可覆盖组件内置的 `48rpx`,**仅影响首页、不动全局与其他页面**。
|
||||
- 生成 `preview-buttons.html` 对照预览(圆角 vs 直角)。
|
||||
- **用户反馈直角不好看 → 改为折中 `border-radius:10rpx`(8–12 中间值)**,5 处 `custom-style` 已更新。仅首页、不动全局。预览页「改后」栏同步更新为 10rpx 效果。
|
||||
- **布局调整**:首页「自由训练 / 循环训练」两个 ghost 次按钮从竖排改为并排一行。wxml 用 `<view class="action-row">` 包住两个 `<ui-btn class="action-cell">`;index.wxss 加 `.action-row{display:flex;gap:16rpx}` + `.action-row .action-cell{flex:1;min-width:0}`(宿主节点为 flex 项,flex:1 各占一半,保留 `block` 让内部按钮撑满宿主)。按钮文字精简为「自由训练」「循环训练」。
|
||||
- **高度对齐**:自由/循环两个 ghost 次按钮 `size` 从 `md`(80rpx) 改为 `xl`(108rpx),与主按钮 `size="xl"` 高度一致(圆角保持 16rpx)。预览页 `btn--md`→`btn--xl` 同步。仅首页、不动全局组件。
|
||||
@@ -0,0 +1,181 @@
|
||||
# 2026-08-04 工作日志
|
||||
|
||||
## 修复(治本):勋章图形精确居中 + 领奖台对齐(第二轮)
|
||||
- 用户反馈第一轮 CSS 微调后勋章"还是偏上"。深挖发现真因:**7 枚 Phosphor 勋章图形在 24 viewBox 内垂直重心各不相同**(medalMilitary/shieldStar 偏下 1.13、trophyPh 偏下 0.75、crownPh 偏上 1.13),CSS margin 只能整体挪 `<image>` 盒子,永远修不齐不同勋章。
|
||||
- **关键坑**:自写 SVG path 解析器易错(`V/H` 单参数命令打乱坐标配对、`a` 圆弧终点),包围盒必须用可靠库 `svgpath`(npm 装到 managed workspace):`abs() → unarc() → unshort() → iterate()` 收集控制点算包围盒。
|
||||
- 治本方案:7 枚勋章 path 全部按图形包围盒**等比缩放到 24×0.92 并平移到 (12,12) 精确居中**(svgpath scale+translate,round(4))。`unarc` 把弧线展开为贝塞尔,path 变长(medal 344→1153 字符)但形状无损,7 枚共约 +9KB 可忽略。
|
||||
- 同时 CSS:`.podium-badge` margin-top 2rpx→6rpx(flex 行盒中心与中文 glyph 视觉重心偏差补偿,图形已居中所以只需一次全局补偿)。
|
||||
- 验证:7 枚新 path 包围盒中心全部 (12.00,12.00);node --check 通过;build() 7 枚 Fill/White 变体齐全;show_widget 渲染 3 枚代表勋章与昵称齐平。
|
||||
- 后续若用户仍觉偏移,只调 `.podium-badge` margin-top 一个数即可(图形差异已清零)。
|
||||
|
||||
## 修复:领奖台勋章与昵称对齐错位
|
||||
- 用户反馈领奖台(podium)勋章图标与昵称上下错位。
|
||||
- 根因:flex align-items:center 对齐的是行盒中心,而 `.podium-name` line-height 1.3 使行盒(33.8rpx)远大于字高(26rpx),叠加中文 glyph 视觉重心偏高,图标视觉上比文字低。
|
||||
- 修复(leaderboard.wxss,仅领奖台样式,`.podium-name` 无他处引用):①line-height 1.3→1(行盒贴字高);②`.podium-dur` margin-top 2→10rpx(补偿行盒缩短 ~8rpx,保持与名字间距);③`.podium-badge` 加 margin-top:2rpx(图标下移抵消中文视觉重心偏上)。
|
||||
- 教训:icon+text 横排对齐,text 必须 line-height:1(或行盒≈图标高度),并视字体微调图标偏移;别依赖 line-height 冗余空间做"视觉居中"。
|
||||
|
||||
## 代码问题分析(已核实修订)
|
||||
- 对平板支撑小程序做全量代码审查,初版报告 8 条问题经逐条事实核实后修订:撤回 3 条(隐私协议缺失/onLaunch await 阻塞启动/组件 styleIsolation 不一致,均为判断错误)、降级 2 条(console.log/云环境ID)、保留 3 条可选优化(lazyCodeLoading/测试覆盖/月份翻页上限)。
|
||||
- 报告写入 docs/问题分析_2026-08-04.md。
|
||||
- 教训记入 MEMORY.md:判断隐私合规先看 MP 后台而非代码;onLaunch async 不阻塞首屏;审查勿把合理设计/优化建议拔高成风险。
|
||||
|
||||
## 修复:记录页月份翻页上限
|
||||
- pages/records/records.js 的 onNextMonth 原本可无限往后翻到未来月份。
|
||||
- 加上限判断:下一月超过当前真实年月时提示"已经是最新月份"并 return,不翻页。
|
||||
- node --check 通过。
|
||||
|
||||
## 第三次整体审查(用户再次询问"有没有问题")
|
||||
- 又通读 custom-tab-bar、trend-chart、records.wxml 等此前未细看模块,git 历史确认项目持续迭代、质量高。
|
||||
- 结论:无功能性 bug、无合规阻断、无需要紧急修复项。
|
||||
- 如实记录的少数"设计权衡/边界"(非 bug,方向均偏乐观):
|
||||
1. 首页"今日已完成"用 getTodayRecord 的全天累计时长(storage.js:451),自由训练/循环训练 duration 也计入今日目标达成判断 → 偏乐观。
|
||||
2. 云同步是全量整包 update(最后写入胜),无字段级冲突合并;mergeRecords 仅在本地合并,多设备同时改边缘可能覆盖(单设备无影响)。
|
||||
3. 卡路里 0.068 系数仅 onDayTap 一处,无一致性冲突。
|
||||
|
||||
## 优化:卡路里系数抽到 config.js
|
||||
- config.js 新增 `caloriesPerSecond: 0.068` 配置项(注释说明估算口径),records.js onDayTap 改引用 `config.caloriesPerSecond`(带 `|| 0.068` 兜底)。
|
||||
- 语法 node --check 通过。
|
||||
|
||||
## 新功能:完成弹窗科学提示(价值+风险)
|
||||
- 用户要求把平板支撑科学建议加入完成成绩弹窗。先做了科学核查:原表"磷酸原/糖酵解按时长切换"是错误说法,方案改用修正口径——只讲价值/风险,不提供能系统。
|
||||
- 实现:config.js 新增 `scienceTips`(4 段按时长 maxSeconds:120/300/480/Infinity,每段 value+risk);timer.js 新增 `_pickScienceTip(elapsed)` + data 字段 completionTipValue/Risk + _saveAndShowCompletion 里 setData;timer.wxml 连胜行后加 completion-tip 卡片(价值行 + 弱化「小贴士」风险行);timer.wxss 加样式(rgba primary 0.08 底、20rpx 圆角、tipIn 动画延迟 1.0s)。
|
||||
- 边界验证:120s 整入段2、480s 整入段4、负/0 秒兜底段1;缺配置时 wx:if 不渲染,零风险。
|
||||
- 循环训练(circuit)复用同一路径,workTotal 天然适用。
|
||||
|
||||
## 调整:删除完成弹窗「本次训练」标签;星级方案放弃
|
||||
- 用户提出删除 completion-label「本次训练」+ 给提示卡加推荐指数星级。星级算法有产品理念矛盾(按时长给星违背科学共识/打脸耐力榜,按科学区间破纪录用户会低星),用户决策放弃星级。
|
||||
- 实施:timer.wxml 删 `completion-label` 节点;timer.wxss 删对应样式,把 24rpx 间距并入 `.completion-number-wrap` 的 margin-bottom(8→32rpx)保持呼吸感。grep 确认无残留。
|
||||
|
||||
## 试点:勋章图标换成 Phosphor medal(30天「神登」)
|
||||
- 对比 6 个图标库(Phosphor/Tabler/MDI/Ionicons/Lucide/FA),推荐 Phosphor。用户要求先做一枚进记录页。
|
||||
- **关键坑**:Phosphor 是 256 viewBox,项目 `_svg` 硬编码 24 viewBox,直接塞会裁切。需 path 缩放——但**不能无脑除数字**:`a` 命令的 large-arc-flag/sweep-flag 只能 0/1、x-axis-rotation 是角度,都不该缩放。写了命令感知缩放器(token 流解析,a 命令 idx%7===2/3/4 不缩放),渲染对比验证无变形。
|
||||
- 改动:icons.js PATHS 加 `medal`(缩放后 path)+ `out.medalWhite`;config.js 30天 badge icon 改 `medal`/`medalWhite`。trophy 系列保留(完成弹窗/排行榜空态仍用)。
|
||||
- 验证:node --check 通过、build() 生成灰版(#999999)与白版(#FFFFFF)正确。
|
||||
|
||||
## 勋章图标全套替换为 Phosphor 勋章系(完成)
|
||||
- 7 级递进:sealCheck(3天 抖登) → medal(7天 中登) → medalMilitary(14天 老登) → trophyPh(30天 神登) → certificate(60天 上古神登) → shieldStar(80天 洪荒神登) → crownPh(100天 究极登祖)。
|
||||
- 全部 256→24 坐标缩放(用命令感知缩放器,a 命令 large-arc/sweep/rotation 不缩放)。**命名避坑**:Phosphor 版 trophy/crown 带 Ph 后缀(trophyPh/crownPh),避免覆盖 Material 版 trophy/crown(完成弹窗/排行榜仍在用)。
|
||||
- 改动:icons.js PATHS 加 6 枚 + 6 个 White 变体(medal 已加);config.js trainingDayBadges 7 行全改。
|
||||
- 验证:语法通过、7 枚灰版+白版 build 全部生成 OK、旧图标引用确认未误删。
|
||||
- 缩放脚本在 /tmp/scale-phosphor.js(一次性工具,未入库)。
|
||||
|
||||
## 个人资料昵称旁显示已解锁勋章
|
||||
- 需求:settings 页个人资料昵称后面展示已获得的勋章。
|
||||
- **图标色坑**:badge 图标现有变体只有灰(未解锁)/白(解锁坐主题色圆盘),设置页浅底白图标会隐形。方案:icons.js 在 PATHS 定义后加 `PATHS.xxxFill = PATHS.xxx`(对象属性引用同一 path),build 循环自动生成主题色版 out.xxxFill,颜色跟随当前主题。
|
||||
- 实现:settings.js data 加 `unlockedBadges: []`,onShow 里 `_getUnlockedBadges()`(totalDays >= b.days 过滤 + iconFill = icon+'Fill')setData;settings.wxml 昵称包 profile-name-row(flex),右侧 wx:for 渲染勋章组;settings.wxss 加 profile-name-row/profile-badges/profile-badge(32rpx,昵称可收缩省略、勋章组 flex-shrink:0)。
|
||||
- 验证:语法通过、7 枚主题色版 build 生成 OK(跟随主题色)。
|
||||
|
||||
## 榜单页显示最新勋章
|
||||
- 需求:leaderboard 页"我"的位置显示最新解锁勋章(点击 toast 显示勋章名+天数门槛)。用户确认:进榜 is-me 行 + 未进榜 my-bar 两处都显示 + 点击提示。
|
||||
- **图标色分场景**:is-me 行浅底(primary 12%)用主题色 `xxxFill`;my-bar 主题渐变底用白版 `xxxWhite`——正好复用现有两种变体,零新增。
|
||||
- 实现:leaderboard.js data 加 `myLatestBadge: null`,onShow 同步 `_getLatestBadge()`(本地 totalDays 取 days<=totalDays 最后一枚,返回 days/name/iconFill/iconWhite;<3 天或异常返回 null,且 days 未变不重复 setData),加 `onBadgeTap`(dataset 传 name/days);wxml 两处 rank-name 包 rank-name-row + 勋章 image(is-me 行 iconFill、my-bar iconWhite,wx:if myLatestBadge);wxss 加 rank-name-row/rank-badge(30rpx)。
|
||||
- 数据纯本地,零云函数改动(勋章是"我的"荣誉,别人看不到)。
|
||||
- 验证:语法通过;边界 0/2→null、3→3天、29→14天、85→80天、500→100天封顶 全对;7 枚 Fill/White 变体全存在;wxml/js 引用一致。
|
||||
|
||||
## 排查「17次不显示勋章」+ 修复本地兜底 isMe bug
|
||||
- 用户反馈某用户 17 次训练不显示勋章。核实后根因:①用户看的是**线上旧版**(勋章功能今天刚加,未发版);②看的是**别人的行**(勋章只在"我"的行显示,设计如此)。
|
||||
- 顺带发现真实 bug:`_applyLocal`(云函数不可用的本地兜底)`rankedList: [entry]` 的 entry **缺 isMe 标记** → is-me 行不渲染勋章,且 my-bar 条件(rank 1 > length 1)为 false → 弱网/云函数故障时勋章完全不可见。
|
||||
- 修复:leaderboard.js 两处本地兜底 entry 补 `isMe: true`(耐力榜 + 日/月/年榜)。node --check 通过。
|
||||
|
||||
## 榜单勋章全榜可见(云函数 + 客户端联动)
|
||||
- 需求:用户希望榜单上**所有用户**都能看到彼此的勋章(之前只有"我"本地算的勋章)。
|
||||
- **云函数** `cloudfunctions/leaderboard/index.js`:`_buildFromScan` 每个 doc 开头算 `totalDays`(遍历全部 records 去重日期,与榜单周期无关),endurance/day/month/year 四个分支的 entry、ranked、myEntry 输出都带 `totalDays`。快照经 `publicEntry`(rest 解构只剥 memberKey/openid)自动透传,无需改 data.js。
|
||||
- **客户端** `pages/leaderboard/leaderboard.js`:删掉 myLatestBadge(data/onShow/_getLatestBadge),改为通用 `_badgeOf(totalDays)`;`_applyResult` 给每个 ranked item 映射 `badgeIcon(Fill)/badgeName/badgeDays`(浅底行用),myEntry 额外 `badgeIconWhite`(my-bar 渐变底用);`_applyLocal` 开头算 localBadge 字段,两处 entry 展开进 badge 字段。
|
||||
- **wxml**:rank-item 每行勋章 `wx:if item.badgeIcon`(不限 isMe,所有人显示)+ data-name/days;my-bar 用 `myEntry.badgeIconWhite`。
|
||||
- 兼容:旧快照无 totalDays → _badgeOf(undefined)→null → 不渲染,等新快照(4min 定时重建)。**必须重新部署 leaderboard 云函数**。
|
||||
- 验证:语法 OK、跨月去重正确、badge 映射(2天不渲染/14天老登/100天皇冠)OK、myLatestBadge 无残留。
|
||||
|
||||
## 修复:榜单勋章 500 报错(badgeIcon 赋了字符串 key 而非 data URI)
|
||||
- 用户反馈:排行榜不显示勋章,控制台 `Failed to load local image resource /pages/leaderboard/medalMilitaryFill (500)`。
|
||||
- **根因**:重构 myLatestBadge → `item.badgeIcon` 时,把 `badge.icon + 'Fill'`(字符串 key)直接赋给了 badgeIcon;WXML 直接 `src="{{item.badgeIcon}}"` 期望 data URI,字符串被当成路径加载 → 500。旧实现是 `icons[myLatestBadge.iconFill]` 先查映射再取 URI,重构时漏了这层查询。
|
||||
- 修复:leaderboard.js 三处(_applyResult 的 list 映射、myEntry 映射、_applyLocal 的 localBadgeFields)都改为 `(this.data.icons || iconsMod.build())[badge.icon + 'Fill']` 取真实 data URI。
|
||||
- 验证:语法 OK、badgeIcon 实测输出 `data:image/svg+xml;charset=utf-8,...`。
|
||||
- 教训:WXML `<image src>` 直接绑定 data 字段时,字段必须已是 data URI,不能是图标 key 字符串。
|
||||
|
||||
## 补漏:领奖台前三名显示勋章
|
||||
- 用户反馈领奖台前 3 名没勋章。原因:领奖台是独立 podium-slot 区(rankedList[0/1/2]),上一轮只改了普通 rank-item 行。
|
||||
- 数据其实已就绪(_applyResult 对全部 ranked item 含前 3 名映射了 badgeIcon data URI)。
|
||||
- 改动:leaderboard.wxml 3 个 podium-slot 的 podium-name 包成 podium-name-row(flex 居中),右侧加 podium-badge(第 1 名用 podium-badge--first 34rpx,2/3 名 30rpx),带 onBadgeTap;leaderboard.wxss 加 row/badge 样式。
|
||||
- 验证:3 处渲染点齐全、语法 OK。
|
||||
|
||||
## 优化:虚拟领奖台(不足 3 名也显示)
|
||||
- 用户要求:榜单数据不足 3 名时,不要只显示列表,也要显示(虚拟)领奖台。
|
||||
- 改动(纯前端,无云函数改动):
|
||||
- leaderboard.wxml 领奖台 `wx:if` 由 `rankedList.length >= 3` 改为 `>= 1`;3 个 podium-slot 各自用 `<block wx:if="{{rankedList[i]}}">` 真实内容 / `<block wx:else>` 虚拟空位(虚线轮廓头像 + 灰化 peopleFill 图标 + 底座「虚位以待」)。
|
||||
- 下方列表 `wx:if` 由 `rankedList.length < 3 || index >= 3` 改为 `index >= 3`:领奖台出现时前 3 名不再在列表重复出现(<3 名时列表直接为空,名次全在领奖台)。
|
||||
- leaderboard.wxss 加 `.podium-avatar--empty`(虚线圆、淡底)、`.podium-avatar__img--empty`(灰化 0.26 透明)、`.podium-empty-label`、`.podium-crown--empty`(0.3 透明)。
|
||||
- 边界:0 名仍走 empty 空态(「暂无排行数据」);1 名→领奖台只填第 1 名,2/3 空位;2 名→填 1/2 名;≥3 名同原逻辑。语法 OK、无残留旧条件引用。
|
||||
|
||||
## 微调:领奖台勋章与昵称对齐(反复下压.margin-top)
|
||||
- 用户反馈前三名昵称后勋章持续「偏高」。本质是 flex 居中 + 中文字形视觉重心(字体度量 descent)的固有偏差,只能靠 margin-top 盲推。
|
||||
- 轨迹:.podium-badge margin-top 6rpx→8rpx(看截图仍高)→11rpx(用户说偏高)→14rpx。每次 +3rpx。
|
||||
- 单一可调数字。若 14rpx 仍差,改结构性修法:勋章缩到与昵称同高(30→26rpx)或 .podium-name 加 display:inline-block 重约束行盒,从根消除偏移,不再靠 margin。
|
||||
- 纯样式无语法风险。
|
||||
|
||||
## 升级:领奖台勋章对齐改为结构性修法(而非 margin 盲推)
|
||||
- 用户反馈 margin-top 推到 14rpx 仍偏高,确认盲推失效。深挖两个隐藏根因:
|
||||
1. `.podium-name` 自带 `margin-top:30rpx` 且在 flex **cross 轴**上,等于把昵称整体下推,反而让勋章更"偏上"——所以一路加 badge margin-top 永远追不平。
|
||||
2. `.podium-name` `line-height:1`(26rpx 盒)比勋章(30rpx)矮,`align-items:center` 对齐的是"26rpx 行盒中心"与"30rpx 图标盒中心",叠加中文 glyph 视觉重心偏下,图标自然飘上。
|
||||
- 结构性修法(leaderboard.wxss):① `.podium-name` `line-height:1`→`30rpx`(与勋章等高盒);② `.podium-name--first` 加 `line-height:34rpx`(与首名 34rpx 勋章等高);③ 间距 `margin-top:30rpx` 从 `.podium-name` **移到 `.podium-name-row`**(避免 cross 轴 margin 干扰居中);④ **删除 `.podium-badge` 的 `margin-top`** 盲推。
|
||||
- 结果:flex `align-items:center` 现在对齐的是两个等高盒的中心,图标与昵称视觉精确对齐,不再依赖任意偏移量。若个别冠类勋章因图形重心仍有 ±1~2rpx 残差,单独加 `transform: translateY(2rpx)` 即可。
|
||||
- 纯样式、无语法风险、无残留旧 margin 引用。
|
||||
|
||||
## 检查并统一:成就勋章图标跨页尺寸
|
||||
- 用户对齐 OK 后,顺带查勋章大小一致性。结论:
|
||||
1. 图标本体视觉大小一致——7 枚勋章 SVG 早已统一缩放居中到 24×0.92 viewBox,同尺寸盒内可见主体等大,无"crown 比 medal 大"问题。
|
||||
2. 非预期不一致在跨页盒子尺寸:排行榜(领奖台 2/3 名 .podium-badge 30、列表 .rank-badge 30)用 30rpx;设置页 .profile-badge 与记录页 .badge-node-icon 用 32rpx。
|
||||
- 修复:把 settings.wxss `.profile-badge` 与 records.wxss `.badge-node-icon` 的 32rpx→30rpx,统一到基准 30rpx(且与领奖台昵称对齐 line-height:30rpx 配套)。领奖台首名 .podium-badge--first 34rpx 作为冠军强调保留(设计性,非 bug)。
|
||||
- 若后续想连冠军也统一 30rpx,改 .podium-badge--first 为 30 且 .podium-name--first line-height 同步回 30rpx 即可。
|
||||
|
||||
## 调整:领奖台前三名勋章统一大小
|
||||
- 用户要求前三名勋章一致大小(此前首名 34rpx 强调、2/3 名 30rpx)。
|
||||
- 改动:leaderboard.wxss `.podium-badge--first` 34→30rpx;配套 `.podium-name--first` line-height 34→30rpx(保持首名昵称与勋章等高盒对齐)。
|
||||
- 结果:领奖台前三名勋章统一 30rpx,与全局基准(列表 .rank-badge 30、设置页 .profile-badge 30、记录页 .badge-node-icon 30)完全一致。纯样式无语法风险。
|
||||
|
||||
## 提交与推送
|
||||
- commit `b52db87`(17 文件,+690/-67)包含今天整轮(虚拟领奖台+勋章对齐结构修复+全 app 勋章 30rpx 统一)。
|
||||
- 已推 `origin`(github.com/cnliucheng/wx_pbzc) + 新增远程 `soao`(https://git.soao.net/lc/wx_pbzc.git) 并推 `main`。两个远程同步于 b52db87。
|
||||
- 提醒:cloudfunctions/leaderboard 若含线上逻辑改动需开发者工具「上传并部署」才生效(本次主要改前端)。
|
||||
|
||||
## 上线前审查(launch readiness)
|
||||
- 主包 540KB(<<2MB,无需分包);网络全走 wx.cloud(免 request 域名白名单);无硬编码 http;隐私接口仅 chooseAvatar(后台已配)。**合规面干净**。
|
||||
- 🔴 阻断项:①云函数 git 提交≠部署——`leaderboard`(本次 b52db87 改 17 行 totalDays/全榜勋章)、`tts`(8-03 改)需重新部署;②`tts` 环境变量 TTS_SECRET_ID/KEY/REGION 须在云函数控制台配好(否则语音失败)。
|
||||
- 🟡 建议项:清理调试 console.log(cloud.js 14 处等)、project.config.json 上线开关(minified→true/uploadWithSourceMap→false)、加 wx.onNeedPrivacyAuthorize 自定义隐私弹窗、加全局错误上报、清理残留(cloudfunctions/shared 空死目录/根 preview-buttons.html/.claude/.codegraph)。
|
||||
- 核实:leaderboard 实际 require('./data') 非 '../shared',空 shared 目录无害;`data.js`/`cache.js` 同目录存在,部署含之;无分包;无 wx.onError。
|
||||
- 报告:.workbuddy/launch-readiness-review.md。
|
||||
|
||||
## 执行:上线前建议项清理(用户确认"需要")
|
||||
- 清调试 console.*:脚本 .workbuddy/clean-console.js 批量删(独立 console 行 + 箭头函数体 `=> console`→`=> {}`),删 34 处;漏 2 处 `if(){console.log();return}` 行内形式手动改;Grep 复扫全项目 *.js 已无 console.* 残留。涉及 cloud.js/storage.js/voice.js/theme.js/app.js/leaderboard.js/timer.js/settings.js/cloudfunctions/leaderboard/index.js,均 node --check 通过。
|
||||
- project.config.json:minified false→true、uploadWithSourceMap true→false(上线防源码泄露+减小包体)。
|
||||
- 清残留:删 preview-buttons.html + rmdir cloudfunctions/shared(空死目录,重构遗留);.claude/.codegraph 已被 packOptions.ignore 的 `.!*` 忽略,不动。
|
||||
- 未做:全局错误上报(wx.onError)、自定义隐私弹窗(用户仅确认做这三个清理项)。
|
||||
- 改动未提交(git status 显示 M/D 文件)。clean-console.js 为一性工具,留 .workbuddy 不进包。
|
||||
|
||||
## 上线前优化续:组件按需注入 + records 补声明 + 提交推送
|
||||
- 微信开发者工具「代码质量」扫描显示「组件:启用组件按需注入 — 未通过」。
|
||||
- 根因:project.config.json 缺 `lazyCodeLoading` 配置项。已加 `"lazyCodeLoading": "requiredComponents"`(只加载页面 usingComponents 声明且模板实际用到的组件)。
|
||||
- 核实:7 组件均静态写死在 WXML,无动态 `<component is=`/抽象节点/custom-tab-bar 不受影响 → 无负面影响;基础库 <2.10.1 用户自动回退全量加载(无害)。
|
||||
- 顺带发现真 bug:pages/records/records.json 漏声明 ui-skeleton(WXML 第 4/6 行已用)→ 已补;5 页面声明与 WXML 实际使用现已完全一致。
|
||||
- 提交:commit `187c426`(14 文件,+100/-199,含之前的 console 清理+project.config 开关+删 preview-buttons.html+lazyCodeLoading+records 补声明)。clean-console.js 一次性工具未入库。
|
||||
- 推送:soao(https://git.soao.net/lc/wx_pbzc.git)成功(b52db87..187c426);**origin(github)当前环境到 github.com:443 SSL 连接被重置(HTTP2 framing/SSL_ERROR_SYSCALL 交替),多次重试失败——环境网络抖动,非代码问题**。本地 commit 安全,待网络恢复后重试 `git push origin main`。
|
||||
|
||||
## 功能:个人最佳趋势改为折线图 + 底部加最佳趋势产生日期
|
||||
- 用户要求:记录页「个人最佳趋势」(bestTrendData)从柱状图改折线图,并在底部加「最佳趋势产生日期」(像「最高训练日」那样)。
|
||||
- 实现:趋势组件 `trend-chart` 加 `chartType`(bar|line,默认 bar,柱状图完全不动)+ `lineColor` 属性。line 模式用**内联 SVG(data URI)→`<image>`** 渲染折线+半透明面积(无 canvas,和组件 no-canvas 理念一致);圆点与横轴标签用 WXML 绝对定位(按 xPercent/yPercent),保证清晰且能吃主题色(因 SVG 在 image 内读不到 CSS 变量,颜色由页面传 hex)。
|
||||
- 几何:viewBox=640×H(H=peak?170:200),`mode=widthFix` 让 SVG 宽高比与容器 rpx 一致→stroke 均匀不拉伸;x 按 (i+0.5)*640/N 均分,y 用 topPad/bottomPad 留头尾余量;零值点落到接近底部。
|
||||
- 数据层不动(getBestTrendData 仍返回 label=周一/7月 等周期标签,符合"就像最高训练日"的粒度)。
|
||||
- records 页:best 趋势 `<trend-chart chart-type="line" line-color="{{bestLineColor}}">`;原在 header 的 `best-trend-best` 文本移到图表下方,复用 `.trend-highlight` 对称块(icons.crownFill + bestTrendSummary.peakTitle + peakText);新增 `_buildBestSummary` 把 peakTitle 覆盖为「个人最佳日/月」(通用 getTrendSummary 原给"最高训练日",不对);bestLineColor=theme.primary(peak 实际用主题主色,非金)。
|
||||
- 验证:node --check 通过;tests/trend-state.test.js 5/5 通过(数据层未变);SVG 字符串模拟确认无 NaN、含 stroke/fill-opacity/width/height。删了 records.wxss 里已不用的 .best-trend-best 样式。
|
||||
- 改动未提交(git status 有 M)。待用户审阅后可提交(soao 国内远程稳定;origin 仍待网络恢复推)。
|
||||
|
||||
## BUG 修复:折线图运行时崩溃(toFixed of undefined)
|
||||
- 现象:记录页切换 trend 模式报 `Cannot read property 'toFixed' of undefined`(trend-chart.js:90, _buildLineSvg 内 `p.y.toFixed`)。
|
||||
- 根因:`_compute` 生成 linePoints 时只存了 `x`(绝对)与 `yPercent`(百分比),**漏存绝对坐标 `y`**;`_buildLineSvg` 画 polyline/area 用的是 viewBox 绝对坐标 `p.x`/`p.y`,`p.y` 为 undefined → 崩。WXML 圆点/标签用 xPercent/yPercent 定位(无误)。
|
||||
- 修复:linePoints 返回对象补 `y`(与 x 同为 viewBox 坐标);`_buildLineSvg` 加防御 `valid = points.filter(p => p && typeof p.x==='number' && typeof p.y==='number')`,first/last/area/polyline 全改走 `valid`,空则返 ''。
|
||||
- 验证:node --check 通过;用真实 getBestTrendData 输出实跑 _compute(week/month)均生成 lineSvg 不再崩;稀疏数组也安全。临时 repro 脚本已删。改动未提交。
|
||||
|
||||
## BUG 修复:折线图数据点未显示数值
|
||||
- 现象:折线模式每个数据点旁没有数值文字(柱状图有,折线没有)。
|
||||
- 根因:line 模式 WXML 只渲染了圆点(`trend-line-dot`)与横轴 label,**漏渲染 valueText**;且 linePoints 返回对象未透传 `value` 字段,无法做 `value>0` 判断。
|
||||
- 修复:trend-chart.js 的 linePoints 返回补 `value: b.value`(bars 项第57行本就有 value);WXML 圆点内加 `<text wx:if="{{item.value>0}}" class="trend-line-value">{{item.valueText}}</text>`(仿柱状图,0 值不显示);WXSS 加 `.trend-line-value`(绝对定位在 dot 上方居中,16rpx,高亮点用主色加粗),并把 `.trend-chart.line` 的 padding-top 16rpx→30rpx 给最高点数值留缓冲防裁切。
|
||||
- 验证:node --check 通过;bars.value 字段确认存在。改动未提交。
|
||||
@@ -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/wxss(contentMode 增 'milestone');config 增 milestoneFirsts。全部从现有 training_records 推导,**零存储/云函数改动**。
|
||||
**图标:** 节点用 *White 变体(playWhite/boltWhite/formWhite/各 badgeWhite 等);timeWhite、repeatWhite 实现时各加一行或复用现有白图标。注意组件 style isolation 陷阱——图标色烤死在 SVG,必须用 *White。
|
||||
**状态:用户选择「再调整一下」,暂未批准写代码。** 下一步:与用户敲定文案/门槛/配色后实现。
|
||||
@@ -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()`。用户尚未批准改代码。
|
||||
@@ -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.wxml(greeting-row 结构)、index.wxss(横向 flex 布局 + 头像样式)、index.js(data 加 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` 的 bounds(hold 下限5 / rest 下限0)无需改,越界自动 clamp。
|
||||
|
||||
## 首页 index 整体改版(参考 plank-training.html,方案 B 橙色系)
|
||||
- 结构重构(纯视觉/布局,不动训练/记录/设置逻辑):全宽橙渐变 `hero`(头像+昵称+按小时时间问候+「时长」敢不敢来挑战+🔥真实连胜)→ `quest-card` 进度点地图(白卡负 margin 上浮叠 hero,圆点+连线,done/now/final 三态,末节点🏆)→ `dial` 虚线刻度环+呼吸动画(目标时长+预计千卡)→ CTA「立即挑战+时长」带脉冲 → 次级入口自由/循环。
|
||||
- 取消每日语录模块(用户要求)。
|
||||
- 配色:方案 B,hero 渐变用 `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.wxml:progress-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/3:520/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 900(base64 内联)
|
||||
- 用户认为 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 通过。未提交。
|
||||
@@ -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.20;box-shadow 外层 40rpx/0.45 → 30rpx/0.22、内层 28rpx/0.22 → 20rpx/0.10;auraBreathe 透明度 0.55↔0.85 → 0.30↔0.50,fast 版 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% 淡底、无边框无阴影、圆角 999rpx;padding 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 图标 + goalLine,26rpx/500/主色(静态摘要,常驻)
|
||||
- 分隔线 `info-divider`(1rpx 主色 14% 淡线,仅提示出现时渲染)
|
||||
- 下 `info-line--tip`:tipIcon + runningTip,30rpx/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.js,size=420 时验证):
|
||||
- 根因:间距 = gap − arcW/2 = 3−4 = **−1dpr(重叠 1)**。因为分段弧 arcR 仅比进度弧外缘靠外 3dpr,而弧半宽 4dpr。
|
||||
- `_draw()` 的 `glowPad` 6→14dpr(进度弧半径内缩 8,给外圈腾空间);`_drawSetArcs()` 的 radius pad 6→14(**两处必须同步**,已加注释)且 arcR 间距 `+3→+9dpr`。结果:进度弧外缘 204→196、分段弧内缘 203→201,间距 −1→**+5dpr**。副作用:单组/自由模式的进度环也内缩 ~8dpr(约 4%),中心数字区略小;进度弧尾端发光点 shadowBlur=12 阴影反而从"被 canvas 裁"变完整。
|
||||
- 段间空隙 gap=2.5° 是段间距,**与环间距无关,不动**。
|
||||
- **光点置顶**:原实现光点在逐段循环内、每段弧 stroke 之后画,理论上应在弧上,但视觉上被 round cap 相邻段边缘"吞掉"。重构为**两遍绘制**:pass1 只画所有弧并收集 emphasized 段光点(x/y/color),pass2 统一在所有弧之上画光点。同时光点加大加亮:dotR 从 `0.85~1.2×弧半宽` → `1.15~1.4×`,alpha `0.55~1.0` → `0.7~1.0`,白芯 0.85→0.9,读起来像"珠珠骑在弧上"。
|
||||
- 光点仍居段中点(沿用上轮两端对称修复);外缘 209dpr 不超 canvas 半宽 210,与 breath-aura(内缘 ~232)无重叠。
|
||||
- 验证:node --check 通过;数值全部核算。未提交 git。
|
||||
- **间距仍不够(用户反馈"还是太近")→ 再翻倍**:canvas 半宽 210 是硬上限,剩余空间仅 ~5dpr,要明显拉开只能继续内缩进度弧。glowPad 14→18、arcR 外移量 9→14,间距 **5→10dpr**(进度弧外缘 196→192,分段弧内缘 201→202,光点外缘 211.6 超画布 0.8px 亚像素无感)。进度环直径累计缩 ~6%(408→384)。约束备忘:**间距 = arcR外移量 − arcW/2,且 ≤ glowPad − arcW − margin**;想再拉开只能继续缩环或减弧宽。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 2026-08-14 工作日志
|
||||
|
||||
## 修复:设置页切换暗黑模式后其他 tab 的 tabBar 不跟随变暗
|
||||
- **现象**:设置页选暗黑模式后,只有设置 tab 的底栏变暗,切到训练/记录/排行 tab 底栏仍是白色,完全退出重进才正常。
|
||||
- **根因**:自定义 tabBar 组件的 `pageLifetimes.show()` 对自定义 tabBar **不触发**(微信框架特殊行为——自定义 tabBar 由框架管理,不走普通组件的页面生命周期)。各 tab 页 `onShow` 里只做了 `tb.setData({selected:X})` 设高亮,没有同步主题;`onSelectDarkMode` 里 `getCurrentPages()` 只返回当前 tab 页(其他 tab 不在页面栈里),所以 `tb.updateTheme()` 只刷新了设置页的 tabBar 实例。
|
||||
- **修复**:4 个 tab 页 `onShow` 里获取 tabBar 后,在 `setData({selected:X})` 之后加一行 `tb.updateTheme()`。`updateTheme()` 内部调 `applyThemeToPage(this)`(有"样式未变则跳过"的优化),不会产生多余渲染。涉及 `pages/index/index.js`、`pages/records/records.js`、`pages/leaderboard/leaderboard.js`、`pages/settings/settings.js`。
|
||||
- **教训**:自定义 tabBar 不能依赖 `pageLifetimes` 做状态同步,必须在各 tab 页 `onShow` 里显式调 `this.getTabBar()` 更新。
|
||||
|
||||
## 修复:循环训练偶数组数休息语音被「一半」提示吞掉
|
||||
- **现象**:循环训练设 2 组时,第 1 组练完进入休息,本该播 `restStart`「休息一下」,实际播的是 `halfway`「已完成一半啦」,休息语音不响。
|
||||
- **根因**:偶数 set 数时 `sets×hold/2` 正好落在某组练完→休息切换的那一 tick。`_circuitCue` 先播 halfway 并设 `_voiceBusyUntil=now+2600` 占麦;紧接着 `_advance()`→`_circuitPhase('resting')` 检查麦占用,`restStart` 被静音。原代码注释有意让"进度提示优先于阶段提示",但偶数组数下两者撞车。
|
||||
- **修复**:`_circuitCue` 里先算 `atRestBoundary`(phaseRemaining<=0 && rest>0 && setIndex<sets) 与 `restWillSpeak`(hold>=10 && rest>=5);halfway/last30/last10 在"落在休息边界且 restStart 会播"时跳过自身,让休息语音优先。奇数组数一半点在中段不会撞车,不动;短组配置 rest 不播也保持原 halfway 行为,无回归。改 `pages/timer/timer.js` 的 `_circuitCue`。
|
||||
- 顺带:last30/last10 在更长偶数组数(如 4 组×30s 的 set3→rest)也会撞同一问题,一并按同规则让位,行为一致。
|
||||
|
||||
## 新增:训练完成分享海报(canvas 绘制 + 小程序码)
|
||||
- **现象**:训练完成后点分享,微信默认截取当前页面,因完成弹窗垂直居中,卡片只截到上半部分(绿色星星+"完成!"),大数字和按钮被切掉。
|
||||
- **方案**:用隐藏 canvas 2d 绘制一张 5:4(500×400 逻辑像素)的专用分享海报,在 `onShareAppMessage` 里作为 `imageUrl` 返回;提前在 `_saveAndShowCompletion` 弹窗数据就绪后生成,避免分享时现场绘制延迟。
|
||||
- **海报内容(初版)**:浅灰底 + 顶部品牌 + 白色圆角成绩卡 + 绿色渐变星星图标 + 状态文案 + 大数字时长 + 连续打卡(如有)+ 底部"扫码一起打卡"引导 + 右下角小程序二维码。
|
||||
- **文件改动**:
|
||||
- `pages/timer/timer.wxml`:添加隐藏 `<canvas type="2d" id="sharePoster">`
|
||||
- `pages/timer/timer.wxss`:`.share-poster-canvas` 移出视口、不响应点击
|
||||
- `pages/timer/timer.js`:新增 `_drawSharePoster()`、`_formatShareDuration()`;`_saveAndShowCompletion()` 里 setData 后预生成;`onShareAppMessage()` 返回 `imageUrl: this._shareImageUrl`;`onTrainAgain()` 清空旧图防止复用
|
||||
- `static/share-qr.jpg`:加入用户提供的小程序二维码资源
|
||||
- **注意**:导出用 jpg quality 0.9、dest 500×400,控制文件在 128KB 以内;二维码路径 `/static/share-qr.jpg` 为相对小程序根目录;若二维码加载失败仍导出无二维码的海报,保证分享入口不灰。
|
||||
|
||||
## 调整:分享海报(实测反馈)
|
||||
- **反馈**:① 大数字(分钟数)压住了上方"完成"状态文案;② 顶部小程序名称多余;③ 要删二维码。
|
||||
- **根因**:初版状态文案 `y=172`(字号22)、大数字基线 `y=230` 且字号最大 90px,数字顶部≈165 落入状态文案区域(150~172),多位数分钟(如纯分钟 "6"→90px)直接压字。
|
||||
- **修复**(仅改 `_drawSharePoster` 布局):删除顶部"平板的支撑"品牌名;删除右下角二维码及依赖它的"扫码一起打卡"引导(连带 `static/share-qr.jpg` 也删,不再引用);重排垂直节奏——卡片放大(42~358)、图标 `circleY=104`、状态文案 `y=184`(24px)、大数字基线 `y=286`(上限84px,顶部≈226,与状态文案下沿 192 间距 34 安全)、连续打卡 `y=332`;导出改为绘制完直接 `canvasToTempFilePath`,无需等图片加载。
|
||||
- **结论**:海报现为"成绩卡 + 图标 + 状态 + 大数字 + 连续打卡"的极简版,无品牌名无二维码。
|
||||
|
||||
## 新增:新版本更新通知(What's New)弹窗
|
||||
- **需求**:新版本发布后用户首次进入弹一个"更新了什么"通知弹窗(纯通知,不是微信"点击更新→重启"流程)。用户澄清要的是 What's New,不是 applyUpdate 重启。
|
||||
- **实现**:复用 `ui-modal`(position:center) 居中弹窗 + `ui-btn`,不新建组件;`config.js` 加 `releaseNotes` 数组;首页 `index.js` 的 `_checkUpdateNote()` 在 `onShow` 检测 `storage.lastUpdateNoteVersion` vs `config.version`,不一致则显示并写回 storage 防重复;顶部绿色渐变徽章 + `sparkleWhite` 图标 + 版本标题 + 要点列表 + "知道了"按钮。
|
||||
- **文件**:`config.js`(releaseNotes)、`pages/index/index.{js,wxml,wxss}`。`index.json` 无需改(ui-modal/ui-btn 已注册)。
|
||||
- **发版约定**:每次发版改 `config.version` 并同步更新 `releaseNotes` 即可。前端改动不发云函数。
|
||||
+29
-38
@@ -1,43 +1,34 @@
|
||||
# 项目长期记忆(wx_pbzc 平板支撑训练小程序)
|
||||
|
||||
## 关键技术陷阱
|
||||
- **小程序组件 style isolation**:自定义组件默认 `isolated`。组件内 `variant` 拼出的类(`gradient`/`in` 等)在组件作用域,页面里写的 `.组件类 <slot后代>` 跨作用域选择器**全部失效**(如 `.ui-card.gradient .streak-num`)。唯一能跨边界传递的是 **CSS 自定义属性**(会沿 DOM 继承进 slot)。
|
||||
- 推论:渐变/主色容器内要反白的文字,必须用 `var(--text)/var(--text-secondary)`(容器在 `.gradient` 里重定义过即会变白),**绝不能用 `var(--primary)`**——否则橙色字画在橙色底上=看不见。
|
||||
- 进度条填充同理:用 `var(--on-primary, <原渐变>)` 让容器可覆盖为白,而非依赖失效的后代选择器。
|
||||
- **图标 SVG 颜色是烤死在 data URI 里的**(`utils/icons.js` 的 `build()`:`*Fill` 用 `primary` 主题色、`*`(无 Fill)用灰 `#999`)。CSS 的 `color`/`currentColor`/变量**都改不了 `<image src>` 这种图标**。所以:
|
||||
- 放在**主色/渐变底**(gradient hero 卡、primary 实心按钮)上的图标必须用**白色变体**(`hotWhite`/`formWhite`/`playWhite`/`trophyWhite` 等),否则同色隐形。
|
||||
- 加新图标或新彩色容器时,先想清楚底色:浅色底用 `*Fill`(主题色),彩色/主色底用 `*White` 变体。
|
||||
- 主按钮 `ui-btn--primary` 背景是橙渐变,`playFill`(橙) 放上去即隐形——须用 `playWhite`。ghost/outline 按钮底色浅或灰,用 `*Fill`/灰色变体即可。
|
||||
- `components/ui-btn` 图标尺寸 `1.3em`(随按钮字号自适应),不要回到 `1em` 以免显得过小。
|
||||
- 深色模式有两套事实源,改配色前先统一:`theme.json` 的 dark 与 `app.wxss`/`utils/theme.js` 要一致(已统一到 `#131316` 系)。
|
||||
- **微信圆形头像/图片裁剪陷阱(用户基础库实测)**:本项目的圆形头像在用户机器上表现如下——
|
||||
- 单独给 `<image>` 加 `border-radius:50%`(无 overflow 父层)→ 圆框里露方图,失效。
|
||||
- 外层 `<view>` 加 `overflow:hidden`+`border-radius:50%` 包裹 → **能裁切 SVG 占位图,但裁不掉远程照片(cloud:// 真实头像)**,照片仍方。所以日榜(多为占位图)正常、月榜/年榜(真实照片)却方。
|
||||
- **唯一可靠写法**:外层 `overflow:hidden` 父层 **且** `<image>` 自身也加 `border-radius:50%`(双保险)。设置页 `.avatar-btn`(button,overflow:hidden)+`.avatar-img`(image,border-radius:50%) 即是此写法且实测正常。排行榜 `.avatar`/`.avatar__img` 已对齐这一写法。
|
||||
- 推论:以后做圆形头像,**必须** image 自身 `border-radius:50%`,不能只靠父层 overflow。
|
||||
- **微信系统字体缩放导致文字溢出固定盒子(模拟器不重现、真机重现)**:微信会按用户「设置→通用→字体大小」自动放大**所有文字(含 rpx 字号)**,但**不放大 rpx 盒子尺寸**(width/height/padding)。症状:固定尺寸的圆形/方形容器里的文字在真机被放大、超出后被 `overflow:hidden` 裁掉,模拟器(标准档倍率 1.0)正常。
|
||||
- 获取缩放:`wx.getAppBaseInfo().fontSizeScaleFactor`(当前字号÷标准 17px);旧接口 `getSystemInfoSync().fontSizeSetting`(px,标准 17)。
|
||||
- **真机/模拟器不对称(关键坑)**:`getAppBaseInfo().host.env` 在模拟器是 `'devtools'`、真机是 `'wechat'`。模拟器会**原样返回你手机的真实缩放倍率**,但**渲染时并不会把文字放大**;真机才会真的放大。所以若不做区分,补偿把字号缩成 `design/factor` 后:真机放大回 design(正常),模拟器按缩小值渲染(数字变小=「不正常」)。→ **必须在 `host.env === 'devtools'` 时跳过补偿、直接用设计字号**;仅在真机/PC 微信才补偿。首页 `_readFontScale()` 已加此判断。
|
||||
- 补偿法:把容器内的关键文字字号反向除以该倍率(`designRpx / factor`),并在 JS 里 clamp 到安全区间(如 48–96),使真机渲染成和模拟器一致的视觉大小、永不溢出。首页 `.target-time` 已用此法(`index.js` `_readFontScale()`)。
|
||||
- 配套 CSS:`white-space:nowrap` 防换行顶出圆圈。
|
||||
- 推论:以后凡把文字放进**固定尺寸的圆形/方形容器**且依赖 `overflow:hidden` 裁切,都必须考虑字体缩放补偿,否则真机大字体档必溢出。
|
||||
- **排行榜头像 1–2s 延迟根因(设计使然,非 bug)**:`profile.avatarUrl` 存的是 `cloud://` 云存储 fileID(`settings._uploadAvatar` 用 `wx.cloud.uploadFile` 返回 fileID);云函数 `leaderboard` 直接透传,前端 `<image src="cloud://...">` 在 `setData` 渲染时才把 fileID 解析成临时 https URL(等效一次 `getTempFileURL` 网络往返)再下载。文字(昵称/时长/排名)是本地字符串瞬时渲染 → 故"文字先出、头像后出",延迟正是头像专属的「云存储解析 + 原图下载」段,叠加云函数冷启动。
|
||||
- 占位图 `peopleFill` 是本地 SVG data URI 瞬时,所以日榜(多为占位)无感、月/年榜(真实照片)延迟明显。
|
||||
- 优化方向:①云函数返回前对 `cloud://` 的 `avatarUrl` 批量 `cloud.getTempFileURL` 预解析成 https;②`_uploadAvatar` 上传前把 `chooseAvatar` 原图压缩/裁剪到最长边 ~200px、转 jpg/webp,体积降一个数量级;③云函数保活降冷启动。
|
||||
- **云函数数据库写操作必须 `{ data: {...} }` 包裹(wx-server-sdk 关键坑)**:`wx-server-sdk`(本项目 2.6.3) 的 `db.collection().doc(id).set(x)` / `.update(x)` / `.add(x)` **写操作**,参数必须是 `{ data: realObj }` 包裹形式——SDK 内部读 `parameter.data` 当作要写入的文档。直接传裸对象 `set(realObj)` 会让 SDK 读 `realObj.data` === `undefined`,报 `parameter.data should be object instead of undefined`(读操作 `get()` 不受影响,裸对象/无参都行)。`leaderboard` 云函数 `_persistSnapshot` 踩过此坑:写快照全失败、集合一直空。
|
||||
- 修复:`docRef.set({ data: { period, ranked, myOpenid, updatedAt } })`。写入后文档内容即 `realObj`(data 外套被 SDK 解开),读取 `doc.data` 仍是 `realObj`,读逻辑无需改。
|
||||
- 推论:**以后任何云函数写库代码,先写 `{ data: ... }`**,别沿用客户端 SDK 的裸对象习惯;出错时先怀疑是不是漏了 data 包裹,而不是怀疑部署。
|
||||
## 关键技术陷阱(最高优先级,改样式/加组件前必读)
|
||||
- **组件 style isolation = `isolated`**:页面 wxss 改不了组件内部类;唯一能跨边界的是 **CSS 变量**(沿 DOM 继承进 slot)。渐变/主色容器内反白文字必须用 `var(--text)`,**绝不**用 `var(--primary)`(橙字画橙底=隐形)。进度条填充用 `var(--on-primary,...)` 让容器可覆盖为白。
|
||||
- **图标色烤死在 SVG data URI**(`utils/icons.js`:`*Fill`=主题色、`*`(无Fill)=灰 `#999`),CSS 改不了 `<image>`。主色/渐变底(hero 卡、primary 按钮)须用 `*White` 变体(如 `playWhite`),浅底用 `*Fill`。**随状态换色的小装饰用纯 CSS 画**(如 `.circuit-phase-dot`),别用 icon。
|
||||
- **图标图形在 viewBox 内未必垂直居中**:外部库(Phosphor 等)的 path 直接塞进 24 viewBox,图形重心可能偏上/偏下 ±1rpx(medalMilitary 偏下 1.13、crownPh 偏上 1.13),导致「同排 icon 与文字对齐」各图标错位不一,CSS 微调永远修不齐。**治本**:用 `svgpath` 库(npm 装 managed workspace)`abs→unarc→unshort→iterate` 算包围盒,`scale+translate` 把图形等比缩放到 24×0.92 并平移到 (12,12) 居中。自写 path 解析器易错(`V/H` 单参数命令打乱配对、`a` 圆弧终点),别手写。7 枚勋章已按此法居中(2026-08-04)。
|
||||
- **圆形头像裁剪**:必须「外层 `overflow:hidden` + `<image>` 自身 `border-radius:50%`」双保险,否则远程照片(cloud://)裁不掉。
|
||||
- **系统字体缩放致文字溢出固定盒**(真机重现、模拟器不重现):`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()` 无此限制。
|
||||
- **自定义组件宿主默认 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` 仅本地调试用,正式版非必须。
|
||||
- **App.onLaunch 的 async/await 不阻塞首屏**:微信框架是「非阻塞生命周期调度」——onLaunch 被调用但页面 onLoad/onShow 并行推进,不等 onLaunch 内 Promise resolve。所以 app.js 里 `await cloud.pullAll()` 不会卡首屏,只是 await 之后的同步逻辑延迟执行。判断启动性能**别把 onLaunch 的 await 当阻塞**。
|
||||
- **代码审查避免过度判断**:给本项目做问题分析时,勿把"合理设计选择/优化建议"拔高成"问题/风险"。已被纠错的判断:①隐私协议缺失(实为后台配置,代码看不到)②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 落地 P0/P1/P2 后)
|
||||
- **预计算快照**:`cloudfunctions/leaderboard` 的 `exports.main` 现在区分两种调用——
|
||||
- **定时触发器**(`config.json` 的 `triggers`:`snapshotTimer` / `0 */4 * * * * *`,每 4 分钟)进入 `_rebuildSnapshots()`:扫描一次 `plank_data`(`_fetchLatestByOpenid`,走 updatedAt 索引+投影),复用 `_buildFromScan()` 算 day/month/year 三榜,各自写进 `leaderboard_snapshot` 集合(doc._id=周期,字段 `period/ranked/myOpenid/updatedAt`;首次写自动建集合)。
|
||||
- **客户端请求**:默认走 `_serveFromSnapshot()`(1 次 `get` ≈ 50ms),快照缺失/过期(>6min)才 `_computeBoard()` 实时重算并回写。**首开/冷启动已从 2-3s 降到 ≈50ms。**
|
||||
- **force 语义**:仅训练后那一次(客户端 `onShow` 读 `_lb_force_refresh` 置 `force=true`)走 `_computeBoard(force:true,persist:true)` 实时重算+回写快照;其他 99% 请求读快照,已满足"force 不再绕过云缓存"诉求。
|
||||
- **P1 启动预热**:`app.js` `onLaunch` 在 `cloud.init()` 后 fire-and-forget 调一次 `leaderboard`(day),提前暖云实例/快照。
|
||||
- **头像延迟**:云函数内 `getTempFileURL` 预解析已落地(在 `_buildFromScan` 里),前端不再懒加载,故"文字先出头像后出"的 1-2s 延迟已大幅消除;快照每 4min 重写,临时 URL 始终新鲜。
|
||||
- **改动注意**:要生效必须**重新部署 `leaderboard` 云函数**(定时器随部署注册)。`plank_data.updatedAt` 单字段索引需在控制台已建(用户已建)。响应结构 `{period,ranked,myOpenid,myEntry,updatedAt}` 不变,客户端 `leaderboard.js` 无需改逻辑(仅注释更新)。
|
||||
## 排行榜架构(2026-07-28+,云函数 `cloudfunctions/leaderboard`)
|
||||
- **预计算快照**:定时触发器(每 4min)`_rebuildSnapshots()` 扫 `plank_data` 算 day/month/year/endurance 四榜写 `leaderboard_snapshot`;客户端 99% 走 `_serveFromSnapshot()`(≈50ms),仅训练后 `force=true` 那次实时重算+回写。**改后须重新部署云函数**(定时器随部署注册)。
|
||||
- **耐力榜(endurance)**:全局单次最久,取 `max(duration)` 并记 `bestDate`;本人未进 top10 由 my-bar 在底部显示(依赖快照存 top500)。
|
||||
- 头像延迟已通过云函数 `getTempFileURL` 预解析解决;`plank_data.updatedAt` 需建单字段索引。
|
||||
|
||||
## 语音 / 常亮 / 循环训练
|
||||
- **TTS 词条两份白名单**(`utils/voice.js` 与 `cloudfunctions/tts/index.js`)须同步;加词条后**必须重新部署 tts 云函数**。词条避免含数字/变量(如"第3组"),用通用句。
|
||||
- **`voice.play` 截断前一条**(`_playSeq`):circuit halfway 与 rest 切换同刻须用 `_voiceBusyUntil` 让位;每组<10s 不播过场。
|
||||
- **`wx.setKeepScreenOn` 是小程序级、切后台失效**:`onUnload` 须显式关,`onShow` 若 `isRunning` 须重新武装(否则后半程息屏丢语音/震动)。
|
||||
- **循环训练 circuit**(2026-08-03):自由训练的循环形态,不碰 `plan.js`。入口首页 ghost 按钮→配置弹窗→`timer?circuit=1&...`。`duration`=组数×每组秒(不含休息);记录 `mode:'circuit'`、`planId:'circuit'`(计入连胜不计入计划进度)。状态机 `utils/circuitTimer.js`。
|
||||
|
||||
## 约定
|
||||
- UI 动词说法:index=首页/训练首页,timer=训练页,leaderboard=排行榜,records=记录,settings=设置。
|
||||
- 美化按"见效快优先"分批做,每轮 node --check + grep 残留再交付。
|
||||
- **git 推送需走 sandbox 外**:本机 Bash 工具默认 sandbox 隔离网络,`git push` 到 github.com 会 `SSL connection timeout`(约 5 分钟才报错)。必须用 `dangerouslyDisableSandbox: true` 在沙箱外执行推送(或 `run_in_background` + 沙箱外)才能连上。无 SSH key,remote 仅 HTTPS。
|
||||
- 页面叫法:index=训练首页,timer=训练页,leaderboard=排行榜,records=记录,settings=设置。
|
||||
- 美化按"见效快优先"分批,每轮 `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。
|
||||
- **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),别依赖该代理。
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 循环训练(简化版 + 配置持久化)实现概览
|
||||
|
||||
## 做了什么
|
||||
在「自由训练」并列新增**「循环训练」入口**,让用户自定义 每组时长 / 组数 / 组间休息(外加每周目标次数,仅作显示),并把这些设置**本地持久化**——下次打开直接沿用,无需重填。这正是你要的"简化版 + 至少要有循环训练设置的持久化"。
|
||||
|
||||
## 核心设计
|
||||
- **数据模型零入侵**:循环记录以 `planId:'circuit'` 落库,`getPlanDay` 不会把它算进每日计划进度;`duration` 仍存"有效撑总秒 = 组数×每组秒"(不含休息),排行榜 / 里程碑 / 统计零改动。
|
||||
- **状态机独立**:`utils/circuitTimer.js` 是 `idle→working→resting→…→done` 的 FSM,复用现有 `Timer` 的 `Date.now` + `onAppShow` 前台补偿风格;`stop()` 返回有效撑总秒,直接复用 timer 页现有保存逻辑。
|
||||
- **配置持久化**:`storage.getCircuitConfig / saveCircuitConfig` 存本地,含默认值与范围校验,不进云同步(纯设备偏好)。
|
||||
- **阶段提示**:语音只有固定 6 个 key、无"第N组 / 休息"词,故阶段切换用**震动 + 屏上文本**提示,完成复用 `complete` 语音——不过度依赖 TTS。进度环在循环模式恒中性灰(避免每阶段都"已达标"变绿误导)。
|
||||
|
||||
## 改动文件
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `utils/storage.js` | 加 circuit 配置读写(本地持久化) |
|
||||
| `utils/circuitTimer.js` | 新:循环状态机 |
|
||||
| `pages/timer/timer.{js,wxml,wxss}` | circuit 模式分支、状态条、阶段提示、完成/保存 |
|
||||
| `pages/index/index.{js,wxml,wxss}` | 「循环训练」入口 + 配置弹窗(stepper + 持久化) |
|
||||
| `utils/icons.js` | 加 `repeat` / `repeatFill` 图标 |
|
||||
| `pages/records/records.{js,wxml}` | 循环记录显示「循环 N组×Ms」 |
|
||||
|
||||
## 验证
|
||||
- 6 个 JS 文件全部通过 `node --check`。
|
||||
- 未改动 `plan.js` / 云函数 / 榜单 / 云同步逻辑,首提风险低。
|
||||
|
||||
## 未做(可选后续)
|
||||
- 周频次硬追踪(仅存 `sessionsPerWeek` 作显示,未做"本周 X/N 次"计数)
|
||||
- 逐周自动进阶
|
||||
- 设置页统一管理入口
|
||||
|
||||
## 真机注意
|
||||
- 环形进度用 countUp 动画,不进固定 rpx 盒,规避字体缩放溢出(首页 `_readFontScale` 思路已避开)。
|
||||
- 主色 / 渐变底图标用 White 变体;`repeatFill` 为橙主题色,在 ghost 按钮浅底 OK。
|
||||
@@ -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. 节奏结构
|
||||
先总(一句话更新概览)→ 分(功能块)→ 总(召唤行动)。
|
||||
@@ -118,7 +118,6 @@ App({
|
||||
}
|
||||
// else: no data anywhere — nothing to sync
|
||||
} catch (e) {
|
||||
console.error('Cloud sync on launch failed:', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -145,7 +144,6 @@ App({
|
||||
const localChangedAt = wx.getStorageSync('_local_changed_at') || 0
|
||||
const keepLocal = cloudTs > 0 && localChangedAt > cloudTs
|
||||
if (keepLocal) {
|
||||
console.log('[cloud] restore: keeping local single-value fields (local changed after cloud snapshot)')
|
||||
} else {
|
||||
if (cloudData.settings) {
|
||||
wx.setStorageSync('user_settings', cloudData.settings)
|
||||
@@ -169,7 +167,6 @@ App({
|
||||
// records so it reflects actual history on this device.
|
||||
storage.recomputeStreak()
|
||||
} catch (e) {
|
||||
console.error('Cloud restore failed:', e)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -117,7 +117,6 @@ const _resolveAvatars = async (urls) => {
|
||||
if (f && f.fileID && f.tempFileURL) map[f.fileID] = f.tempFileURL
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[leaderboard] getTempFileURL batch failed:', e)
|
||||
}
|
||||
}
|
||||
return (urls || []).map(u => (u && map[u]) ? map[u] : u)
|
||||
@@ -149,6 +148,51 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
|
||||
for (const doc of latestByOpenid.values()) {
|
||||
const openid = doc._openid || 'unknown'
|
||||
const records = doc.records || {}
|
||||
|
||||
// 勋章等级 = 累计去重训练日(遍历该用户全部记录,与榜单周期无关)。
|
||||
// 客户端据此映射最新解锁勋章,让全榜用户都能看到彼此的勋章。
|
||||
const trainedDays = new Set()
|
||||
for (const monthKey of Object.keys(records)) {
|
||||
const arr = records[monthKey]
|
||||
if (!Array.isArray(arr)) continue
|
||||
for (const r of arr) {
|
||||
if (!r || typeof r.date !== 'string') continue
|
||||
trainedDays.add(r.date.substring(0, 10))
|
||||
}
|
||||
}
|
||||
const totalDays = trainedDays.size
|
||||
|
||||
if (period === 'endurance') {
|
||||
// 全局单次最久:遍历该用户全部月份的 records,取 max(duration) 那条,
|
||||
// 并记下其 r.date 作为"产生时间"。不做日/月/年过滤、不累加。
|
||||
let bestDuration = 0
|
||||
let bestDate = ''
|
||||
let total = 0
|
||||
for (const monthKey of Object.keys(records)) {
|
||||
const arr = records[monthKey]
|
||||
if (!Array.isArray(arr)) continue
|
||||
for (const r of arr) {
|
||||
if (!r || typeof r.date !== 'string') continue
|
||||
total++
|
||||
const dur = Number(r.duration) || 0
|
||||
if (dur > bestDuration) { bestDuration = dur; bestDate = r.date }
|
||||
}
|
||||
}
|
||||
if (bestDuration > 0) {
|
||||
const profile = doc.profile || {}
|
||||
userMap.set(openid, {
|
||||
openid,
|
||||
duration: bestDuration,
|
||||
bestDate,
|
||||
sessions: total,
|
||||
totalDays,
|
||||
nickname: profile.nickname || '',
|
||||
avatarUrl: profile.avatarUrl || ''
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let duration = 0
|
||||
let sessions = 0
|
||||
|
||||
@@ -179,6 +223,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
|
||||
openid,
|
||||
duration,
|
||||
sessions,
|
||||
totalDays,
|
||||
nickname: profile.nickname || '',
|
||||
avatarUrl: profile.avatarUrl || ''
|
||||
})
|
||||
@@ -208,6 +253,8 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
|
||||
name: _displayName(item),
|
||||
duration: item.duration,
|
||||
sessions: item.sessions,
|
||||
bestDate: item.bestDate || '',
|
||||
totalDays: item.totalDays || 0,
|
||||
avatarUrl: item.avatarUrl || ''
|
||||
}))
|
||||
|
||||
@@ -231,6 +278,8 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
|
||||
name: _displayName(myEntryRaw),
|
||||
duration: myEntryRaw.duration,
|
||||
sessions: myEntryRaw.sessions,
|
||||
bestDate: myEntryRaw.bestDate || '',
|
||||
totalDays: myEntryRaw.totalDays || 0,
|
||||
avatarUrl: myEntryRaw.avatarUrl || ''
|
||||
} : null
|
||||
|
||||
@@ -255,12 +304,9 @@ const _persistSnapshot = async (period, result) => {
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
console.log('[leaderboard] persisting snapshot', period, 'rankedLen=', ranked.length)
|
||||
const docRef = db.collection(SNAPSHOT_COLLECTION).doc(period)
|
||||
const setRes = await docRef.set(payload)
|
||||
console.log('[leaderboard] persist ok', period, JSON.stringify(setRes))
|
||||
} catch (e) {
|
||||
console.warn('[leaderboard] persist snapshot failed for', period, e && e.errMsg ? e.errMsg : e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,9 +340,8 @@ const _serveFromSnapshot = async (period, limit, myOpenid) => {
|
||||
*/
|
||||
const _rebuildSnapshots = async () => {
|
||||
const latestByOpenid = await _fetchLatestByOpenid(true)
|
||||
for (const p of ['day', 'month', 'year']) {
|
||||
for (const p of ['day', 'month', 'year', 'endurance']) {
|
||||
const result = await _buildFromScan(latestByOpenid, p, SNAPSHOT_TOP, null)
|
||||
console.log('[leaderboard] rebuilt', p, 'rankedLen=', (result && result.ranked) ? result.ranked.length : 'undef')
|
||||
await _persistSnapshot(p, result)
|
||||
}
|
||||
return { ok: true, rebuiltAt: new Date().toISOString() }
|
||||
@@ -328,7 +373,7 @@ exports.main = async (event) => {
|
||||
const { period, maxRank, force } = event || {}
|
||||
const limit = Math.max(1, Math.min(parseInt(maxRank) || DEFAULT_MAX_RANK, 500))
|
||||
if (!period) return { err: 'missing period' }
|
||||
if (period !== 'day' && period !== 'month' && period !== 'year') return { err: 'invalid period' }
|
||||
if (period !== 'day' && period !== 'month' && period !== 'year' && period !== 'endurance') return { err: 'invalid period' }
|
||||
|
||||
const myOpenid = cloud.getWXContext().OPENID
|
||||
|
||||
|
||||
@@ -26,7 +26,12 @@ const PROMPTS = {
|
||||
last30: '最后30秒,保持呼吸,稳住姿势!',
|
||||
last10: '最后10秒,再加把劲!',
|
||||
goal: '目标达成,继续挑战!',
|
||||
complete: '太棒了!今天的目标已完成,继续加油!'
|
||||
complete: '太棒了!今天的目标已完成,继续加油!',
|
||||
// 循环训练(circuit)阶段切换专用。必须与 utils/voice.js 的 PROMPTS 保持一致,
|
||||
// 否则客户端能请求、云端返回 Unknown prompt key(会静默降级为不播)。
|
||||
restStart: '休息一下,调整呼吸',
|
||||
nextSet: '下一组,准备开始',
|
||||
lastSet: '最后一组,全力冲刺!'
|
||||
}
|
||||
|
||||
const CACHE_DIR = 'tts-cache'
|
||||
|
||||
@@ -59,7 +59,9 @@ Component({
|
||||
const recordMap = {}
|
||||
if (records && records.length) {
|
||||
records.forEach((r) => {
|
||||
const d = parseInt(r.date.split('-')[2])
|
||||
// 先用 util.dateOnly 归一化日期(兼容 "YYYY-MM-DD HH:MM:SS" / ISO 等),
|
||||
// 再稳定地取出「日」,避免直接 split('-')[2] 取到 "DD HH:MM:SS" 靠 parseInt 侥幸解析的脆弱写法
|
||||
const d = parseInt(util.dateOnly(r.date).split('-')[2], 10)
|
||||
recordMap[d] = (recordMap[d] || 0) + r.duration
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,27 +14,47 @@ Component({
|
||||
trackColor: { type: String, value: '#EEEEEE' },
|
||||
// Total elapsed seconds (since start). Shown in the ring center once the
|
||||
// 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: {
|
||||
displayTime: '00:00',
|
||||
statusText: '准备开始'
|
||||
displayTime: '00:00'
|
||||
},
|
||||
|
||||
observers: {
|
||||
'remaining, status, duration, elapsed'() {
|
||||
const texts = { idle: '准备开始', running: '坚持住', paused: '已暂停', completed: '完成!' }
|
||||
// 倒计时阶段显示剩余;达标后 remaining 归零,改显总训练时长(overtime 模式)
|
||||
const shown = this.data.remaining > 0 ? this.data.remaining : (this.data.elapsed || 0)
|
||||
this.setData({
|
||||
displayTime: util.formatTime(shown),
|
||||
statusText: texts[this.data.status] || ''
|
||||
displayTime: util.formatTime(shown)
|
||||
})
|
||||
},
|
||||
|
||||
'remaining, duration, size, ringWidth, primaryColor, primaryLightColor, trackColor'() {
|
||||
'remaining, duration, size, ringWidth, primaryColor, primaryLightColor, trackColor, ticks'() {
|
||||
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
|
||||
this._ctx = canvas.getContext('2d')
|
||||
this._dpr = dpr
|
||||
this._canvas = canvas
|
||||
this._draw()
|
||||
this._syncSetPulse()
|
||||
})
|
||||
},
|
||||
|
||||
detached() {
|
||||
this._stopSetPulse()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -70,7 +96,7 @@ Component({
|
||||
const ctx = this._ctx
|
||||
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 w = size * dpr
|
||||
@@ -79,8 +105,11 @@ Component({
|
||||
|
||||
const cx = w / 2
|
||||
const cy = h / 2
|
||||
// reserve room for the glowing end dot so its shadow isn't clipped
|
||||
const glowPad = 6 * dpr
|
||||
// Reserve room for the glowing end dot's shadow AND the outer set-progress
|
||||
// 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 lw = ringWidth * dpr
|
||||
|
||||
@@ -92,6 +121,43 @@ Component({
|
||||
ctx.lineCap = 'round'
|
||||
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)
|
||||
const ratio = duration > 0 ? Math.max(0, Math.min(1, remaining / duration)) : 0
|
||||
if (ratio > 0.001) {
|
||||
@@ -143,6 +209,183 @@ Component({
|
||||
ctx.fill()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
<canvas type="2d" id="ringCanvas" class="ring-canvas" style="width: {{size}}rpx; height: {{size}}rpx;"></canvas>
|
||||
<view class="ring-center">
|
||||
<text class="time-text">{{displayTime}}</text>
|
||||
<text class="status-text">{{statusText}}</text>
|
||||
<text class="sub-text" wx:if="{{subText}}">{{subText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 88rpx;
|
||||
font-size: 96rpx;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -29,9 +29,9 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 28rpx;
|
||||
.sub-text {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 10rpx;
|
||||
margin-top: 14rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
const { getBarPercent } = require('../../utils/util')
|
||||
|
||||
/**
|
||||
* Trend bar chart - pure CSS (no canvas), matches the calendar-heatmap's
|
||||
* rendering approach for consistency.
|
||||
* Trend chart — pure CSS bar mode + optional SVG line mode (no canvas),
|
||||
* matching the calendar-heatmap's rendering approach for consistency.
|
||||
*
|
||||
* chartData: [{ label, value, valueText, highlight }]
|
||||
* - label: bottom caption (e.g. "一" / "7月")
|
||||
* - value: numeric magnitude (seconds); drives bar height
|
||||
* - value: numeric magnitude (seconds); drives bar height / line y
|
||||
* - valueText: formatted string shown above the bar (e.g. "1分30秒")
|
||||
* - highlight: truthy -> theme glow + bold label (today / this month)
|
||||
*
|
||||
* Bar height is value/max*100%, with an 8% floor for any non-zero value so
|
||||
* short sessions stay visible. Zero values render a faint stub.
|
||||
* chartType: 'bar' (default) | 'line'
|
||||
* - bar: CSS columns, height strictly proportional to value/max.
|
||||
* - line: inline SVG (data URI) polyline + translucent area, rendered in an
|
||||
* <image>; dots + axis labels are real WXML elements positioned by
|
||||
* percentage so they stay crisp and theme-colored (SVG inside <image>
|
||||
* cannot read page CSS variables). The viewBox aspect is matched to
|
||||
* the container's rpx aspect (widthFix) so the stroke stays uniform
|
||||
* — no non-uniform horizontal/vertical stretch.
|
||||
*
|
||||
* lineColor: hex used for the SVG stroke + area fill. Must be passed by the
|
||||
* page because SVG inside <image> cannot read page CSS variables.
|
||||
*/
|
||||
const VB_W = 640
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
chartData: { type: Array, value: [] }
|
||||
chartData: { type: Array, value: [] },
|
||||
variant: { type: String, value: 'total' },
|
||||
chartType: { type: String, value: 'bar' },
|
||||
lineColor: { type: String, value: '#FF6B35' }
|
||||
},
|
||||
data: {
|
||||
bars: []
|
||||
bars: [],
|
||||
linePoints: [],
|
||||
lineSvg: ''
|
||||
},
|
||||
observers: {
|
||||
'chartData'(chartData) {
|
||||
this._compute(chartData)
|
||||
'chartData, chartType, lineColor'(chartData, chartType, lineColor) {
|
||||
this._compute(chartData, chartType, lineColor)
|
||||
}
|
||||
},
|
||||
lifetimes: {
|
||||
ready() {
|
||||
this._compute(this.properties.chartData)
|
||||
this._compute(this.properties.chartData, this.properties.chartType, this.properties.lineColor)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
_compute(chartData) {
|
||||
_compute(chartData, chartType, lineColor) {
|
||||
const arr = Array.isArray(chartData) ? chartData : []
|
||||
const max = arr.reduce((m, d) => Math.max(m, Number(d && d.value) || 0), 0)
|
||||
const bars = arr.map((d) => {
|
||||
@@ -39,10 +57,52 @@ Component({
|
||||
value: v,
|
||||
valueText: (d && d.valueText) || '',
|
||||
highlight: !!(d && d.highlight),
|
||||
percent: max > 0 ? Math.max((v / max) * 100, v > 0 ? 8 : 0) : 0
|
||||
percent: getBarPercent(v, max)
|
||||
}
|
||||
})
|
||||
this.setData({ bars })
|
||||
|
||||
const N = arr.length
|
||||
let linePoints = []
|
||||
let lineSvg = ''
|
||||
if (N > 0 && chartType === 'line') {
|
||||
const lineH = this.properties.variant === 'peak' ? 170 : 200
|
||||
const topPad = 16
|
||||
const bottomPad = 12
|
||||
const plot = lineH - topPad - bottomPad
|
||||
linePoints = bars.map((b, i) => {
|
||||
const x = (i + 0.5) * (VB_W / N)
|
||||
const y = topPad + (1 - b.percent / 100) * plot
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
xPercent: (x / VB_W) * 100,
|
||||
yPercent: (y / lineH) * 100,
|
||||
label: b.label,
|
||||
valueText: b.valueText,
|
||||
value: b.value,
|
||||
highlight: b.highlight
|
||||
}
|
||||
})
|
||||
lineSvg = this._buildLineSvg(linePoints, VB_W, lineH, lineColor || '#FF6B35')
|
||||
}
|
||||
this.setData({ bars, linePoints, lineSvg })
|
||||
},
|
||||
|
||||
_buildLineSvg(points, W, H, color) {
|
||||
const valid = (points || []).filter(p => p && typeof p.x === 'number' && typeof p.y === 'number')
|
||||
if (valid.length === 0) return ''
|
||||
const pts = valid.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
|
||||
const first = valid[0]
|
||||
const last = valid[valid.length - 1]
|
||||
let area = `M ${first.x.toFixed(2)},${H} `
|
||||
valid.forEach((p) => { area += `L ${p.x.toFixed(2)},${p.y.toFixed(2)} ` })
|
||||
area += `L ${last.x.toFixed(2)},${H} Z`
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">` +
|
||||
`<path d="${area}" fill="${color}" fill-opacity="0.16"/>` +
|
||||
`<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"/>` +
|
||||
`</svg>`
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
<view class="trend-chart">
|
||||
<view class="trend-bars">
|
||||
<view class="trend-bar-col" wx:for="{{bars}}" wx:key="label">
|
||||
<view class="trend-bar {{item.value === 0 ? 'zero' : ''}} {{item.highlight ? 'highlight' : ''}}" style="height: {{item.percent}}%;">
|
||||
<text class="trend-bar-value" wx:if="{{item.value > 0}}">{{item.valueText}}</text>
|
||||
<view class="trend-chart {{variant === 'peak' ? 'peak' : ''}} {{chartType === 'line' ? 'line' : ''}}">
|
||||
<!-- Bar mode -->
|
||||
<block wx:if="{{chartType !== 'line'}}">
|
||||
<view class="trend-bars">
|
||||
<view class="trend-bar-col" wx:for="{{bars}}" wx:key="label">
|
||||
<view class="trend-bar {{item.value === 0 ? 'zero' : ''}} {{item.highlight ? 'highlight' : ''}}" style="height: {{item.percent}}%;">
|
||||
<text class="trend-bar-value" wx:if="{{item.value > 0}}">{{item.valueText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="trend-labels">
|
||||
<text class="trend-bar-label {{item.highlight ? 'highlight' : ''}}" wx:for="{{bars}}" wx:key="label">{{item.label}}</text>
|
||||
</view>
|
||||
<view class="trend-labels">
|
||||
<text class="trend-bar-label {{item.highlight ? 'highlight' : ''}}" wx:for="{{bars}}" wx:key="label">{{item.label}}</text>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- Line mode -->
|
||||
<block wx:else>
|
||||
<view class="trend-line">
|
||||
<image class="trend-line-img" src="{{lineSvg}}" mode="widthFix"></image>
|
||||
<view wx:for="{{linePoints}}" wx:key="label" class="trend-line-dot {{item.highlight ? 'highlight' : ''}}" style="left: {{item.xPercent}}%; top: {{item.yPercent}}%;">
|
||||
<text wx:if="{{item.value > 0}}" class="trend-line-value">{{item.valueText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="trend-line-labels">
|
||||
<text wx:for="{{linePoints}}" wx:key="label" class="trend-line-label {{item.highlight ? 'highlight' : ''}}" style="left: {{item.xPercent}}%;">{{item.label}}</text>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
@@ -36,14 +36,40 @@
|
||||
box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.5);
|
||||
}
|
||||
|
||||
.trend-chart.peak .trend-bars {
|
||||
height: 156rpx;
|
||||
}
|
||||
|
||||
.trend-chart.peak .trend-bar {
|
||||
width: 12rpx;
|
||||
margin: 0 auto;
|
||||
min-height: 8rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.trend-chart.peak .trend-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
left: -4rpx;
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.trend-chart.peak .trend-bar.zero::after {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* value caption floats above the bar so it never eats into bar height */
|
||||
.trend-bar-value {
|
||||
position: absolute;
|
||||
top: -28rpx;
|
||||
top: -26rpx;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 18rpx;
|
||||
font-size: 16rpx;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
@@ -66,3 +92,80 @@
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Line mode ---- */
|
||||
.trend-chart.line {
|
||||
/* extra top room so the value caption above the highest dot isn't clipped */
|
||||
padding-top: 30rpx;
|
||||
}
|
||||
|
||||
.trend-line {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trend-line-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trend-line-dot {
|
||||
position: absolute;
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
margin-left: -8rpx;
|
||||
margin-top: -8rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
border: 3rpx solid var(--card-bg, #FFFFFF);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.trend-chart.peak .trend-line-dot {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.trend-line-dot.highlight {
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
margin-left: -10rpx;
|
||||
margin-top: -10rpx;
|
||||
box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.6);
|
||||
}
|
||||
|
||||
/* value caption floats above the dot so it never eats into the line */
|
||||
.trend-line-value {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 6rpx;
|
||||
font-size: 16rpx;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trend-line-dot.highlight .trend-line-value {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.trend-line-labels {
|
||||
position: relative;
|
||||
height: 28rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.trend-line-label {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
font-size: 20rpx;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trend-line-label.highlight {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
Component({
|
||||
options: { multipleSlots: true },
|
||||
properties: {
|
||||
variant: { type: String, value: '' }, // '' | 'soft' | 'in'
|
||||
variant: { type: String, value: '' }, // '' | 'soft' | 'in' | 'gradient'
|
||||
padded: { type: Boolean, value: true },
|
||||
customStyle: { type: String, value: '' }
|
||||
customStyle: { type: String, value: '' },
|
||||
// 入场交错序号(0 起)。组件无从得知自己是页面里的第几张卡,:nth-child 在组件
|
||||
// 内部又永远命中第 1 个,所以延迟只能由页面显式传入。>3 的一律钳到 3,避免
|
||||
// 卡片多的页面(如设置页 7 张)最后一张要等 0.65s 才出现。
|
||||
index: { type: Number, value: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<view class="ui-card {{variant}} {{padded ? '' : 'ui-card--flush'}}" style="{{customStyle}}">
|
||||
<!-- customStyle 写在最后,保证页面传入的样式能覆盖内联的 animation-delay -->
|
||||
<view
|
||||
class="ui-card {{variant}} {{padded ? '' : 'ui-card--flush'}}"
|
||||
style="animation-delay:{{50 + (index > 3 ? 3 : (index > 0 ? index : 0)) * 100}}ms;{{customStyle}}"
|
||||
>
|
||||
<slot></slot>
|
||||
</view>
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
/* 宿主节点必须显式声明 display —— 自定义组件宿主默认是 inline,而内部是 block。
|
||||
inline 盒被 block 子元素打断后会碎成「前半段/后半段」两个零宽行盒,页面侧一旦
|
||||
在 <ui-card> 标签上加 border / box-shadow,就只会画在这两个碎片上,看起来是
|
||||
「角上冒出两根竖条」(首页 .just-completed 高亮踩过这个坑);transform 对非替换
|
||||
inline 元素更是完全无效,缩放脉冲动画一次都不会跑。
|
||||
margin-bottom 也一并上提到宿主:外边距归宿主、内边距归内部,页面侧加边框时
|
||||
才不会把 24rpx 外边距一起圈进框里。 */
|
||||
:host {
|
||||
display: block;
|
||||
margin-bottom: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ui-card {
|
||||
background: var(--card-bg, #FFFFFF);
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -41,16 +53,14 @@
|
||||
--on-primary: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Staggered entrance animation */
|
||||
/* Staggered entrance animation.
|
||||
交错延迟由页面传入的 index 换算成内联 animation-delay(见 ui-card.wxml),
|
||||
不能用 :nth-child —— .ui-card 在组件内部永远是根节点下唯一的第 1 个子元素,
|
||||
恒匹配 nth-child(1),所有卡片延迟都会是同一个值,压根不交错。 */
|
||||
.ui-card.in {
|
||||
animation: uiCardIn 0.45s ease both;
|
||||
}
|
||||
|
||||
.ui-card.in:nth-child(1) { animation-delay: 0.05s; }
|
||||
.ui-card.in:nth-child(2) { animation-delay: 0.15s; }
|
||||
.ui-card.in:nth-child(3) { animation-delay: 0.25s; }
|
||||
.ui-card.in:nth-child(4) { animation-delay: 0.35s; }
|
||||
|
||||
@keyframes uiCardIn {
|
||||
from { opacity: 0; transform: translateY(40rpx); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
|
||||
@@ -4,10 +4,23 @@
|
||||
*/
|
||||
module.exports = {
|
||||
/** 应用版本号,外显在设置页「关于」 */
|
||||
version: 'v3.02',
|
||||
version: 'v3.3',
|
||||
|
||||
/** 最后更新日期,外显在设置页「关于」 */
|
||||
updatedAt: '2026-07-29',
|
||||
updatedAt: '2026-08-14',
|
||||
|
||||
/**
|
||||
* 新版本更新通知(What's New)要点。首页冷启动检测 config.version 与本地
|
||||
* 记录版本不一致时,弹一次居中通知弹窗展示这些要点。每次发版改 version
|
||||
* 的同时更新本数组即可(措辞尽量短,每条一行)。
|
||||
*/
|
||||
releaseNotes: [
|
||||
'循环训练分段弧优化:光点居中、与进度弧间距拉大,看得更清楚',
|
||||
'暗黑模式切换后,各页面底部栏同步变暗',
|
||||
'循环训练偶数组数:休息语音不再被「完成一半」提示覆盖',
|
||||
'训练完成新增分享海报,成绩展示更完整',
|
||||
'训练时长不足 20 秒不计入记录'
|
||||
],
|
||||
|
||||
/** 开发者名称 / 微信号,设置页点击可复制 */
|
||||
developer: '三口一瓶',
|
||||
@@ -21,6 +34,12 @@ module.exports = {
|
||||
/** 排行榜最大显示人数 */
|
||||
leaderboardMaxRank: 50,
|
||||
|
||||
/**
|
||||
* 耐力榜领奖台顶部文案。哲理注脚,点出"撑得越久不一定越好"的价值观,
|
||||
* 与耐力榜(比谁单次撑最久)形成反差。仅耐力榜领奖台顶部显示,改这里即可。
|
||||
*/
|
||||
leaderboardPodiumSlogan: '平板支撑不是计时器比赛,是肌肉的精细控制。宁做30秒的钢板,不做5分钟的烂泥。',
|
||||
|
||||
/**
|
||||
* 语音合成(TTS)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。
|
||||
* 设置页可选男声/女声,客户端按选择取下面对应的一组传给 tts 云函数。
|
||||
@@ -30,8 +49,8 @@ module.exports = {
|
||||
* speed: 语速 -2~2(正数加快,0 为默认)。
|
||||
*/
|
||||
tts: {
|
||||
female: { voiceType: 501004, volume: 9, speed: 0 },
|
||||
male: { voiceType: 502005, volume: 9, speed: 0 }
|
||||
female: { voiceType: 501004, volume: 10, speed: 0 },
|
||||
male: { voiceType: 502005, volume: 10, speed: 0 }
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -54,20 +73,55 @@ module.exports = {
|
||||
/** 姿势要领每条显示的秒数 */
|
||||
tipInterval: 10,
|
||||
|
||||
/**
|
||||
* 卡路里估算系数(千卡/秒)。平板支撑按体重约 3-4 千卡/分钟估算,
|
||||
* 取均值约 0.068 千卡/秒。仅记录页点日期查看「约消耗」时使用。
|
||||
* 改这里即可调整,无需动逻辑代码。
|
||||
*/
|
||||
caloriesPerSecond: 0.068,
|
||||
|
||||
/**
|
||||
* 最短记录时长(秒)。本次训练有效时长低于该值时:
|
||||
* - 不写入训练记录、不更新连续打卡天数(避免"撑几秒也算一天"的无效记录)
|
||||
* - 完成弹窗会明确提示"本次未计入记录",并隐藏查看记录/分享入口
|
||||
* 循环训练按"有效撑持秒数"(各组时长之和,不含休息)判断。
|
||||
* 改这里即可调整门槛,无需动逻辑代码。
|
||||
*/
|
||||
minRecordSeconds: 15,
|
||||
|
||||
/**
|
||||
* 完成弹窗科学提示(按本次训练时长分段)。
|
||||
* 完成时按实际撑的秒数命中第一段 maxSeconds 大于它的配置,
|
||||
* 在弹窗里展示该段的 value(价值)与 risk(风险,弱化小字)。
|
||||
* maxSeconds: 该段时长上限(秒),最后一段用 Infinity 兜底。
|
||||
* risk 留空则不显示风险行(如短时训练无风险提示)。
|
||||
*
|
||||
* 注意口径:只讲"价值/风险",刻意不提供能系统说法——
|
||||
* "磷酸原/糖酵解按时长切换"是流传广但错误的说法,避免误导用户。
|
||||
* 风险文案往"姿势/休息"引导,不吓人,与 leaderboardPodiumSlogan
|
||||
* "撑得越久不一定越好"的理念保持一致。
|
||||
*/
|
||||
scienceTips: [
|
||||
{ maxSeconds: 120, value: '核心稳定性与肌耐力正在建立,每一次坚持都在加固', risk: '' },
|
||||
{ maxSeconds: 300, value: '肌耐力提升明显,核心控制力渐入佳境', risk: '累了容易塌腰,姿势变形就休息' },
|
||||
{ maxSeconds: 480, value: '这是一场意志力的较量,肌肉收益开始递减', risk: '肩胛压力增大,不必硬撑更久' },
|
||||
{ maxSeconds: Infinity, value: '心理韧性训练,远超普通坚持的挑战', risk: '腰椎肩关节持续受压,不建议作为常规时长' }
|
||||
],
|
||||
|
||||
/**
|
||||
* 累计训练天数里程碑勋章,按累计去重训练日(可中断,非连续打卡)解锁。
|
||||
* 记录页用里程碑进度条展示,节点按 sqrt 感知映射分布(早期里程碑更显眼),
|
||||
* 填充与节点同映射,解锁时填充正好抵达该节点、满级时满格。增删改这里即可,
|
||||
* 记录页用等距里程碑进度条展示,每个连接段按当前阶段进度填充为绿色。
|
||||
* 解锁下一枚勋章时,对应连接段恰好填满。增删改这里即可,
|
||||
* 口径=累计去重训练日(可中断);name 仅作趣味命名,不暗示连续打卡。
|
||||
*/
|
||||
trainingDayBadges: [
|
||||
{ days: 3, name: '抖登', icon: 'hot', iconWhite: 'hotWhite' },
|
||||
{ days: 7, name: '中登', icon: 'bolt', iconWhite: 'boltWhite' },
|
||||
{ days: 14, name: '老登', icon: 'moon', iconWhite: 'moonWhite' },
|
||||
{ days: 30, name: '神登', icon: 'trophy', iconWhite: 'trophyWhite' },
|
||||
{ days: 60, name: '上古神登', icon: 'diamond', iconWhite: 'diamondWhite' },
|
||||
{ days: 80, name: '洪荒神登', icon: 'star', iconWhite: 'starWhite' },
|
||||
{ days: 100, name: '究极登祖', icon: 'crown', iconWhite: 'crownWhite' }
|
||||
{ days: 3, name: '抖登', icon: 'sealCheck', iconWhite: 'sealCheckWhite' },
|
||||
{ days: 7, name: '中登', icon: 'medal', iconWhite: 'medalWhite' },
|
||||
{ days: 14, name: '老登', icon: 'medalMilitary', iconWhite: 'medalMilitaryWhite' },
|
||||
{ days: 30, name: '神登', icon: 'trophyPh', iconWhite: 'trophyPhWhite' },
|
||||
{ days: 60, name: '上古神登', icon: 'certificate', iconWhite: 'certificateWhite' },
|
||||
{ days: 80, name: '洪荒神登', icon: 'shieldStar', iconWhite: 'shieldStarWhite' },
|
||||
{ days: 100, name: '究极登祖', icon: 'crownPh', iconWhite: 'crownPhWhite' }
|
||||
],
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# IconPark 勋章视觉替换设计
|
||||
|
||||
## 目标
|
||||
|
||||
替换记录页训练勋章的现有通用图标,使其形成更清晰的成长叙事,同时保留主题换色、解锁状态、勋章点击说明和现有进度计算。
|
||||
|
||||
## 已确认的视觉方向
|
||||
|
||||
采用 IconPark 的彩色填充风格(预览方案 A)。图标序列为:火焰、闪电、徽章、奖杯、钻石、星耀、皇冠。
|
||||
|
||||
- 前三枚已解锁图标使用暖橙、金黄、主题绿的温和递进,强调早期坚持。
|
||||
- 第四至第七枚解锁图标使用当前主题色,保持换肤一致性。
|
||||
- 未解锁图标沿用浅灰描边圆形节点。
|
||||
- 图标在本地以 SVG path 形式嵌入既有 `utils/icons.js`,不添加 npm 包、远程请求或新的运行时模块。
|
||||
|
||||
## 数据和交互
|
||||
|
||||
`config.trainingDayBadges` 继续定义天数、名称和图标键;记录页进度、逐段填充、解锁庆祝和点击详情均不改变。
|
||||
|
||||
图标构建器新增每枚勋章的彩色/白色变体:节点解锁时使用彩色图标,主题节点上的图标保持白色以保证对比度;未解锁时使用灰色图标。
|
||||
|
||||
## 验证
|
||||
|
||||
- 为图标构建结果补充单元测试,验证七枚勋章的键均存在。
|
||||
- 运行完整 Node 测试与 JavaScript 语法检查。
|
||||
- 在微信开发者工具检查默认主题及蓝、绿主题下的记录页:节点、文字、进度段和点击说明均可见。
|
||||
|
||||
## 授权
|
||||
|
||||
图标来源为 ByteDance IconPark,Apache-2.0;项目保留来源和许可证说明。
|
||||
@@ -0,0 +1,75 @@
|
||||
# 平板支撑训练小程序 — 代码问题分析(核实修订版)
|
||||
|
||||
> 分析日期:2026-08-04 | 版本:v3.1 | 修订:2026-08-04(经事实核实)
|
||||
|
||||
## 修订说明
|
||||
|
||||
初版报告列了 8 条"问题",经逐条事实核实(而非凭代码表面/记忆判断),**3 条撤回、2 条降级、3 条保留为可选优化**。本项目已审核上架运行一个月,代码质量实际很高,初版多处过度判断,特此修正。
|
||||
|
||||
## 核实结论总览
|
||||
|
||||
| 初版判断 | 核实结果 | 最终定性 |
|
||||
|---------|---------|---------|
|
||||
| 隐私协议缺失(审核风险) | ❌ 撤回 | 隐私协议在 MP 后台配置,代码看不到;已上架运行 = 后台已配好 |
|
||||
| 云同步阻塞启动(冷启动多等 2-3s) | ❌ 撤回 | onLaunch 的 await 不阻塞首屏,微信为非阻塞生命周期调度 |
|
||||
| 组件 styleIsolation 不一致 | ❌ 撤回 | 默认 isolated / apply-shared 是合理分层,非问题 |
|
||||
| console.log 残留(信息泄露) | ⬇️ 降级 | 生产环境仅调试可见,非信息泄露;属代码整洁建议 |
|
||||
| 云环境 ID 硬编码(安全) | ⬇️ 降级 | 云环境 ID 非敏感(数据需 openid 鉴权);属工程化建议 |
|
||||
| 缺少 lazyCodeLoading | ✅ 保留 | 可选优化,收益有限(仅 7 个组件) |
|
||||
| 测试覆盖不足 | ✅ 保留 | 建议,非问题 |
|
||||
| 月份翻页无上限 | ✅ 保留 | 极低优先级体验瑕疵 |
|
||||
|
||||
## 撤回的 3 条(初版判断错误)
|
||||
|
||||
### 1. 隐私协议缺失 — 撤回
|
||||
- **初版判断**:app.json 没配 `__usePrivacyCheck__`,chooseAvatar 会报错,审核被拒。
|
||||
- **核实事实**:隐私协议配置在小程序 MP 后台(设置 → 服务内容声明 → 用户隐私保护指引),不在代码里。chooseAvatar 确属隐私接口需声明"用户信息",但小程序已审核上架运行一个月,证明后台已配好、隐私弹窗链路已打通。app.json 看不到是正常现象,`__usePrivacyCheck__` 仅本地调试用。
|
||||
- **结论**:非问题。
|
||||
|
||||
### 2. 云同步阻塞启动 — 撤回
|
||||
- **初版判断**:app.js:82 `await cloud.pullAll()` 阻塞启动链路,冷启动多等 2-3s。
|
||||
- **核实事实**:微信小程序采用「非阻塞生命周期调度」——框架调用 `App.onLaunch` 但**不 await 其返回的 Promise**,页面 `onLoad/onShow` 与 onLaunch 并行推进。因此 onLaunch 里的 `await cloud.pullAll()` 不阻塞首屏渲染,页面照常加载。await 之后的 `_restoreFromCloud` / `cloud.pushAll` 本就是后台云操作。且 `globalData.theme` 在 await 之前已赋值,页面取主题色无影响。
|
||||
- **结论**:非问题,机制理解错误。
|
||||
|
||||
### 3. 组件 styleIsolation 不一致 — 撤回
|
||||
- **初版判断**:5 个组件未配 styleIsolation(默认 isolated),3 个配 apply-shared,属"不一致问题"。
|
||||
- **核实事实**:这恰是合理的设计分层——需要继承页面主题色的组件(progress-ring 画主题色环、calendar-heatmap 渲染日历、custom-tab-bar 主题切换)配 `apply-shared`;样式完全自包含的通用组件(ui-card/btn/modal/skeleton/trend-chart)用默认 `isolated` 隔离。MEMORY.md 记录的"isolated 时样式穿透靠 CSS 变量"是使用注意事项,不是 isolated 本身的缺陷。
|
||||
- **结论**:非问题,符合组件化最佳实践。
|
||||
|
||||
## 降级的 2 条(定性过重)
|
||||
|
||||
### 4. console.log 残留 — 降级为代码整洁建议
|
||||
- **位置**:utils/cloud.js(18 处)、cloudfunctions/leaderboard/index.js(5 处)等
|
||||
- **核实事实**:小程序生产环境的 console 输出**仅开发者工具或真机调试时可见**,普通用户看不到,不存在"信息泄露给用户"的风险。openid 等标识确实会出现在日志里,但只有开发者自己调试时能看到。
|
||||
- **修正定性**:从"信息泄露隐患"降级为"代码整洁度"。保留 console.log 不影响功能与安全,但生产构建剔除更干净。可选优化。
|
||||
|
||||
### 5. 云环境 ID 硬编码 — 降级为工程化建议
|
||||
- **位置**:utils/cloud.js:2 `const ENV_ID = 'cloudbase-d1g56kl2q8f4f7d8a'`
|
||||
- **核实事实**:云环境 ID 本身**不是敏感凭据**——CloudBase 数据访问依赖 openid 鉴权与安全规则,光有环境 ID 无法读取他人数据。单环境个人项目硬编码完全可接受。
|
||||
- **修正定性**:从"安全/不利多环境"降级为"工程化建议"。仅当未来需要开发/生产双环境切换时才需抽出。可选。
|
||||
|
||||
## 保留的 3 条(可选优化 / 建议)
|
||||
|
||||
### 6. 缺少 lazyCodeLoading(可选优化)
|
||||
- **位置**:app.json
|
||||
- **事实**:未配 `"lazyCodeLoading": "requiredComponents"`。该项目仅 7 个自定义组件,按需注入对启动时间的实际提升有限(主要收益在组件数量多的大型小程序)。
|
||||
- **建议**:可加,低成本、无副作用,但别期待显著提速。
|
||||
|
||||
### 7. 测试覆盖不足(建议)
|
||||
- **位置**:tests/ 仅 3 个测试(badge-state、cloud-data-correctness、trend-state)
|
||||
- **事实**:核心计时器 Timer/CircuitTimer、云同步 cloud.js、语音 voice.js 等关键路径无单元测试。但项目已稳定运行一个月,说明逻辑经受了真实用户验证。
|
||||
- **建议**:补 timer/circuitTimer 状态机测试、storage 合并去重测试,利于后续重构信心。非阻塞项。
|
||||
|
||||
### 8. 记录页月份翻页无上限(极低优先级瑕疵)
|
||||
- **位置**:pages/records/records.js:158 onNextMonth
|
||||
- **事实**:可一直往后翻到未来月份,未来月份显示空数据。
|
||||
- **建议**:限制不超过当前月即可。极低优先级,不影响功能。
|
||||
|
||||
## 最终总体评价
|
||||
|
||||
经逐条核实,本项目代码质量**高于初版报告所暗示的水平**:
|
||||
|
||||
- 云同步 openid 预解析防重复 doc、排行榜快照预计算 + 客户端乐观缓存(热路径≈50ms)、计时器 Date.now 对齐无 drift、TTS 双层缓存防账单刷量、已知陷阱(组件隔离/图标色/字体缩放/云函数 data 包裹)均有防御——这些设计扎实可靠。
|
||||
- 初版报告的 8 条"问题"中,3 条是判断错误(已撤回),2 条定性过重(已降级),仅 3 条属真实可选优化,且无一是功能性 bug 或合规阻断。
|
||||
|
||||
**结论:当前无需紧急修复任何项。** 若有余力,按优先级可选做:补测试 > 加 lazyCodeLoading > 月份翻页上限 > 清理 console.log。云环境 ID 抽离等有双环境需求时再做。
|
||||
+165
-66
@@ -15,80 +15,54 @@ Page({
|
||||
todayDone: false,
|
||||
todayDuration: 0,
|
||||
todayTargetText: '',
|
||||
homeTip: '',
|
||||
// Ring number font size in rpx. WeChat auto-scales text by the user's
|
||||
// font-size setting (fontSizeScaleFactor) but does NOT scale rpx box
|
||||
// dimensions — so on a real device with enlarged system font the 84rpx
|
||||
// number grows past the fixed-size circular ring and gets clipped. We
|
||||
// cancel that scale here so the number renders at a constant visual size
|
||||
// (matching the devtools/simulator) on every device. See _readFontScale().
|
||||
targetTimeFontSize: 84,
|
||||
// 顶部问候区的用户资料(头像 + 昵称),来自 storage.getProfile()。
|
||||
// 空则回退占位(人形图标 + "运动达人"),settings 改完回首页即刷新。
|
||||
nickName: '',
|
||||
avatarUrl: '',
|
||||
streakCount: 0,
|
||||
planName: '',
|
||||
planDay: 1,
|
||||
planTotal: 7,
|
||||
planProgress: 0,
|
||||
useSegments: false,
|
||||
planDays: [],
|
||||
// hero / 进度地图 / 时长展示相关字段(由 refresh 生成)
|
||||
greetText: '你好',
|
||||
streakText: '今天开启坚持之旅',
|
||||
questNodes: [],
|
||||
questTip: '',
|
||||
estKcal: 0,
|
||||
showPicker: false,
|
||||
pickerInputFocus: false,
|
||||
presets: [30, 60, 90, 120, 180, 300],
|
||||
customDuration: 60,
|
||||
customInput: '',
|
||||
// --- 新版本更新通知(What's New) ---
|
||||
showUpdateNote: false,
|
||||
updateNoteVersion: '',
|
||||
updateNoteItems: [],
|
||||
updateNoteDate: '',
|
||||
|
||||
// --- Circuit (循环训练) config panel (persisted via storage.saveCircuitConfig) ---
|
||||
showCircuitPicker: false,
|
||||
cHold: 30,
|
||||
cSets: 4,
|
||||
cRest: 15,
|
||||
cSessions: 3,
|
||||
icons: iconsMod.build()
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
themeMod.applyThemeToPage(this)
|
||||
this._readFontScale()
|
||||
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() {
|
||||
themeMod.applyThemeToPage(this)
|
||||
this._checkUpdateNote()
|
||||
try {
|
||||
const tb = this.getTabBar()
|
||||
if (tb) tb.setData({ selected: 0 })
|
||||
if (tb) {
|
||||
tb.setData({ selected: 0 })
|
||||
tb.updateTheme()
|
||||
}
|
||||
} catch (e) {}
|
||||
// Skip the first onShow to avoid double-refresh; subsequent tab switches still refresh.
|
||||
if (this._firstShow) {
|
||||
@@ -125,6 +99,28 @@ Page({
|
||||
} 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() {
|
||||
this.refresh()
|
||||
wx.stopPullDownRefresh()
|
||||
@@ -143,15 +139,17 @@ Page({
|
||||
const target = planMod.getTodayTarget(settings.planId, clampedDay, customPlans)
|
||||
|
||||
const theme = themeMod.getCurrentTheme()
|
||||
// 防御:totalDays 为 0(极端数据损坏)时避免除以 0 得到 NaN
|
||||
const planProgress = plan.totalDays > 0 ? Math.round((clampedDay / plan.totalDays) * 100) : 0
|
||||
// 分段格子(每天一格)只在计划天数 <= 10 时用;更长则退化为里程碑连续条,避免格子过密
|
||||
const useSegments = plan.totalDays > 0 && plan.totalDays <= 10
|
||||
const planDays = useSegments ? Array.from({ length: plan.totalDays }, (_, i) => i) : []
|
||||
// 首页姿势要领:从 config.postureTips 随机取一条,每次刷新都换,
|
||||
// 让用户反复进首页能看到不同要领。配置为空时回退到默认文案,避免空白。
|
||||
const tips = config.postureTips || []
|
||||
const homeTip = tips.length ? tips[Math.floor(Math.random() * tips.length)] : '保持身体成一条直线,核心收紧,均匀呼吸'
|
||||
const profile = storage.getProfile()
|
||||
const greetText = this._greetByHour()
|
||||
const streakText = streak.count > 0
|
||||
? `已连续 ${streak.count} 天打卡`
|
||||
: '今天开启坚持之旅'
|
||||
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({
|
||||
theme,
|
||||
icons: iconsMod.build(theme),
|
||||
@@ -163,13 +161,60 @@ Page({
|
||||
planName: plan.name,
|
||||
planDay: clampedDay,
|
||||
planTotal: plan.totalDays,
|
||||
planProgress,
|
||||
useSegments,
|
||||
planDays,
|
||||
homeTip
|
||||
greetText,
|
||||
streakText,
|
||||
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() {
|
||||
wx.navigateTo({ url: '/pages/timer/timer' })
|
||||
},
|
||||
@@ -212,6 +257,60 @@ Page({
|
||||
wx.navigateTo({ url: `/pages/timer/timer?free=${dur}` })
|
||||
},
|
||||
|
||||
/**
|
||||
* 循环训练入口:打开配置弹窗,预填上次保存的设置(getCircuitConfig 含默认值
|
||||
* + 校验),用户改完确认后持久化并跳计时器。设置因此被保存下来——下次打开直接
|
||||
* 沿用,无需重填。
|
||||
*/
|
||||
onCircuitTrain() {
|
||||
const cfg = storage.getCircuitConfig()
|
||||
this.setData({
|
||||
showCircuitPicker: true,
|
||||
cHold: cfg.holdPerSet,
|
||||
cSets: cfg.sets,
|
||||
cRest: cfg.restPerSet,
|
||||
cSessions: cfg.sessionsPerWeek
|
||||
})
|
||||
},
|
||||
|
||||
onCloseCircuitPicker() {
|
||||
this.setData({ showCircuitPicker: false })
|
||||
},
|
||||
|
||||
onCircuitStep(e) {
|
||||
const field = e.currentTarget.dataset.field
|
||||
const delta = Number(e.currentTarget.dataset.delta) || 0
|
||||
const keyMap = { hold: 'cHold', sets: 'cSets', rest: 'cRest', sessions: 'cSessions' }
|
||||
const key = keyMap[field]
|
||||
if (!key) return
|
||||
const bounds = {
|
||||
hold: [5, 600],
|
||||
sets: [1, 50],
|
||||
rest: [0, 600],
|
||||
sessions: [1, 14]
|
||||
}
|
||||
let v = this.data[key] + delta
|
||||
const min = bounds[field][0]
|
||||
const max = bounds[field][1]
|
||||
if (v < min) v = min
|
||||
if (v > max) v = max
|
||||
this.setData({ [key]: v })
|
||||
},
|
||||
|
||||
onConfirmCircuit() {
|
||||
const cfg = {
|
||||
holdPerSet: this.data.cHold,
|
||||
sets: this.data.cSets,
|
||||
restPerSet: this.data.cRest,
|
||||
sessionsPerWeek: this.data.cSessions
|
||||
}
|
||||
storage.saveCircuitConfig(cfg)
|
||||
this.setData({ showCircuitPicker: false })
|
||||
wx.navigateTo({
|
||||
url: `/pages/timer/timer?circuit=1&hold=${cfg.holdPerSet}&sets=${cfg.sets}&rest=${cfg.restPerSet}&sessions=${cfg.sessionsPerWeek}`
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 分享给朋友。微信在用户点击右上角"···"菜单里的"转发给朋友"、
|
||||
* 或者页面内任何 `<button open-type="share">` / `<button open-type="share">`
|
||||
|
||||
+137
-90
@@ -1,84 +1,48 @@
|
||||
<view class="container page-enter" style="{{themeStyle}}">
|
||||
<view class="container page-enter index-page" style="{{themeStyle}}">
|
||||
|
||||
<!-- 顶部问候 -->
|
||||
<view class="greeting-row">
|
||||
<view class="greeting-text">
|
||||
<image class="greeting-icon" src="{{todayDone ? icons.successFill : icons.emojiFill}}" mode="aspectFit"></image>
|
||||
<text class="greeting-title">{{todayDone ? '太棒了!' : '准备好了吗?'}}</text>
|
||||
<!-- 全宽渐变头图 hero -->
|
||||
<view class="hero">
|
||||
<view class="hero-top">
|
||||
<!-- 圆形头像:无头像时白底 + peopleFill 占位(橙图标在白底清晰);
|
||||
有头像时透明描边,远程(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>
|
||||
<text class="greeting-sub">{{todayDone ? '继续保持这个势头' : '今天的平板支撑等着你'}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 连续打卡卡片:火焰连胜 + 分段进度 -->
|
||||
<ui-card variant="gradient in" class="{{justCompleted ? 'just-completed' : ''}}">
|
||||
<view class="header-card">
|
||||
<!-- 上区:连胜视觉重心 + 计划徽章 -->
|
||||
<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 class="streak-text">
|
||||
<text class="streak-num">{{streakCount}}</text>
|
||||
<text class="streak-label">连续打卡</text>
|
||||
</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>
|
||||
<!-- 闯关进度地图(上浮叠在 hero 下) -->
|
||||
<ui-card variant="in" index="0" class="quest-card {{justCompleted ? 'just-completed' : ''}}">
|
||||
<view class="quest-head">
|
||||
<text class="quest-title">{{planName}}<text class="quest-sub">第 {{planDay}} / {{planTotal}} 天</text></text>
|
||||
</view>
|
||||
<view class="quest-map">
|
||||
<block wx:for="{{questNodes}}" wx:key="label">
|
||||
<view class="q-node {{item.state}} {{item.final ? 'final' : ''}}">{{item.label}}</view>
|
||||
<view wx:if="{{index < questNodes.length - 1}}" class="q-link {{item.state === 'done' ? 'done' : ''}}"></view>
|
||||
</block>
|
||||
</view>
|
||||
<text class="quest-tip">{{questTip}}</text>
|
||||
</ui-card>
|
||||
|
||||
<!-- 今日目标卡片 -->
|
||||
<ui-card variant="in">
|
||||
<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>
|
||||
</view>
|
||||
</view>
|
||||
</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">
|
||||
@@ -86,28 +50,36 @@
|
||||
variant="primary"
|
||||
size="xl"
|
||||
block
|
||||
text="{{todayDone ? '再次训练' : '开始训练'}}"
|
||||
text="{{todayDone ? '再战一次 ' : '立即挑战 '}}{{todayTargetText}}"
|
||||
icon-src="{{icons.playWhite}}"
|
||||
bindtap="onStartTrain"
|
||||
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
|
||||
variant="ghost"
|
||||
size="md"
|
||||
block
|
||||
text="自由训练 · 自定义时长"
|
||||
icon-src="{{icons.timeFill}}"
|
||||
bindtap="onFreeTrain"
|
||||
></ui-btn>
|
||||
</view>
|
||||
<view class="action-row">
|
||||
<ui-btn
|
||||
class="action-cell"
|
||||
variant="ghost"
|
||||
size="xl"
|
||||
block
|
||||
text="自由训练"
|
||||
icon-src="{{icons.timeFill}}"
|
||||
bindtap="onFreeTrain"
|
||||
custom-style="border-radius:16rpx;"
|
||||
></ui-btn>
|
||||
|
||||
<!-- 小提示 -->
|
||||
<ui-card variant="soft" padded="{{false}}">
|
||||
<view class="tip-card">
|
||||
<image class="tip-icon" src="{{icons.lightFill}}" mode="aspectFit"></image>
|
||||
<text class="tip-text">{{homeTip}}</text>
|
||||
<ui-btn
|
||||
class="action-cell"
|
||||
variant="ghost"
|
||||
size="xl"
|
||||
block
|
||||
text="循环训练"
|
||||
icon-src="{{icons.repeatFill}}"
|
||||
bindtap="onCircuitTrain"
|
||||
custom-style="border-radius:16rpx;"
|
||||
></ui-btn>
|
||||
</view>
|
||||
</ui-card>
|
||||
</view>
|
||||
|
||||
<!-- 时间选择弹窗 -->
|
||||
<ui-modal
|
||||
@@ -147,7 +119,82 @@
|
||||
block
|
||||
text="开始自由训练"
|
||||
bindtap="onConfirmFree"
|
||||
custom-style="border-radius:16rpx;"
|
||||
></ui-btn>
|
||||
<text class="picker-cancel" bindtap="onClosePicker">取消</text>
|
||||
</ui-modal>
|
||||
|
||||
<!-- 循环训练配置弹窗:每组时长 / 组数 / 休息 / 每周次数,保存后持久化 -->
|
||||
<ui-modal
|
||||
visible="{{showCircuitPicker}}"
|
||||
position="bottom"
|
||||
bind:close="onCloseCircuitPicker"
|
||||
>
|
||||
<text class="picker-title">循环训练设置</text>
|
||||
<text class="picker-sub">自定义每组时长、组数与组间休息</text>
|
||||
|
||||
<view class="circuit-row">
|
||||
<text class="circuit-row-label">每组时长</text>
|
||||
<view class="stepper">
|
||||
<view class="stepper-btn" data-field="hold" data-delta="-10" bindtap="onCircuitStep">−</view>
|
||||
<text class="stepper-val">{{cHold}}秒</text>
|
||||
<view class="stepper-btn" data-field="hold" data-delta="10" bindtap="onCircuitStep">+</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="circuit-row">
|
||||
<text class="circuit-row-label">组数</text>
|
||||
<view class="stepper">
|
||||
<view class="stepper-btn" data-field="sets" data-delta="-1" bindtap="onCircuitStep">−</view>
|
||||
<text class="stepper-val">{{cSets}}组</text>
|
||||
<view class="stepper-btn" data-field="sets" data-delta="1" bindtap="onCircuitStep">+</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="circuit-row">
|
||||
<text class="circuit-row-label">组间休息</text>
|
||||
<view class="stepper">
|
||||
<view class="stepper-btn" data-field="rest" data-delta="-10" bindtap="onCircuitStep">−</view>
|
||||
<text class="stepper-val">{{cRest}}秒</text>
|
||||
<view class="stepper-btn" data-field="rest" data-delta="10" bindtap="onCircuitStep">+</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="circuit-row">
|
||||
<text class="circuit-row-label">每周目标</text>
|
||||
<view class="stepper">
|
||||
<view class="stepper-btn" data-field="sessions" data-delta="-1" bindtap="onCircuitStep">−</view>
|
||||
<text class="stepper-val">{{cSessions}}次/周</text>
|
||||
<view class="stepper-btn" data-field="sessions" data-delta="1" bindtap="onCircuitStep">+</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<ui-btn
|
||||
variant="primary"
|
||||
size="lg"
|
||||
block
|
||||
text="保存并开始"
|
||||
bindtap="onConfirmCircuit"
|
||||
custom-style="border-radius:16rpx;"
|
||||
></ui-btn>
|
||||
<text class="picker-cancel" bindtap="onCloseCircuitPicker">取消</text>
|
||||
</ui-modal>
|
||||
|
||||
<!-- 新版本更新通知(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>
|
||||
|
||||
+349
-272
@@ -1,342 +1,287 @@
|
||||
/* ---- greeting ---- */
|
||||
.greeting-row {
|
||||
padding: 16rpx 8rpx 24rpx;
|
||||
animation: cardIn 0.4s ease both;
|
||||
/* ===== 全宽渐变头图 hero ===== */
|
||||
/* 负 margin 抵消 container 的 24rpx 内边距,让头图左右贯穿并贴到导航栏底边;
|
||||
底部留大内边距给下方 quest-card 负 margin 上浮叠住。文字统一白色,图标用
|
||||
*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;
|
||||
align-items: center;
|
||||
margin-bottom: 8rpx;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.greeting-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
.hero-avatar {
|
||||
width: 84rpx;
|
||||
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;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.greeting-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
.hero-cta-text {
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.15);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.greeting-sub {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 60rpx;
|
||||
line-height: 1;
|
||||
.hero-social {
|
||||
margin-top: 0;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* ---- header card ---- */
|
||||
.header-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28rpx;
|
||||
padding: 4rpx 0;
|
||||
/* ===== 闯关进度地图(上浮卡片) ===== */
|
||||
.quest-card {
|
||||
margin-top: -56rpx;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* 上区:左连胜视觉重心,右计划徽章 */
|
||||
.streak-row {
|
||||
.quest-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
align-items: center;
|
||||
padding: 8rpx 4rpx;
|
||||
}
|
||||
|
||||
/* 火焰图标 + 光晕:光晕脉动让火焰"活"起来 */
|
||||
.streak-icon-wrap {
|
||||
position: relative;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
.q-node {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
background: var(--bg-soft);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@keyframes streakHalo {
|
||||
0%, 100% { transform: scale(0.85); opacity: 0.7; }
|
||||
50% { transform: scale(1.1); opacity: 1; }
|
||||
.q-node.done {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 计划徽章:移到上区右侧,半透明白底药丸,与火焰渐变卡片区分 */
|
||||
.plan-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
flex-shrink: 0;
|
||||
.q-node.now {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 6rpx rgba(var(--primary-rgb), 0.18);
|
||||
animation: qPulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.plan-badge-icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
margin-right: 8rpx;
|
||||
.q-node.final {
|
||||
background: #FFF3E0;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.plan-badge-text {
|
||||
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 {
|
||||
.q-link {
|
||||
flex: 1;
|
||||
height: 12rpx;
|
||||
border-radius: 6rpx;
|
||||
background: transparent;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.30);
|
||||
box-sizing: border-box;
|
||||
transition: background 0.3s ease, border-color 0.3s ease;
|
||||
height: 4rpx;
|
||||
background: var(--bg-soft);
|
||||
margin: 0 2rpx;
|
||||
}
|
||||
|
||||
.segment-cell.done {
|
||||
background: var(--on-primary, #FFFFFF);
|
||||
border-color: var(--on-primary, #FFFFFF);
|
||||
.q-link.done {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.segment-cell.current {
|
||||
background: var(--on-primary, #FFFFFF);
|
||||
border-color: var(--on-primary, #FFFFFF);
|
||||
animation: segmentPulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
.quest-tip {
|
||||
display: block;
|
||||
margin-top: 20rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
margin-top: 16rpx;
|
||||
@keyframes qPulse {
|
||||
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;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
text-align: center;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.done-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: rgba(var(--success-rgb), 0.08);
|
||||
padding: 10rpx 24rpx;
|
||||
border-radius: 24rpx;
|
||||
.time-display {
|
||||
display: block;
|
||||
font-family: 'Saira Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 200rpx;
|
||||
font-weight: 900;
|
||||
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 {
|
||||
width: 28rpx;
|
||||
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 {
|
||||
.time-label {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
margin: 8rpx 0 8rpx;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* ---- tip card ---- */
|
||||
.tip-card {
|
||||
/* 自由训练 / 循环训练 并排一行 */
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 24rpx 32rpx;
|
||||
background: transparent;
|
||||
border-radius: 20rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.tip-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
margin-right: 12rpx;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
.action-row .action-cell {
|
||||
flex: 1;
|
||||
min-width: 0; /* 防止图标+文字在窄屏撑破 flex 项 */
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
/* 主按钮脉冲发光(加在 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 ===== */
|
||||
.picker-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
@@ -427,14 +372,146 @@
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
/* ---- 训练完成高亮 (3 秒后淡出) ---- */
|
||||
/* ===== circuit (循环训练) config panel ===== */
|
||||
.picker-sub {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: var(--text-secondary);
|
||||
margin: -18rpx 0 24rpx;
|
||||
}
|
||||
|
||||
.circuit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 8rpx;
|
||||
border-bottom: 1rpx solid var(--border);
|
||||
}
|
||||
|
||||
.circuit-row-label {
|
||||
font-size: 30rpx;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.stepper-btn {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-soft);
|
||||
color: var(--primary);
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
transition: transform 0.12s ease, opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.stepper-btn:active {
|
||||
transform: scale(0.9);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stepper-val {
|
||||
min-width: 124rpx;
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ===== 训练完成高亮 (3 秒后淡出) ===== */
|
||||
/* 这个 class 落在 <ui-card> 宿主节点上(ui-card.wxss 已把宿主设为 display:block,
|
||||
否则 border/box-shadow 只会碎成两根竖条、transform 完全不生效)。
|
||||
描边用 box-shadow 的 spread 而非 border:border 会撑大宿主 8rpx,高亮出现/消失
|
||||
时整页布局跳动;box-shadow 不占布局空间。宿主自身无圆角,必须补上与内部卡片
|
||||
一致的 24rpx,否则描边会是直角框套在圆角卡片外面。 */
|
||||
.just-completed {
|
||||
animation: justCompletedPulse 1s ease-in-out 2;
|
||||
border: 4rpx solid var(--primary) !important;
|
||||
box-shadow: 0 4rpx 24rpx rgba(var(--primary-rgb), 0.3) !important;
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 0 0 4rpx var(--primary), 0 4rpx 24rpx rgba(var(--primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
@keyframes justCompletedPulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ Page({
|
||||
theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' },
|
||||
themeStyle: themeMod.BASE_VARS,
|
||||
periods: [
|
||||
{ key: 'day', label: '日榜' },
|
||||
{ key: 'month', label: '月榜' },
|
||||
{ key: 'year', label: '年榜' }
|
||||
{ key: 'day', label: '日榜', maxRank: config.leaderboardMaxRank },
|
||||
{ key: 'month', label: '月榜', maxRank: config.leaderboardMaxRank },
|
||||
{ key: 'year', label: '年榜', maxRank: config.leaderboardMaxRank },
|
||||
{ key: 'endurance', label: '耐力', maxRank: 10 }
|
||||
],
|
||||
activePeriod: 'day',
|
||||
podiumSlogan: config.leaderboardPodiumSlogan,
|
||||
rankedList: [],
|
||||
myEntry: null,
|
||||
loading: false,
|
||||
@@ -34,7 +36,10 @@ Page({
|
||||
this.setData({ theme, icons: iconsMod.build(theme) })
|
||||
try {
|
||||
const tb = this.getTabBar()
|
||||
if (tb) tb.setData({ selected: 2 })
|
||||
if (tb) {
|
||||
tb.setData({ selected: 2 })
|
||||
tb.updateTheme()
|
||||
}
|
||||
} catch (e) {}
|
||||
// 训练完设的强刷标志:下次进排行榜跳过云函数缓存,立刻拿到含新记录的数据
|
||||
let force = false
|
||||
@@ -63,11 +68,44 @@ Page({
|
||||
this.fetchRank()
|
||||
},
|
||||
|
||||
/**
|
||||
* totalDays → 最新解锁勋章(trainingDayBadges 按天数递增,取 days <= totalDays 最后一项)。
|
||||
* 返回 badge 定义(含 days/name/icon),无解锁或异常返回 null。
|
||||
* 云函数路径:item.totalDays 是云端算的(全榜通用,别人的行也能显示);
|
||||
* 本地兜底:用 storage 本地 totalDays 算自己那条。
|
||||
*/
|
||||
_badgeOf(totalDays) {
|
||||
try {
|
||||
const days = Math.max(0, Number(totalDays) || 0)
|
||||
const badges = config.trainingDayBadges || []
|
||||
let latest = null
|
||||
for (const b of badges) {
|
||||
if (days >= b.days) latest = b
|
||||
}
|
||||
return latest
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
/** 点击勋章:toast 展示勋章名与解锁天数门槛 */
|
||||
onBadgeTap(e) {
|
||||
const badge = e.currentTarget.dataset
|
||||
if (!badge || !badge.name) return
|
||||
wx.showToast({
|
||||
title: badge.name + ' · 累计 ' + badge.days + ' 天解锁',
|
||||
icon: 'none',
|
||||
duration: 1800
|
||||
})
|
||||
},
|
||||
|
||||
fetchRank(cb, force) {
|
||||
// 序列号:快速切换周期时丢弃旧响应,避免旧 period 的数据覆盖新 period
|
||||
this._fetchSeq = (this._fetchSeq || 0) + 1
|
||||
const seq = this._fetchSeq
|
||||
const period = this.data.activePeriod
|
||||
const periodObj = this.data.periods.find(p => p.key === period)
|
||||
const maxRank = (periodObj && periodObj.maxRank) || config.leaderboardMaxRank
|
||||
|
||||
// 乐观缓存:命中该 period 的近期缓存就先秒显旧数据,后台再静默拉新。
|
||||
// 命中时不进 loading 骨架屏(避免旧数据被空白覆盖),用 refreshing 标记
|
||||
@@ -91,7 +129,7 @@ Page({
|
||||
name: 'leaderboard',
|
||||
data: {
|
||||
period,
|
||||
maxRank: config.leaderboardMaxRank,
|
||||
maxRank,
|
||||
force: !!force
|
||||
}
|
||||
}).then(res => {
|
||||
@@ -103,7 +141,6 @@ Page({
|
||||
// local data silently. No toast here: empty day-leaderboard is
|
||||
// normal (user didn't train today), and a toast would mislead
|
||||
// them into thinking the cloud is broken.
|
||||
console.warn('[leaderboard] cloud function failed, using local fallback:', err)
|
||||
if (seq !== this._fetchSeq) { if (cb) cb(); return }
|
||||
// 已有缓存秒显时后台刷新失败:保持旧数据,不打断用户;仅无缓存才走本地兜底。
|
||||
if (cached && cached.rankedList) {
|
||||
@@ -116,15 +153,56 @@ Page({
|
||||
},
|
||||
|
||||
_applyResult(result) {
|
||||
const list = (result.ranked || []).map(item => ({
|
||||
...item,
|
||||
durationText: util.formatDuration(item.duration)
|
||||
}))
|
||||
const myEntry = result.myEntry ? {
|
||||
...result.myEntry,
|
||||
durationText: util.formatDuration(result.myEntry.duration),
|
||||
isMe: true
|
||||
} : null
|
||||
const isEndurance = this.data.activePeriod === 'endurance'
|
||||
const list = (result.ranked || []).map(item => {
|
||||
const base = { ...item, durationText: util.formatDuration(item.duration) }
|
||||
if (isEndurance) {
|
||||
const { date, time } = this._splitBestDate(item.bestDate)
|
||||
base.subText = this._formatBestDate(item.bestDate)
|
||||
base.subDate = date
|
||||
base.subTime = time
|
||||
} else {
|
||||
base.subText = `${item.sessions}次`
|
||||
}
|
||||
// 勋章:云函数下发每个用户的 totalDays,映射最新解锁勋章(全榜可见)。
|
||||
// 浅底行用主题色版 iconFill;旧快照无 totalDays 时 badge 为 null,不渲染。
|
||||
// 注意:badgeIcon 必须赋图标 data URI(从 icons 映射查),不能赋字符串 key,
|
||||
// 否则 WXML src 会当成路径加载报 500。
|
||||
const badge = this._badgeOf(item.totalDays)
|
||||
if (badge) {
|
||||
const icons = this.data.icons || iconsMod.build()
|
||||
base.badgeIcon = icons[badge.icon + 'Fill']
|
||||
base.badgeName = badge.name
|
||||
base.badgeDays = badge.days
|
||||
}
|
||||
return base
|
||||
})
|
||||
const myEntry = result.myEntry ? (() => {
|
||||
const base = {
|
||||
...result.myEntry,
|
||||
durationText: util.formatDuration(result.myEntry.duration),
|
||||
isMe: true
|
||||
}
|
||||
if (isEndurance) {
|
||||
const { date, time } = this._splitBestDate(result.myEntry.bestDate)
|
||||
base.subText = this._formatBestDate(result.myEntry.bestDate)
|
||||
base.subDate = date
|
||||
base.subTime = time
|
||||
} else {
|
||||
base.subText = `${result.myEntry.sessions}次`
|
||||
}
|
||||
// my-bar 是主题渐变底,需要白版图标;badgeIcon(主题色)给 is-me 行用。
|
||||
// 同样必须赋 data URI,不能赋字符串 key。
|
||||
const badge = this._badgeOf(result.myEntry.totalDays)
|
||||
if (badge) {
|
||||
const icons = this.data.icons || iconsMod.build()
|
||||
base.badgeIcon = icons[badge.icon + 'Fill']
|
||||
base.badgeIconWhite = icons[badge.icon + 'White']
|
||||
base.badgeName = badge.name
|
||||
base.badgeDays = badge.days
|
||||
}
|
||||
return base
|
||||
})() : null
|
||||
const empty = list.length === 0 && !myEntry
|
||||
this.setData({
|
||||
rankedList: list,
|
||||
@@ -141,11 +219,49 @@ Page({
|
||||
_applyLocal() {
|
||||
const period = this.data.activePeriod
|
||||
const allRecords = Object.values(storage.getRecords()).flat()
|
||||
const profile = storage.getProfile()
|
||||
const localName = profile.nickname || '我'
|
||||
|
||||
// 本地兜底勋章:从本地记录算去重训练日(与云函数 totalDays 口径一致),
|
||||
// 映射最新解锁勋章。云函数不可用时自己的行/my-bar 仍能显示勋章。
|
||||
// 注意:badgeIcon 必须是 data URI(从 icons 映射查),不能是字符串 key。
|
||||
const trainedDays = new Set()
|
||||
allRecords.forEach(r => { if (r && r.date) trainedDays.add(util.dateOnly(r.date)) })
|
||||
const localBadge = this._badgeOf(trainedDays.size)
|
||||
const _icons = this.data.icons || iconsMod.build()
|
||||
const localBadgeFields = localBadge
|
||||
? { badgeIcon: _icons[localBadge.icon + 'Fill'], badgeIconWhite: _icons[localBadge.icon + 'White'], badgeName: localBadge.name, badgeDays: localBadge.days }
|
||||
: {}
|
||||
|
||||
// 耐力榜本地兜底:取本地全部记录里单次最久 + 其日期
|
||||
if (period === 'endurance') {
|
||||
let bestDuration = 0
|
||||
let bestDate = ''
|
||||
allRecords.forEach(r => {
|
||||
const dur = Number(r.duration) || 0
|
||||
if (dur > bestDuration) { bestDuration = dur; bestDate = r.date }
|
||||
})
|
||||
if (bestDuration > 0) {
|
||||
const { date, time } = this._splitBestDate(bestDate)
|
||||
const entry = {
|
||||
rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '',
|
||||
duration: bestDuration, durationText: util.formatDuration(bestDuration),
|
||||
bestDate, subText: this._formatBestDate(bestDate), subDate: date, subTime: time,
|
||||
// 本地兜底也要 isMe:否则 is-me 行不渲染勋章、my-bar 条件(rank>length)又不满足,
|
||||
// 勋章在弱网/云函数故障时完全不可见
|
||||
isMe: true,
|
||||
...localBadgeFields
|
||||
}
|
||||
this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false })
|
||||
} else {
|
||||
this.setData({ loading: false, refreshing: false, empty: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const today = storage.getToday()
|
||||
const thisMonth = today.substring(0, 7)
|
||||
const thisYear = String(new Date().getFullYear())
|
||||
const profile = storage.getProfile()
|
||||
const localName = profile.nickname || '我'
|
||||
|
||||
let duration = 0
|
||||
let sessions = 0
|
||||
@@ -158,7 +274,11 @@ Page({
|
||||
if (duration > 0) {
|
||||
const entry = {
|
||||
rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '',
|
||||
duration, durationText: util.formatDuration(duration), sessions
|
||||
duration, durationText: util.formatDuration(duration), sessions,
|
||||
subText: `${sessions}次`,
|
||||
// 同耐力榜:本地兜底 entry 必须带 isMe,否则勋章不渲染
|
||||
isMe: true,
|
||||
...localBadgeFields
|
||||
}
|
||||
this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false })
|
||||
} else {
|
||||
@@ -166,6 +286,26 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 把记录里的 "YYYY-MM-DD HH:MM:SS" 截成 "YYYY-MM-DD HH:MM"(去掉秒),
|
||||
* 纯日期串原样返回。耐力榜用其展示单次的"产生时间"。
|
||||
*/
|
||||
_formatBestDate(s) {
|
||||
if (!s || typeof s !== 'string') return ''
|
||||
if (s.length >= 16) return s.substring(0, 16)
|
||||
return s
|
||||
},
|
||||
|
||||
/**
|
||||
* 把 "YYYY-MM-DD HH:MM"(来自 _formatBestDate)拆成
|
||||
* { date: 'YYYY-MM-DD', time: 'HH:MM' },供领奖台底座两行展示耐力榜的"单次日期"。
|
||||
*/
|
||||
_splitBestDate(s) {
|
||||
const f = this._formatBestDate(s)
|
||||
if (f && f.length >= 11) return { date: f.substring(0, 10), time: f.substring(11) }
|
||||
return { date: f || '', time: '' }
|
||||
},
|
||||
|
||||
/**
|
||||
* 分享给朋友 — 排行榜页。把用户当前名次写进标题,有"攀比"属性,
|
||||
* 分享欲望会强一些。
|
||||
@@ -175,7 +315,7 @@ Page({
|
||||
const s = config.share.leaderboard
|
||||
// myEntry.rank 依赖云函数返回,缺失时回退静态 title,避免出现"排第 undefined"
|
||||
if (me && me.rank) {
|
||||
const periodLabel = { day: '日', month: '月', year: '年' }[this.data.activePeriod] || ''
|
||||
const periodLabel = { day: '日', month: '月', year: '年', endurance: '耐力' }[this.data.activePeriod] || ''
|
||||
return {
|
||||
title: s.titleTemplate.replace('{period}', periodLabel).replace('{rank}', me.rank),
|
||||
path: s.path
|
||||
|
||||
@@ -41,38 +41,78 @@
|
||||
</view>
|
||||
|
||||
<block wx:else>
|
||||
<!-- Podium: top 3 (only when there are at least 3 entries) -->
|
||||
<view class="podium" wx:if="{{rankedList.length >= 3}}">
|
||||
<!-- 耐力榜领奖台顶部文案:哲理注脚,点出"撑得越久不一定越好" -->
|
||||
<view class="podium-slogan" wx:if="{{activePeriod === 'endurance'}}">{{podiumSlogan}}</view>
|
||||
|
||||
<!-- Podium: top 3 (shown whenever there is at least 1 entry; empty slots render as virtual placeholders) -->
|
||||
<view class="podium" wx:if="{{rankedList.length >= 1}}">
|
||||
<!-- 2nd -->
|
||||
<view class="podium-slot podium-slot--second rank-item--enter" style="animation-delay: 60ms;">
|
||||
<view class="podium-avatar-wrap">
|
||||
<view class="avatar avatar--md {{rankedList[1].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[1].avatarUrl || icons.peopleFill}}" mode="{{rankedList[1].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
|
||||
<view class="podium-medal podium-medal--silver">2</view>
|
||||
</view>
|
||||
<text class="podium-name">{{rankedList[1].name}}</text>
|
||||
<text class="podium-dur">{{rankedList[1].durationText}}</text>
|
||||
<view class="podium-base podium-base--second"><text class="podium-base__label">{{rankedList[1].sessions}}次</text></view>
|
||||
<block wx:if="{{rankedList[1]}}">
|
||||
<view class="podium-avatar-wrap">
|
||||
<view class="avatar avatar--md {{rankedList[1].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[1].avatarUrl || icons.peopleFill}}" mode="{{rankedList[1].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
|
||||
<view class="podium-medal podium-medal--silver">2</view>
|
||||
</view>
|
||||
<view class="podium-name-row">
|
||||
<text class="podium-name">{{rankedList[1].name}}</text>
|
||||
<image wx:if="{{rankedList[1].badgeIcon}}" class="podium-badge" src="{{rankedList[1].badgeIcon}}" mode="aspectFit" data-name="{{rankedList[1].badgeName}}" data-days="{{rankedList[1].badgeDays}}" bindtap="onBadgeTap"></image>
|
||||
</view>
|
||||
<text class="podium-dur">{{rankedList[1].durationText}}</text>
|
||||
<view class="podium-base podium-base--second"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[1].subDate}}</text><text class="podium-base__time">{{rankedList[1].subTime}}</text></block><text wx:else>{{rankedList[1].subText}}</text></view></view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<view class="podium-avatar-wrap">
|
||||
<view class="avatar avatar--md podium-avatar--empty"><image class="avatar__img podium-avatar__img--empty" src="{{icons.peopleFill}}" mode="aspectFit"></image></view>
|
||||
<view class="podium-medal podium-medal--silver">2</view>
|
||||
</view>
|
||||
<view class="podium-base podium-base--second"><view class="podium-base__label"><text class="podium-empty-label">虚位以待</text></view></view>
|
||||
</block>
|
||||
</view>
|
||||
<!-- 1st -->
|
||||
<view class="podium-slot podium-slot--first rank-item--enter" style="animation-delay: 0ms;">
|
||||
<image class="podium-crown" src="{{icons.crownFill}}" mode="aspectFit"></image>
|
||||
<view class="podium-avatar-wrap">
|
||||
<view class="avatar avatar--md avatar--lg {{rankedList[0].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[0].avatarUrl || icons.peopleFill}}" mode="{{rankedList[0].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
|
||||
<view class="podium-medal podium-medal--gold">1</view>
|
||||
</view>
|
||||
<text class="podium-name podium-name--first">{{rankedList[0].name}}</text>
|
||||
<text class="podium-dur podium-dur--first">{{rankedList[0].durationText}}</text>
|
||||
<view class="podium-base podium-base--first"><text class="podium-base__label">{{rankedList[0].sessions}}次</text></view>
|
||||
<block wx:if="{{rankedList[0]}}">
|
||||
<image class="podium-crown" src="{{icons.crownFill}}" mode="aspectFit"></image>
|
||||
<view class="podium-avatar-wrap">
|
||||
<view class="avatar avatar--md avatar--lg {{rankedList[0].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[0].avatarUrl || icons.peopleFill}}" mode="{{rankedList[0].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
|
||||
<view class="podium-medal podium-medal--gold">1</view>
|
||||
</view>
|
||||
<view class="podium-name-row">
|
||||
<text class="podium-name podium-name--first">{{rankedList[0].name}}</text>
|
||||
<image wx:if="{{rankedList[0].badgeIcon}}" class="podium-badge podium-badge--first" src="{{rankedList[0].badgeIcon}}" mode="aspectFit" data-name="{{rankedList[0].badgeName}}" data-days="{{rankedList[0].badgeDays}}" bindtap="onBadgeTap"></image>
|
||||
</view>
|
||||
<text class="podium-dur podium-dur--first">{{rankedList[0].durationText}}</text>
|
||||
<view class="podium-base podium-base--first"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[0].subDate}}</text><text class="podium-base__time">{{rankedList[0].subTime}}</text></block><text wx:else>{{rankedList[0].subText}}</text></view></view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<image class="podium-crown podium-crown--empty" src="{{icons.crownFill}}" mode="aspectFit"></image>
|
||||
<view class="podium-avatar-wrap">
|
||||
<view class="avatar avatar--md avatar--lg podium-avatar--empty"><image class="avatar__img podium-avatar__img--empty" src="{{icons.peopleFill}}" mode="aspectFit"></image></view>
|
||||
<view class="podium-medal podium-medal--gold">1</view>
|
||||
</view>
|
||||
<view class="podium-base podium-base--first"><view class="podium-base__label"><text class="podium-empty-label">虚位以待</text></view></view>
|
||||
</block>
|
||||
</view>
|
||||
<!-- 3rd -->
|
||||
<view class="podium-slot podium-slot--third rank-item--enter" style="animation-delay: 120ms;">
|
||||
<view class="podium-avatar-wrap">
|
||||
<view class="avatar avatar--md {{rankedList[2].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[2].avatarUrl || icons.peopleFill}}" mode="{{rankedList[2].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
|
||||
<view class="podium-medal podium-medal--bronze">3</view>
|
||||
</view>
|
||||
<text class="podium-name">{{rankedList[2].name}}</text>
|
||||
<text class="podium-dur">{{rankedList[2].durationText}}</text>
|
||||
<view class="podium-base podium-base--third"><text class="podium-base__label">{{rankedList[2].sessions}}次</text></view>
|
||||
<block wx:if="{{rankedList[2]}}">
|
||||
<view class="podium-avatar-wrap">
|
||||
<view class="avatar avatar--md {{rankedList[2].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[2].avatarUrl || icons.peopleFill}}" mode="{{rankedList[2].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
|
||||
<view class="podium-medal podium-medal--bronze">3</view>
|
||||
</view>
|
||||
<view class="podium-name-row">
|
||||
<text class="podium-name">{{rankedList[2].name}}</text>
|
||||
<image wx:if="{{rankedList[2].badgeIcon}}" class="podium-badge" src="{{rankedList[2].badgeIcon}}" mode="aspectFit" data-name="{{rankedList[2].badgeName}}" data-days="{{rankedList[2].badgeDays}}" bindtap="onBadgeTap"></image>
|
||||
</view>
|
||||
<text class="podium-dur">{{rankedList[2].durationText}}</text>
|
||||
<view class="podium-base podium-base--third"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[2].subDate}}</text><text class="podium-base__time">{{rankedList[2].subTime}}</text></block><text wx:else>{{rankedList[2].subText}}</text></view></view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<view class="podium-avatar-wrap">
|
||||
<view class="avatar avatar--md podium-avatar--empty"><image class="avatar__img podium-avatar__img--empty" src="{{icons.peopleFill}}" mode="aspectFit"></image></view>
|
||||
<view class="podium-medal podium-medal--bronze">3</view>
|
||||
</view>
|
||||
<view class="podium-base podium-base--third"><view class="podium-base__label"><text class="podium-empty-label">虚位以待</text></view></view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -81,7 +121,7 @@
|
||||
<view
|
||||
wx:for="{{rankedList}}"
|
||||
wx:key="rank"
|
||||
wx:if="{{rankedList.length < 3 || index >= 3}}"
|
||||
wx:if="{{index >= 3}}"
|
||||
class="rank-item {{item.isMe ? 'is-me' : ''}} rank-item--enter"
|
||||
style="animation-delay: {{index < 10 ? index * 60 : 0}}ms;"
|
||||
>
|
||||
@@ -90,8 +130,21 @@
|
||||
</view>
|
||||
<view class="avatar avatar--sm {{item.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{item.avatarUrl || icons.peopleFill}}" mode="{{item.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
|
||||
<view class="rank-info">
|
||||
<text class="rank-name">{{item.name}}</text>
|
||||
<text class="rank-sessions">{{item.sessions}}次</text>
|
||||
<view class="rank-name-row">
|
||||
<text class="rank-name">{{item.name}}</text>
|
||||
<!-- 每个用户的勋章:云函数下发 totalDays,浅底行用主题色版。
|
||||
无勋章(未达3天)或旧快照无 totalDays 时不渲染 -->
|
||||
<image
|
||||
wx:if="{{item.badgeIcon}}"
|
||||
class="rank-badge"
|
||||
src="{{item.badgeIcon}}"
|
||||
mode="aspectFit"
|
||||
data-name="{{item.badgeName}}"
|
||||
data-days="{{item.badgeDays}}"
|
||||
bindtap="onBadgeTap"
|
||||
></image>
|
||||
</view>
|
||||
<text class="rank-sessions">{{item.subText}}</text>
|
||||
</view>
|
||||
<text class="rank-dur">{{item.durationText}}</text>
|
||||
</view>
|
||||
@@ -103,8 +156,20 @@
|
||||
<view class="rank-num my-bar__num">{{myEntry.rank}}</view>
|
||||
<view class="avatar avatar--sm my-bar__avatar {{myEntry.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{myEntry.avatarUrl || icons.peopleFill}}" mode="{{myEntry.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
|
||||
<view class="rank-info">
|
||||
<text class="rank-name">我 ({{myEntry.name}})</text>
|
||||
<text class="rank-sessions">{{myEntry.sessions}}次</text>
|
||||
<view class="rank-name-row">
|
||||
<text class="rank-name">我 ({{myEntry.name}})</text>
|
||||
<!-- 我的勋章:主题渐变底用白版 -->
|
||||
<image
|
||||
wx:if="{{myEntry.badgeIconWhite}}"
|
||||
class="rank-badge"
|
||||
src="{{myEntry.badgeIconWhite}}"
|
||||
mode="aspectFit"
|
||||
data-name="{{myEntry.badgeName}}"
|
||||
data-days="{{myEntry.badgeDays}}"
|
||||
bindtap="onBadgeTap"
|
||||
></image>
|
||||
</view>
|
||||
<text class="rank-sessions">{{myEntry.subText}}</text>
|
||||
</view>
|
||||
<text class="rank-dur">{{myEntry.durationText}}</text>
|
||||
</view>
|
||||
|
||||
@@ -69,6 +69,23 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 耐力榜领奖台顶部文案:哲理注脚,主色呼应 + 金句卡片质感 */
|
||||
.podium-slogan {
|
||||
position: relative;
|
||||
margin: 8rpx 32rpx 4rpx;
|
||||
padding: 22rpx 28rpx 24rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.10), rgba(var(--primary-rgb), 0.03));
|
||||
border: 1rpx solid rgba(var(--primary-rgb), 0.16);
|
||||
border-radius: 18rpx;
|
||||
box-shadow: 0 4rpx 14rpx rgba(var(--primary-rgb), 0.10);
|
||||
}
|
||||
|
||||
/* ---- podium (top 3) ---- */
|
||||
.podium {
|
||||
display: flex;
|
||||
@@ -177,16 +194,42 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.3;
|
||||
/* 用 name 的 margin-top 直接控制与上方头像/角标的间距:它是 flex 子元素,
|
||||
margin 必定生效不折叠,不依赖 avatar margin-bottom 的 collapse 行为
|
||||
(WebView/Skyline 下不一致,曾导致角标压住昵称)。 */
|
||||
margin-top: 30rpx;
|
||||
/* 关键对齐修复:行盒高度设为与勋章等高(30rpx)。这样 flex 的 align-items:center
|
||||
对齐的是"昵称盒中心"与"勋章盒中心"两个等高盒,而非 line-height:1 下偏矮、
|
||||
且中文 glyph 视觉重心偏下的行盒,图标不会再飘在昵称上方。 */
|
||||
line-height: 30rpx;
|
||||
}
|
||||
|
||||
.podium-name--first {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
line-height: 30rpx; /* 与首名勋章统一为 30rpx,前三名勋章等大且和昵称盒等高对齐 */
|
||||
}
|
||||
|
||||
/* 领奖台名字 + 勋章横排(居中)。名字保留自身 max-width/省略,勋章固定不挤压 */
|
||||
.podium-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
max-width: 220rpx;
|
||||
/* 原间距 margin-top:30rpx 从 .podium-name 移到此处:避免在 flex 子项上用
|
||||
cross 轴 margin 干扰昵称与勋章的居中(那是之前图标"偏高"的隐藏元凶)。 */
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
|
||||
.podium-badge {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
flex-shrink: 0;
|
||||
/* 不再用 margin-top 盲推:昵称行盒已设为与勋章等高(30rpx),flex align-items:center
|
||||
直接对齐两盒中心,图标与昵称视觉对齐。若个别冠类勋章因图形重心仍有 ±1~2rpx
|
||||
偏差,再单独给该图加 transform: translateY(2rpx) 即可,不要回退到 margin 推法。 */
|
||||
}
|
||||
|
||||
.podium-badge--first {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
}
|
||||
|
||||
/* session count printed on the podium base. Pinned a fixed offset above the
|
||||
@@ -194,21 +237,40 @@
|
||||
stepped base heights (the podium itself is bottom-aligned via flex-end). */
|
||||
.podium-base__label {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 10rpx;
|
||||
left: 4rpx;
|
||||
right: 4rpx;
|
||||
bottom: 6rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: 1;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
/* 耐力榜副标签(单次日期)在领奖台底座上拆成「日期 + 时间」两行。
|
||||
缩小字号 + 抬高最矮的第 3 名底座(48→64rpx),三阶统一为 96/80/64,
|
||||
让两行文字在各级底座内都垂直居中、不贴顶(含微信字体缩放余量)。 */
|
||||
.podium-base__date {
|
||||
font-size: 18rpx;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.podium-base__time {
|
||||
font-size: 16rpx;
|
||||
line-height: 1.1;
|
||||
opacity: 0.85;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.podium-dur {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2rpx;
|
||||
/* line-height 收紧后行盒缩短约 8rpx,此处补偿保持与名字的视觉间距 */
|
||||
margin-top: 10rpx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -232,16 +294,40 @@
|
||||
box-shadow: 0 6rpx 18rpx rgba(250, 140, 22, 0.30), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.podium-base--second {
|
||||
height: 64rpx;
|
||||
height: 80rpx;
|
||||
background: linear-gradient(180deg, rgba(201, 205, 212, 0.50), rgba(140, 140, 140, 0.16));
|
||||
box-shadow: 0 6rpx 18rpx rgba(140, 140, 140, 0.22), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.podium-base--third {
|
||||
height: 48rpx;
|
||||
height: 64rpx;
|
||||
background: linear-gradient(180deg, rgba(232, 160, 106, 0.50), rgba(212, 107, 8, 0.16));
|
||||
box-shadow: 0 6rpx 18rpx rgba(212, 107, 8, 0.22), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
/* 虚拟领奖台:空位占位(名单不足 3 名时,空缺名次渲染成"虚位以待") */
|
||||
.podium-avatar--empty {
|
||||
opacity: 1;
|
||||
border: 4rpx dashed rgba(var(--primary-rgb), 0.35);
|
||||
box-shadow: none;
|
||||
background: rgba(var(--primary-rgb), 0.04);
|
||||
}
|
||||
|
||||
.podium-avatar__img--empty {
|
||||
opacity: 0.26;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.podium-empty-label {
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.podium-crown--empty {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Rank list */
|
||||
.rank-list {
|
||||
padding: 16rpx 24rpx;
|
||||
@@ -305,6 +391,25 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 昵称 + 最新勋章横排:昵称可省略,勋章固定不挤压 */
|
||||
.rank-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rank-name-row .rank-name {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rank-sessions {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-secondary);
|
||||
|
||||
+88
-63
@@ -3,7 +3,12 @@ const util = require('../../utils/util')
|
||||
const themeMod = require('../../utils/theme')
|
||||
const iconsMod = require('../../utils/icons')
|
||||
const config = require('../../config')
|
||||
const { getBadgeProgress, getBadgeCelebration } = require('../../utils/badge-state')
|
||||
const {
|
||||
getBadgePositions,
|
||||
getBadgeSegments,
|
||||
getBadgeDisplayLabel,
|
||||
getBadgeCelebration
|
||||
} = require('../../utils/badge-state')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -17,15 +22,20 @@ Page({
|
||||
maxDurationText: '',
|
||||
totalDays: 0,
|
||||
trainingBadges: [],
|
||||
badgeProgress: 0,
|
||||
badgeSegments: [],
|
||||
nextBadgeHint: '',
|
||||
currentYear: new Date().getFullYear(),
|
||||
currentMonth: new Date().getMonth() + 1,
|
||||
monthRecords: [],
|
||||
historyList: [],
|
||||
listMode: 'month',
|
||||
contentMode: 'records',
|
||||
trendMode: 'week',
|
||||
trendData: [],
|
||||
trendSummary: {},
|
||||
bestTrendData: [],
|
||||
bestTrendSummary: {},
|
||||
bestLineColor: '#FF6B35',
|
||||
showDayDetail: false,
|
||||
dayDetail: {},
|
||||
icons: iconsMod.build()
|
||||
@@ -40,7 +50,10 @@ Page({
|
||||
themeMod.applyThemeToPage(this)
|
||||
try {
|
||||
const tb = this.getTabBar()
|
||||
if (tb) tb.setData({ selected: 1 })
|
||||
if (tb) {
|
||||
tb.setData({ selected: 1 })
|
||||
tb.updateTheme()
|
||||
}
|
||||
} catch (e) {}
|
||||
if (this._firstShow) {
|
||||
this._firstShow = false
|
||||
@@ -56,10 +69,19 @@ Page({
|
||||
},
|
||||
|
||||
refresh() {
|
||||
storage.validateStreak()
|
||||
const streak = storage.validateStreak()
|
||||
const stats = storage.getTotalStats()
|
||||
const monthKey = `${this.data.currentYear}-${String(this.data.currentMonth).padStart(2, '0')}`
|
||||
const records = (storage.getRecordsByMonth(monthKey) || []).map(r => ({ ...r, durationText: util.formatTime(r.duration) }))
|
||||
// Decorate each record with a display string for circuit (循环) sessions.
|
||||
const withText = (r) => {
|
||||
const base = { ...r, durationText: util.formatTime(r.duration) }
|
||||
if (r.mode === 'circuit') {
|
||||
const rest = r.restPerSet ? ` · 休${r.restPerSet}s` : ''
|
||||
base.circuitText = `循环 ${r.sets}组×${r.holdPerSet}s${rest}`
|
||||
}
|
||||
return base
|
||||
}
|
||||
const records = (storage.getRecordsByMonth(monthKey) || []).map(withText)
|
||||
|
||||
// Each month is already sorted newest-first in storage. To get top 30 most recent,
|
||||
// walk months in reverse chrono order and concat — O(months*30) instead of O(n log n).
|
||||
@@ -68,27 +90,24 @@ Page({
|
||||
const historyList = []
|
||||
for (const mk of monthKeys) {
|
||||
for (const r of byMonth[mk]) {
|
||||
historyList.push({ ...r, durationText: util.formatTime(r.duration) })
|
||||
historyList.push(withText(r))
|
||||
if (historyList.length >= 30) break
|
||||
}
|
||||
if (historyList.length >= 30) break
|
||||
}
|
||||
|
||||
// 累计训练天数勋章(里程碑进度条):统计口径=累计去重训练日(可中断)。
|
||||
// 节点等距排列(间距一致、视觉整齐),填充在区间内线性插值,
|
||||
// 解锁时填充正好抵达该节点,满级时填充满格。
|
||||
// 节点等距排列;每个连接段独立填充,绿色铺满即解锁下一枚勋章。
|
||||
const totalDays = stats.totalDays
|
||||
const badgeDefs = config.trainingDayBadges
|
||||
const n = badgeDefs.length
|
||||
const TRACK_PAD = 4 // 首尾留白,保证首尾节点/标签不溢出
|
||||
const segWidth = n > 1 ? (100 - TRACK_PAD * 2) / (n - 1) : 0
|
||||
const posOf = (i) => TRACK_PAD + i * segWidth
|
||||
const badgePositions = getBadgePositions(badgeDefs)
|
||||
const trainingBadges = badgeDefs.map((b, i) => ({
|
||||
...b,
|
||||
unlocked: totalDays >= b.days,
|
||||
pos: Math.round(posOf(i) * 10) / 10
|
||||
pos: badgePositions[i],
|
||||
label: getBadgeDisplayLabel(b, totalDays >= b.days)
|
||||
}))
|
||||
const badgeProgress = getBadgeProgress(badgeDefs, totalDays)
|
||||
const badgeSegments = getBadgeSegments(badgeDefs, totalDays)
|
||||
|
||||
// 单枚解锁庆祝反馈:对比持久化"已见解锁集合",只庆祝新增勋章。
|
||||
// 首次运行(无存储)仅初始化集合,避免老用户一打开就被历史勋章刷屏。
|
||||
@@ -101,6 +120,9 @@ Page({
|
||||
: `还差 ${badgeDefs[nextIdx].days - totalDays} 天解锁「${badgeDefs[nextIdx].name}」`
|
||||
|
||||
const theme = themeMod.getCurrentTheme()
|
||||
const trendMode = this.data.trendMode || 'week'
|
||||
const trendData = this._computeTrend(trendMode)
|
||||
const bestTrendData = this._computeBestTrend(trendMode)
|
||||
this.setData({
|
||||
theme,
|
||||
icons: iconsMod.build(theme),
|
||||
@@ -111,13 +133,17 @@ Page({
|
||||
maxDurationText: util.formatDuration(stats.maxDuration),
|
||||
totalDays,
|
||||
trainingBadges,
|
||||
badgeProgress,
|
||||
badgeSegments,
|
||||
nextBadgeHint,
|
||||
monthRecords: records,
|
||||
historyList,
|
||||
listMode: this.data.listMode || 'month',
|
||||
displayList: (this.data.listMode || 'month') === 'month' ? records : historyList,
|
||||
trendData: this._computeTrend(this.data.trendMode || 'week'),
|
||||
trendData,
|
||||
trendSummary: { ...util.getTrendSummary(trendData, trendMode), streakCount: Number(streak.count) || 0 },
|
||||
bestTrendData,
|
||||
bestTrendSummary: this._buildBestSummary(bestTrendData, trendMode),
|
||||
bestLineColor: theme.primary,
|
||||
loading: false
|
||||
})
|
||||
},
|
||||
@@ -135,14 +161,23 @@ Page({
|
||||
},
|
||||
|
||||
onNextMonth() {
|
||||
let { currentYear, currentMonth } = this.data
|
||||
if (currentMonth === 12) {
|
||||
currentYear++
|
||||
currentMonth = 1
|
||||
} else {
|
||||
currentMonth++
|
||||
const { currentYear, currentMonth } = this.data
|
||||
// 计算下一月
|
||||
let nextYear = currentYear
|
||||
let nextMonth = currentMonth + 1
|
||||
if (nextMonth > 12) {
|
||||
nextYear++
|
||||
nextMonth = 1
|
||||
}
|
||||
this.setData({ currentYear, currentMonth })
|
||||
// 上限:不能翻到未来月份。当前月是最新有数据的月份,再往后无意义。
|
||||
const now = new Date()
|
||||
const nowYear = now.getFullYear()
|
||||
const nowMonth = now.getMonth() + 1
|
||||
if (nextYear > nowYear || (nextYear === nowYear && nextMonth > nowMonth)) {
|
||||
wx.showToast({ title: '已经是最新月份', icon: 'none', duration: 1200 })
|
||||
return
|
||||
}
|
||||
this.setData({ currentYear: nextYear, currentMonth: nextMonth })
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
@@ -153,10 +188,25 @@ Page({
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
onToggleContentMode(e) {
|
||||
const mode = e.currentTarget.dataset.mode
|
||||
if (!['records', 'trend'].includes(mode) || mode === this.data.contentMode) return
|
||||
this.setData({ contentMode: mode })
|
||||
},
|
||||
|
||||
onToggleTrendMode(e) {
|
||||
const mode = e.currentTarget.dataset.mode
|
||||
if (!mode || mode === this.data.trendMode) return
|
||||
this.setData({ trendMode: mode, trendData: this._computeTrend(mode) })
|
||||
const trendData = this._computeTrend(mode)
|
||||
const bestTrendData = this._computeBestTrend(mode)
|
||||
this.setData({
|
||||
trendMode: mode,
|
||||
trendData,
|
||||
trendSummary: { ...util.getTrendSummary(trendData, mode), streakCount: Number(storage.getStreak().count) || 0 },
|
||||
bestTrendData,
|
||||
bestTrendSummary: this._buildBestSummary(bestTrendData, mode),
|
||||
bestLineColor: themeMod.getCurrentTheme().primary
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -166,46 +216,21 @@ Page({
|
||||
* 返回 [{ label, value, valueText, highlight }] 供 trend-chart 渲染。
|
||||
*/
|
||||
_computeTrend(mode) {
|
||||
const allRecords = Object.values(storage.getRecords()).flat()
|
||||
const today = new Date()
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return util.getTrendData(storage.getRecords(), mode)
|
||||
},
|
||||
|
||||
if (mode === 'month') {
|
||||
const data = []
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(today.getFullYear(), today.getMonth() - i, 1)
|
||||
const monthKey = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`
|
||||
const monthRecords = storage.getRecordsByMonth(monthKey) || []
|
||||
const total = monthRecords.reduce((s, r) => s + (Number(r.duration) || 0), 0)
|
||||
data.push({
|
||||
label: `${d.getMonth() + 1}月`,
|
||||
value: total,
|
||||
valueText: total > 0 ? util.formatDuration(total) : '',
|
||||
highlight: i === 0
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
_computeBestTrend(mode) {
|
||||
return util.getBestTrendData(storage.getRecords(), mode)
|
||||
},
|
||||
|
||||
// default: week (近 7 天)
|
||||
const data = []
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(today)
|
||||
d.setDate(d.getDate() - i)
|
||||
const dateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
const total = allRecords
|
||||
.filter((r) => util.dateOnly(r.date) === dateStr)
|
||||
.reduce((s, r) => s + (Number(r.duration) || 0), 0)
|
||||
const isToday = i === 0
|
||||
data.push({
|
||||
label: isToday ? '今天' : weekdays[d.getDay()],
|
||||
value: total,
|
||||
valueText: total > 0 ? util.formatDuration(total) : '',
|
||||
highlight: isToday
|
||||
})
|
||||
}
|
||||
return data
|
||||
/**
|
||||
* 个人最佳趋势的峰值摘要:复用通用 getTrendSummary,但把 peakTitle 从
|
||||
* "最高训练日/月" 覆盖成 "个人最佳日/月",贴合"个人最佳趋势"区块语义。
|
||||
*/
|
||||
_buildBestSummary(bestTrendData, mode) {
|
||||
const s = util.getTrendSummary(bestTrendData, mode)
|
||||
s.peakTitle = mode === 'month' ? '个人最佳月' : '个人最佳日'
|
||||
return s
|
||||
},
|
||||
|
||||
onDayTap(e) {
|
||||
@@ -216,7 +241,7 @@ Page({
|
||||
// 日期部分,直接 === 永远不匹配,必须用 dateOnly 截取后再比。
|
||||
const sessions = allRecords.filter(r => util.dateOnly(r.date) === dateStr)
|
||||
const totalDur = sessions.reduce((sum, r) => sum + r.duration, 0)
|
||||
const calories = Math.round(totalDur * 0.068)
|
||||
const calories = Math.round(totalDur * (config.caloriesPerSecond || 0.068))
|
||||
this.setData({
|
||||
showDayDetail: true,
|
||||
dayDetail: {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"usingComponents": {
|
||||
"calendar-heatmap": "/components/calendar-heatmap/calendar-heatmap",
|
||||
"trend-chart": "/components/trend-chart/trend-chart",
|
||||
"ui-card": "/components/ui-card/ui-card"
|
||||
"ui-card": "/components/ui-card/ui-card",
|
||||
"ui-skeleton": "/components/ui-skeleton/ui-skeleton"
|
||||
},
|
||||
"navigationBarTitleText": "训练记录"
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</view>
|
||||
|
||||
<!-- 统计卡片 (有数据时才显示) -->
|
||||
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
||||
<ui-card variant="in" index="0" hidden="{{totalSessions === 0}}">
|
||||
<view class="stats">
|
||||
<view class="stat-item">
|
||||
<image class="stat-icon" src="{{icons.rankFill}}" mode="aspectFit"></image>
|
||||
@@ -45,7 +45,7 @@
|
||||
</ui-card>
|
||||
|
||||
<!-- 训练勋章 (里程碑进度条) -->
|
||||
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
||||
<ui-card variant="in" index="1" hidden="{{totalSessions === 0}}">
|
||||
<view class="badge-section">
|
||||
<view class="badge-header">
|
||||
<view class="badge-title-wrap">
|
||||
@@ -59,7 +59,10 @@
|
||||
<text class="badge-status-next" wx:if="{{nextBadgeHint}}">{{nextBadgeHint}}</text>
|
||||
</view>
|
||||
<view class="badge-track">
|
||||
<view class="badge-fill" style="width: {{badgeProgress}}%;"></view>
|
||||
<view class="badge-segment" wx:for="{{badgeSegments}}" wx:key="index"
|
||||
style="left: {{item.left}}%; width: {{item.width}}%;">
|
||||
<view class="badge-segment-fill" style="width: {{item.progress}}%;"></view>
|
||||
</view>
|
||||
<view class="badge-node {{item.unlocked ? 'unlocked' : ''}} {{item.justUnlocked ? 'celebrate' : ''}}"
|
||||
wx:for="{{trainingBadges}}" wx:key="days"
|
||||
style="left: {{item.pos}}%;"
|
||||
@@ -72,13 +75,20 @@
|
||||
<view class="badge-labels">
|
||||
<text class="badge-label {{item.unlocked ? 'unlocked' : ''}}"
|
||||
wx:for="{{trainingBadges}}" wx:key="days"
|
||||
style="left: {{item.pos}}%;">{{item.unlocked ? item.name : item.days}}</text>
|
||||
style="left: {{item.pos}}%;">{{item.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</ui-card>
|
||||
|
||||
<!-- 内容分区:日历与记录共享月份状态,趋势按需展示,减少默认页长度。 -->
|
||||
<view class="records-content-tabs" hidden="{{totalSessions === 0}}">
|
||||
<view class="records-content-tab {{contentMode === 'records' ? 'active' : ''}}" data-mode="records" bindtap="onToggleContentMode">记录日历</view>
|
||||
<view class="records-content-tab {{contentMode === 'trend' ? 'active' : ''}}" data-mode="trend" bindtap="onToggleContentMode">趋势</view>
|
||||
</view>
|
||||
|
||||
<!-- 训练趋势 -->
|
||||
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
||||
<block wx:if="{{contentMode === 'trend'}}">
|
||||
<ui-card variant="in" index="2" hidden="{{totalSessions === 0}}">
|
||||
<view class="trend-section">
|
||||
<view class="trend-header">
|
||||
<view class="trend-title-wrap">
|
||||
@@ -90,12 +100,45 @@
|
||||
<view class="rec-seg-item {{trendMode === 'month' ? 'active' : ''}}" data-mode="month" bindtap="onToggleTrendMode">近6月</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="trend-summary">
|
||||
<view class="trend-summary-item">
|
||||
<text class="trend-summary-value">{{trendSummary.totalText}}</text>
|
||||
<text class="trend-summary-label">累计时长</text>
|
||||
</view>
|
||||
<view class="trend-summary-item">
|
||||
<text class="trend-summary-value">{{trendSummary.activeCount}}</text>
|
||||
<text class="trend-summary-label">{{trendSummary.activeLabel}}</text>
|
||||
</view>
|
||||
<view class="trend-summary-item">
|
||||
<text class="trend-summary-value">{{trendSummary.streakCount}}</text>
|
||||
<text class="trend-summary-label">连续打卡</text>
|
||||
</view>
|
||||
</view>
|
||||
<trend-chart chart-data="{{trendData}}"></trend-chart>
|
||||
<view class="trend-highlight">
|
||||
<image class="trend-highlight-icon" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
|
||||
<text class="trend-highlight-title">{{trendSummary.peakTitle}}</text>
|
||||
<text class="trend-highlight-text">{{trendSummary.peakText}}</text>
|
||||
</view>
|
||||
<view class="best-trend-header">
|
||||
<view class="trend-title-wrap">
|
||||
<image class="history-title-icon" src="{{icons.crownFill}}" mode="aspectFit"></image>
|
||||
<text class="best-trend-title">个人最佳趋势</text>
|
||||
</view>
|
||||
</view>
|
||||
<trend-chart chart-data="{{bestTrendData}}" variant="peak" chart-type="line" line-color="{{bestLineColor}}"></trend-chart>
|
||||
<view class="trend-highlight">
|
||||
<image class="trend-highlight-icon" src="{{icons.crownFill}}" mode="aspectFit"></image>
|
||||
<text class="trend-highlight-title">{{bestTrendSummary.peakTitle}}</text>
|
||||
<text class="trend-highlight-text">{{bestTrendSummary.peakText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</ui-card>
|
||||
</block>
|
||||
|
||||
<!-- 日历热力图 -->
|
||||
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
||||
<block wx:if="{{contentMode === 'records'}}">
|
||||
<ui-card variant="in" index="2" hidden="{{totalSessions === 0}}">
|
||||
<view class="calendar-section">
|
||||
<view class="month-picker">
|
||||
<view class="month-arrow" bindtap="onPrevMonth">
|
||||
@@ -120,7 +163,7 @@
|
||||
</ui-card>
|
||||
|
||||
<!-- 历史记录 -->
|
||||
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
||||
<ui-card variant="in" index="3" hidden="{{totalSessions === 0}}">
|
||||
<view class="history-section">
|
||||
<view class="history-header">
|
||||
<view class="history-title-wrap">
|
||||
@@ -151,7 +194,7 @@
|
||||
></image>
|
||||
<view class="history-left">
|
||||
<text class="history-date">{{item.date}}</text>
|
||||
<text class="history-plan">{{item.day === 0 ? '自由训练' : '第 ' + item.day + ' 天'}}</text>
|
||||
<text class="history-plan">{{item.mode === 'circuit' ? item.circuitText : (item.day === 0 ? '自由训练' : '第 ' + item.day + ' 天')}}</text>
|
||||
</view>
|
||||
<view class="history-right">
|
||||
<text class="history-duration">{{item.durationText}}</text>
|
||||
@@ -164,6 +207,7 @@
|
||||
</view>
|
||||
</view>
|
||||
</ui-card>
|
||||
</block>
|
||||
</view>
|
||||
<!-- /hidden wrapper -->
|
||||
|
||||
|
||||
+100
-4
@@ -101,11 +101,14 @@
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
|
||||
.badge-fill {
|
||||
.badge-segment {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.badge-segment-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: 2rpx;
|
||||
transition: width 0.6s ease;
|
||||
@@ -144,8 +147,8 @@
|
||||
}
|
||||
|
||||
.badge-node-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
}
|
||||
|
||||
/* 已解锁节点的 ✓ 角标:纯 CSS 画勾(图标 SVG 颜色烤死,无法用变量改色) */
|
||||
@@ -196,6 +199,32 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---- records / trend top-level switch ---- */
|
||||
.records-content-tabs {
|
||||
display: flex;
|
||||
margin: 8rpx 0 20rpx;
|
||||
padding: 6rpx;
|
||||
background: var(--bg-soft);
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.records-content-tab {
|
||||
flex: 1;
|
||||
padding: 14rpx 0;
|
||||
border-radius: 999rpx;
|
||||
color: var(--text-secondary);
|
||||
font-size: 26rpx;
|
||||
text-align: center;
|
||||
transition: background 0.25s ease, color 0.25s ease;
|
||||
}
|
||||
|
||||
.records-content-tab.active {
|
||||
background: var(--card-bg);
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
|
||||
/* ---- calendar ---- */
|
||||
.calendar-section {
|
||||
padding: 0;
|
||||
@@ -264,6 +293,73 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.trend-summary {
|
||||
display: flex;
|
||||
margin: 16rpx 0 4rpx;
|
||||
padding: 16rpx 0;
|
||||
background: rgba(var(--primary-rgb), 0.06);
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.trend-summary-item {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trend-summary-value {
|
||||
color: var(--primary);
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.trend-summary-label {
|
||||
margin-top: 4rpx;
|
||||
color: var(--text-secondary);
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.trend-highlight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-top: 18rpx;
|
||||
padding: 14rpx 16rpx;
|
||||
background: var(--bg-soft);
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
|
||||
.trend-highlight-icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
}
|
||||
|
||||
.trend-highlight-title {
|
||||
color: var(--text-secondary);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.trend-highlight-text {
|
||||
color: var(--text);
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.best-trend-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.best-trend-title {
|
||||
color: var(--text);
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---- history ---- */
|
||||
.history-section {
|
||||
padding: 0;
|
||||
|
||||
@@ -67,6 +67,7 @@ Page({
|
||||
icons: iconsMod.build(),
|
||||
nickName: '',
|
||||
avatarUrl: '',
|
||||
unlockedBadges: [],
|
||||
// Dark mode
|
||||
darkModePref: 'system',
|
||||
darkModeOptions: [
|
||||
@@ -113,7 +114,10 @@ Page({
|
||||
onShow() {
|
||||
try {
|
||||
const tb = this.getTabBar()
|
||||
if (tb) tb.setData({ selected: 3 })
|
||||
if (tb) {
|
||||
tb.setData({ selected: 3 })
|
||||
tb.updateTheme()
|
||||
}
|
||||
} catch (e) {}
|
||||
themeMod.applyThemeToPage(this)
|
||||
const theme = themeMod.getCurrentTheme()
|
||||
@@ -125,10 +129,27 @@ Page({
|
||||
plans: buildPlansList(storage.getCustomPlans()),
|
||||
nickName: profile.nickname || '',
|
||||
avatarUrl: profile.avatarUrl || '',
|
||||
darkModePref: darkMod.getPref()
|
||||
darkModePref: darkMod.getPref(),
|
||||
// 已解锁勋章:个人资料昵称旁展示(主题色版,浅底可见)
|
||||
unlockedBadges: this._getUnlockedBadges()
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 已解锁勋章列表 = 累计去重训练日达到阈值的 badge。
|
||||
* iconFill 是 icons.build 生成的主题色变体 key(icons.js PATHS.*Fill)。
|
||||
*/
|
||||
_getUnlockedBadges() {
|
||||
try {
|
||||
const totalDays = storage.getTotalStats().totalDays
|
||||
return config.trainingDayBadges
|
||||
.filter(b => totalDays >= b.days)
|
||||
.map(b => ({ ...b, iconFill: b.icon + 'Fill' }))
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
applyThemeToPage() {
|
||||
themeMod.applyThemeToPage(this)
|
||||
},
|
||||
@@ -169,11 +190,9 @@ Page({
|
||||
try {
|
||||
permanentUrl = await this._uploadAvatar(avatarUrl)
|
||||
} catch (err) {
|
||||
console.warn('[avatar] cloud upload failed, trying base64:', err)
|
||||
try {
|
||||
permanentUrl = await util.fileToDataURI(avatarUrl)
|
||||
} catch (err2) {
|
||||
console.warn('[avatar] base64 convert failed, saving temp URL:', err2)
|
||||
permanentUrl = avatarUrl
|
||||
}
|
||||
}
|
||||
@@ -359,7 +378,6 @@ Page({
|
||||
await cloud.clearAll()
|
||||
} catch (e) {
|
||||
cloudClearFailed = true
|
||||
console.warn('[settings] cloud clear failed:', e)
|
||||
}
|
||||
|
||||
// 4. Reinstate default settings and streak
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<view class="container page-enter" style="{{themeStyle}}">
|
||||
<!-- 个人资料 -->
|
||||
<ui-card variant="in">
|
||||
<ui-card variant="in" index="0">
|
||||
<view class="section-header">
|
||||
<image class="section-icon" src="{{icons.peopleFill}}" mode="aspectFit"></image>
|
||||
<text class="section-title">个人资料</text>
|
||||
@@ -13,7 +13,13 @@
|
||||
</view>
|
||||
</button>
|
||||
<view class="profile-info">
|
||||
<text class="profile-name">{{nickName || '未设置'}}</text>
|
||||
<view class="profile-name-row">
|
||||
<text class="profile-name">{{nickName || '未设置'}}</text>
|
||||
<!-- 已解锁勋章(主题色版,浅底可见)。无解锁时不占位 -->
|
||||
<view class="profile-badges" wx:if="{{unlockedBadges.length > 0}}">
|
||||
<image class="profile-badge" wx:for="{{unlockedBadges}}" wx:key="days" src="{{icons[item.iconFill]}}" mode="aspectFit"></image>
|
||||
</view>
|
||||
</view>
|
||||
<text class="profile-sub">排行榜将显示此昵称</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -34,7 +40,7 @@
|
||||
</ui-card>
|
||||
|
||||
<!-- 配色方案 -->
|
||||
<ui-card variant="in">
|
||||
<ui-card variant="in" index="1">
|
||||
<view class="section-header">
|
||||
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
|
||||
<text class="section-title">配色方案</text>
|
||||
@@ -56,7 +62,7 @@
|
||||
</ui-card>
|
||||
|
||||
<!-- 深色模式 -->
|
||||
<ui-card variant="in">
|
||||
<ui-card variant="in" index="2">
|
||||
<view class="section-header">
|
||||
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
|
||||
<text class="section-title">深色模式</text>
|
||||
@@ -76,7 +82,7 @@
|
||||
</ui-card>
|
||||
|
||||
<!-- 训练计划选择 -->
|
||||
<ui-card variant="in">
|
||||
<ui-card variant="in" index="3">
|
||||
<view class="section-header">
|
||||
<image class="section-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
|
||||
<text class="section-title">训练计划</text>
|
||||
@@ -107,7 +113,7 @@
|
||||
</ui-card>
|
||||
|
||||
<!-- 训练偏好 -->
|
||||
<ui-card variant="in">
|
||||
<ui-card variant="in" index="4">
|
||||
<view class="section-header">
|
||||
<image class="section-icon" src="{{icons.settingsFill}}" mode="aspectFit"></image>
|
||||
<text class="section-title">训练偏好</text>
|
||||
@@ -159,7 +165,7 @@
|
||||
</ui-card>
|
||||
|
||||
<!-- 数据管理 -->
|
||||
<ui-card variant="in">
|
||||
<ui-card variant="in" index="5">
|
||||
<view class="section-header">
|
||||
<image class="section-icon" src="{{icons.deleteActive}}" mode="aspectFit"></image>
|
||||
<text class="section-title">数据管理</text>
|
||||
@@ -175,7 +181,7 @@
|
||||
</ui-card>
|
||||
|
||||
<!-- 关于 -->
|
||||
<ui-card variant="in">
|
||||
<ui-card variant="in" index="6">
|
||||
<view class="section-header">
|
||||
<image class="section-icon" src="{{icons.infoFill}}" mode="aspectFit"></image>
|
||||
<text class="section-title">关于</text>
|
||||
|
||||
@@ -380,6 +380,31 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 昵称 + 已解锁勋章横排。昵称可收缩省略,勋章组固定宽度不挤压 */
|
||||
.profile-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-name-row .profile-name {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-badge {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
}
|
||||
|
||||
.profile-sub {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-secondary);
|
||||
|
||||
+655
-19
@@ -1,4 +1,5 @@
|
||||
const Timer = require('../../utils/timer')
|
||||
const CircuitTimer = require('../../utils/circuitTimer')
|
||||
const storage = require('../../utils/storage')
|
||||
const planMod = require('../../utils/plan')
|
||||
const themeMod = require('../../utils/theme')
|
||||
@@ -22,7 +23,27 @@ Page({
|
||||
todayTarget: 0,
|
||||
planDay: 1,
|
||||
isFreeMode: false,
|
||||
// --- Circuit (循环训练) mode fields ---
|
||||
isCircuitMode: false,
|
||||
circuitSets: 4,
|
||||
circuitHold: 30,
|
||||
circuitRest: 15,
|
||||
circuitSessions: 3,
|
||||
currentSet: 0,
|
||||
totalSets: 0,
|
||||
phase: 'idle', // idle | working | resting | done
|
||||
phaseRemaining: 0,
|
||||
phaseElapsed: 0,
|
||||
workTotal: 0, // effective held seconds (excludes rest)
|
||||
isResting: false,
|
||||
phaseTip: '', // 阶段切换提示文本(如"最后一组!"/"休息 15 秒")
|
||||
// 组进度改由 progress-ring 外圈分段弧呈现,仅需传 sets/currentSet/resting,
|
||||
// 页面不再维护点阵渲染数据(circuitDots/dotsDone 等已随旧面板一并删除)。
|
||||
celebrationLevel: 'normal', // normal / first / milestone / record
|
||||
// 训练时长低于 config.minRecordSeconds 时置 true:完成弹窗提示"未计入记录",
|
||||
// 隐藏查看记录/分享入口;弹窗内展示门槛值给用户明确反馈
|
||||
completionNotRecorded: false,
|
||||
minRecordSeconds: 20,
|
||||
celebrationMessage: '',
|
||||
confettiPieces: [],
|
||||
// Completion modal (full-screen summary after training ends)
|
||||
@@ -34,15 +55,30 @@ Page({
|
||||
completionStreak: 0,
|
||||
completionLevel: 'normal',
|
||||
completionMessage: '',
|
||||
// 完成弹窗科学提示(config.scienceTips 按本次时长选段)
|
||||
completionTipValue: '',
|
||||
completionTipRisk: '',
|
||||
// --- 计时页改版派生字段(状态文案 / 目标信息卡 / 环刻度 / 环内副标题) ---
|
||||
ringTicks: 0,
|
||||
ringSubText: '',
|
||||
statusText: '准备开始',
|
||||
goalLine: '',
|
||||
runningTip: '',
|
||||
tipType: 'posture',
|
||||
tipIcon: '',
|
||||
icons: iconsMod.build()
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
themeMod.applyThemeToPage(this)
|
||||
this._init(options)
|
||||
// 预载语音到本地缓存,训练开始时即可即时播放(仅语音引导开启时)
|
||||
// 预载语音到本地缓存,训练开始时即可即时播放(仅语音引导开启时)。
|
||||
// 按模式取词条:循环模式要 restStart/nextSet/lastSet,单组模式要 goal,
|
||||
// 各自不载对方的,省掉无谓的云函数调用与下载。
|
||||
const s = storage.getSettings()
|
||||
if (s.voiceGuide !== false) voice.preload()
|
||||
if (s.voiceGuide !== false) {
|
||||
voice.preload(this.data.isCircuitMode ? voice.CIRCUIT_KEYS : voice.HOLD_KEYS)
|
||||
}
|
||||
// countUp: 数字从 0 滚到目标秒数
|
||||
const target = this.data.duration
|
||||
if (this._countUpTask) this._countUpTask.cancel()
|
||||
@@ -59,9 +95,21 @@ Page({
|
||||
themeMod.applyThemeToPage(this)
|
||||
const theme = themeMod.getCurrentTheme()
|
||||
this.setData({ theme, icons: iconsMod.build(theme) })
|
||||
// Coming back from the background (WeChat chat, phone call, home screen)
|
||||
// clears the app-wide keep-awake flag, so re-arm it if a session is still
|
||||
// in flight — otherwise the second half of the training silently loses
|
||||
// every voice/haptic cue again.
|
||||
if (this.data.isRunning) this._keepScreenOn(true)
|
||||
},
|
||||
|
||||
_init(options) {
|
||||
this._initOptions = options || {}
|
||||
// Circuit (循环训练) mode: build the work/rest FSM and bail out of the
|
||||
// single-hold init below. All controls / completion modal / save path are shared.
|
||||
if (this._initOptions.circuit) {
|
||||
this._initCircuit(this._initOptions)
|
||||
return
|
||||
}
|
||||
let target
|
||||
let planDay = 1
|
||||
let isFreeMode = false
|
||||
@@ -79,6 +127,7 @@ Page({
|
||||
target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays), customPlans)
|
||||
}
|
||||
|
||||
const durText = this._durationText(target)
|
||||
this.setData({
|
||||
duration: target,
|
||||
remaining: target,
|
||||
@@ -86,7 +135,14 @@ Page({
|
||||
planDay: isFreeMode ? 0 : planDay,
|
||||
isFreeMode,
|
||||
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) {
|
||||
@@ -108,7 +164,10 @@ Page({
|
||||
onTick: (tick) => {
|
||||
this.setData({
|
||||
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._remind(tick)
|
||||
@@ -120,11 +179,282 @@ Page({
|
||||
if (s.voiceGuide !== false) {
|
||||
voice.play('goal')
|
||||
}
|
||||
this.setData({ goalReached: true })
|
||||
this.setData({ goalReached: true, runningTip: '目标达成,继续挑战!', tipType: 'done', tipIcon: this.data.icons.successFill })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize a circuit (循环) training session. Reads the persisted config
|
||||
* (utils/storage.getCircuitConfig) but lets URL params override so the home
|
||||
* page can jump straight in with the last-saved settings.
|
||||
*/
|
||||
_initCircuit(options) {
|
||||
if (this._timer) {
|
||||
this._timer.stop()
|
||||
this._timer = null
|
||||
}
|
||||
// Reset per-session state (mirrors the single-hold path)
|
||||
this._halfwaySignaled = false
|
||||
this._last30Signaled = false
|
||||
this._last10Signaled = false
|
||||
this._lastMinuteAt = 0
|
||||
this._lastCountdown = 0
|
||||
this._saving = false
|
||||
this._lastTipIndex = -1
|
||||
this._voiceBusyUntil = 0
|
||||
this._clearVibrateTimers()
|
||||
|
||||
const cfg = storage.getCircuitConfig()
|
||||
const hold = this._int(options.hold, cfg.holdPerSet, 5, 600)
|
||||
const sets = this._int(options.sets, cfg.sets, 1, 50)
|
||||
const rest = this._int(options.rest, cfg.restPerSet, 0, 600)
|
||||
this._circuitCfg = {
|
||||
holdPerSet: hold,
|
||||
sets,
|
||||
restPerSet: rest,
|
||||
sessionsPerWeek: cfg.sessionsPerWeek
|
||||
}
|
||||
|
||||
this._timer = new CircuitTimer({
|
||||
holdPerSet: hold,
|
||||
sets,
|
||||
restPerSet: rest,
|
||||
onTick: (t) => {
|
||||
// 组进度语义与旧点阵一致:休息时 setIndex 仍指向"刚做完的那一组",
|
||||
// 组件按 resting 自行推导已完成/进行中/下一组三态(外圈分段弧)。
|
||||
const resting = t.phase === 'resting'
|
||||
this.setData({
|
||||
currentSet: t.setIndex,
|
||||
totalSets: t.sets,
|
||||
phase: t.phase,
|
||||
phaseRemaining: t.phaseRemaining,
|
||||
phaseElapsed: t.phaseElapsed,
|
||||
workTotal: t.workTotal,
|
||||
isResting: resting,
|
||||
// Reuse the single-hold display fields so progress-ring / completion
|
||||
// number keep working: the ring shows the CURRENT set's countdown.
|
||||
remaining: t.phaseRemaining,
|
||||
elapsed: t.workTotal,
|
||||
duration: t.phase === 'working' ? hold : rest,
|
||||
statusText: resting ? '休息中' : '撑住',
|
||||
runningTip: resting ? '' : this.data.phaseTip,
|
||||
tipType: 'phase',
|
||||
tipIcon: this.data.icons.peopleFill,
|
||||
// 环内副标题:宏观进度(第几组)交给外圈分段弧,这里只报当前组位次。
|
||||
ringSubText: `第 ${t.setIndex}/${t.sets} 组`
|
||||
})
|
||||
this._circuitCue(t)
|
||||
},
|
||||
onPhaseChange: (p) => this._circuitPhase(p),
|
||||
onComplete: () => this._onCircuitComplete()
|
||||
})
|
||||
|
||||
// 组数上限 50(见 _int 的 clamp),环形外圈 360° 空间充裕,即使 50 段也
|
||||
// 不至于挤成线(旧点阵的 DOTS_MAX=15 是受横向卡片宽度限制,环形无此问题),
|
||||
// 因此分段弧不做数量降级,页面也无须维护点阵渲染数据。
|
||||
const durText = this._durationText(hold)
|
||||
this.setData({
|
||||
isCircuitMode: true,
|
||||
isFreeMode: false,
|
||||
planDay: 0,
|
||||
circuitSets: sets,
|
||||
circuitHold: hold,
|
||||
circuitRest: rest,
|
||||
circuitSessions: this._circuitCfg.sessionsPerWeek,
|
||||
currentSet: 0,
|
||||
totalSets: sets,
|
||||
phase: 'idle',
|
||||
phaseRemaining: hold,
|
||||
phaseElapsed: 0,
|
||||
workTotal: 0,
|
||||
isResting: false,
|
||||
phaseTip: '',
|
||||
duration: hold,
|
||||
remaining: hold,
|
||||
todayTarget: hold,
|
||||
goalReached: false,
|
||||
elapsed: 0,
|
||||
status: 'idle',
|
||||
isRunning: false,
|
||||
isPaused: false,
|
||||
isCompleted: false,
|
||||
showCompletion: false,
|
||||
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
|
||||
})
|
||||
},
|
||||
|
||||
_int(v, fallback, min, max) {
|
||||
const n = parseInt(v)
|
||||
if (!Number.isFinite(n)) return fallback
|
||||
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.
|
||||
* Mirrors the vibrate helper inside _remind().
|
||||
*/
|
||||
_vibrateOnce(n, ms) {
|
||||
const s = storage.getSettings()
|
||||
if (s.vibrate === false) return
|
||||
const intensity = s.vibrateIntensity || 'heavy'
|
||||
const doVibrate = () => {
|
||||
const fail = (err) => {}
|
||||
if (intensity === 'light') wx.vibrateShort({ type: 'light', fail })
|
||||
else if (intensity === 'medium') wx.vibrateShort({ type: 'medium', fail })
|
||||
else wx.vibrateLong({ fail })
|
||||
}
|
||||
try {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const id = setTimeout(() => {
|
||||
doVibrate()
|
||||
const idx = this._vibrateTimers.indexOf(id)
|
||||
if (idx > -1) this._vibrateTimers.splice(idx, 1)
|
||||
}, i * (ms + 30))
|
||||
this._vibrateTimers.push(id)
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
/**
|
||||
* Per-tick circuit cues. Mirrors _remind() for the single-hold mode, but the
|
||||
* yardstick is the WHOLE session's effective held time (sets × holdPerSet),
|
||||
* NOT the current set — otherwise a 4-set circuit would announce "已完成一半啦"
|
||||
* four times (once per set), which is both noisy and semantically wrong.
|
||||
*
|
||||
* Only evaluated while `working`: workTotal is frozen during rest, so the
|
||||
* thresholds can never re-fire mid-break.
|
||||
*/
|
||||
_circuitCue(t) {
|
||||
if (t.phase !== 'working') return
|
||||
|
||||
const total = this._circuitCfg.sets * this._circuitCfg.holdPerSet
|
||||
const done = t.workTotal
|
||||
const left = total - done
|
||||
const s = storage.getSettings()
|
||||
const voiceOn = s.voiceGuide !== false
|
||||
|
||||
// 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)
|
||||
// don't fire "最后30秒" on the very first tick. `<=` rather than `===`
|
||||
// survives a skipped second when the app returns from background.
|
||||
if (total >= 10 && !this._halfwaySignaled && done >= Math.floor(total / 2)) {
|
||||
this._halfwaySignaled = true
|
||||
if (!(atRestBoundary && restWillSpeak) && voiceOn) this._playProgressVoice('halfway')
|
||||
this._vibrateOnce(2, 150)
|
||||
}
|
||||
if (total > 60 && left <= 30 && !this._last30Signaled) {
|
||||
this._last30Signaled = true
|
||||
if (!(atRestBoundary && restWillSpeak) && voiceOn) this._playProgressVoice('last30')
|
||||
}
|
||||
if (total > 20 && left <= 10 && !this._last10Signaled) {
|
||||
this._last10Signaled = true
|
||||
if (!(atRestBoundary && restWillSpeak) && voiceOn) this._playProgressVoice('last10')
|
||||
}
|
||||
|
||||
// Countdown buzz over the last 5s of the FINAL set (the real finish line).
|
||||
if (t.setIndex >= this._circuitCfg.sets && t.phaseRemaining <= 5 && t.phaseRemaining > 0 && t.phaseRemaining !== this._lastCountdown) {
|
||||
this._lastCountdown = t.phaseRemaining
|
||||
this._vibrateOnce(1, 100)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Play a whole-session progress prompt (halfway / last30 / last10) and claim
|
||||
* a short priority window afterwards.
|
||||
*
|
||||
* Why: the halfway mark is `sets × hold / 2`, which for an EVEN set count
|
||||
* lands exactly on the final second of set sets/2 — i.e. the same tick as the
|
||||
* rest transition. voice.play() drops any in-flight clip when a new one
|
||||
* starts, so without this the phase line would cut "已完成一半啦" off after
|
||||
* one syllable. Progress prompts win; the phase line is the expendable one.
|
||||
*/
|
||||
_playProgressVoice(key) {
|
||||
this._voiceBusyUntil = Date.now() + 2600 // ~ one spoken line
|
||||
voice.play(key)
|
||||
},
|
||||
|
||||
/**
|
||||
* Fired once on each work/rest transition: haptic + on-screen phaseTip +
|
||||
* a voice line (restStart / nextSet / lastSet — number-free, so 3 fixed
|
||||
* clips cover any set count).
|
||||
*
|
||||
* `chatty` gate: a spoken line takes ~2s, so on short sets (<10s each) the
|
||||
* prompts would overlap into a stream of chatter and step on the training
|
||||
* rhythm. Below that we keep only the "last set" cue (the one moment worth
|
||||
* interrupting for) and let haptics + text carry the rest.
|
||||
*/
|
||||
_circuitPhase(p) {
|
||||
const s = storage.getSettings()
|
||||
const chatty = this._circuitCfg.holdPerSet >= 10
|
||||
// Yield the mic if a progress prompt is still speaking (see _playProgressVoice).
|
||||
const voiceOn = s.voiceGuide !== false && Date.now() >= (this._voiceBusyUntil || 0)
|
||||
|
||||
if (p.phase === 'working') {
|
||||
if (p.setIndex === 1) {
|
||||
// No voice here: onStart already plays 'start' at the same instant.
|
||||
this.setData({ phaseTip: '开始第一组' })
|
||||
} else if (p.isLastSet) {
|
||||
this.setData({ phaseTip: '最后一组,冲刺!' })
|
||||
if (voiceOn) voice.play('lastSet')
|
||||
this._vibrateOnce(2, 150)
|
||||
} else {
|
||||
this.setData({ phaseTip: '第 ' + p.setIndex + ' 组' })
|
||||
if (voiceOn && chatty) voice.play('nextSet')
|
||||
this._vibrateOnce(1, 150)
|
||||
}
|
||||
} else if (p.phase === 'resting') {
|
||||
this.setData({ phaseTip: '休息 ' + this._circuitCfg.restPerSet + ' 秒' })
|
||||
// Skip on very short breaks — the clip would still be talking when the
|
||||
// next set starts, colliding with nextSet/lastSet.
|
||||
if (voiceOn && chatty && this._circuitCfg.restPerSet >= 5) voice.play('restStart')
|
||||
this._vibrateOnce(1, 100)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* All sets completed naturally. workTotal already equals sets*holdPerSet.
|
||||
* Reuse the shared completion/save path so celebration + streak + record
|
||||
* all behave identically to a normal session.
|
||||
*/
|
||||
_onCircuitComplete() {
|
||||
this._clearVibrateTimers()
|
||||
const s = storage.getSettings()
|
||||
// 有效撑持秒数未达最低记录时长时不播完成语音(弹窗走"未计入记录"分支)
|
||||
const minSec = config.minRecordSeconds || 20
|
||||
if (this.data.workTotal >= minSec && s.voiceGuide !== false) voice.play('complete')
|
||||
this._saveAndShowCompletion(this.data.workTotal)
|
||||
},
|
||||
|
||||
/**
|
||||
* Rotate the posture cue in the hint area every config.tipInterval seconds.
|
||||
* Only runs while still under the target (after goalReached the hint
|
||||
@@ -167,7 +497,7 @@ Page({
|
||||
// vibrateLong is the most reliable on iOS real devices; the short
|
||||
// types are nicer but may be silent on some devices - user picks.
|
||||
const doVibrate = () => {
|
||||
const fail = (err) => console.warn('[vibrate] failed:', err.errMsg || err)
|
||||
const fail = (err) => {}
|
||||
if (intensity === 'light') wx.vibrateShort({ type: 'light', fail })
|
||||
else if (intensity === 'medium') wx.vibrateShort({ type: 'medium', fail })
|
||||
else wx.vibrateLong({ fail })
|
||||
@@ -233,6 +563,31 @@ Page({
|
||||
this._vibrateTimers = []
|
||||
},
|
||||
|
||||
/**
|
||||
* Keep the screen awake while training.
|
||||
*
|
||||
* Why this matters here: during a plank the user's hands are on the floor —
|
||||
* nobody taps the screen, so the system's idle timer (usually 60s) fires
|
||||
* mid-session. Once the screen locks, the mini program is backgrounded and
|
||||
* JS timers get throttled/suspended: the elapsed count still recovers
|
||||
* (Timer/CircuitTimer reconcile against Date.now on resume), but every
|
||||
* real-time cue in between — halfway / last30 / last10 voice, set-change
|
||||
* and rest prompts, haptics — is simply lost.
|
||||
*
|
||||
* Scope caveat: wx.setKeepScreenOn is APP-wide, not page-scoped, and only
|
||||
* resets when the user exits the mini program. So onUnload MUST turn it
|
||||
* back off, otherwise the screen stays lit while they browse records or
|
||||
* the leaderboard afterwards.
|
||||
*
|
||||
* fail is swallowed on purpose: some Android ROMs ignore this under battery
|
||||
* saver, and a "failed to keep screen on" toast would only confuse the user
|
||||
* — the training itself is unaffected. Supported since base library 1.4.0.
|
||||
*/
|
||||
_keepScreenOn(on) {
|
||||
if (typeof wx.setKeepScreenOn !== 'function') return
|
||||
wx.setKeepScreenOn({ keepScreenOn: !!on, fail() {} })
|
||||
},
|
||||
|
||||
/**
|
||||
* Detect which celebration level to show. Called from _saveAndShowCompletion
|
||||
* (runs in finishTraining, BEFORE saveRecord), so stats reflect pre-save state.
|
||||
@@ -244,7 +599,14 @@ Page({
|
||||
_detectCelebrationLevel(elapsed) {
|
||||
const stats = storage.getTotalStats()
|
||||
const streak = storage.getStreak()
|
||||
const nextStreak = (streak.lastDate && streak.count >= 1) ? streak.count + 1 : 1
|
||||
// 本函数运行在保存记录之前(pre-save state)。updateStreak 对「同一天再次训练」
|
||||
// 会直接 return、不递增连胜数;所以若 streak.lastDate 已是今天,nextStreak 应取
|
||||
// 当前 count 而非 count+1,否则庆祝文案会比真实连胜多 1 天。
|
||||
const todayOnly = storage.getToday().substring(0, 10)
|
||||
const lastIsToday = !!(streak.lastDate && streak.lastDate.substring(0, 10) === todayOnly)
|
||||
const nextStreak = lastIsToday
|
||||
? (streak.count || 1)
|
||||
: ((streak.lastDate && streak.count >= 1) ? streak.count + 1 : 1)
|
||||
|
||||
if (stats.totalSessions === 0) {
|
||||
return { level: 'first', message: '第一次训练!坚持就是胜利' }
|
||||
@@ -283,6 +645,9 @@ Page({
|
||||
onUnload() {
|
||||
voice.stop()
|
||||
voice.destroy()
|
||||
// Mandatory: the flag is app-wide, so leaving it on would keep the screen
|
||||
// lit across every other page until the user quits the mini program.
|
||||
this._keepScreenOn(false)
|
||||
this._clearVibrateTimers()
|
||||
if (this._timer) {
|
||||
this._timer.stop()
|
||||
@@ -301,10 +666,18 @@ Page({
|
||||
onStart() {
|
||||
if (this.data.isPaused) {
|
||||
this._timer.resume()
|
||||
this.setData({ isRunning: true, isPaused: false, status: 'running' })
|
||||
// Re-assert: if the user backgrounded the app while paused, WeChat has
|
||||
// already cleared the flag for us.
|
||||
this._keepScreenOn(true)
|
||||
this.setData({ isRunning: true, isPaused: false, status: 'running', statusText: '撑住', runningTip: '' })
|
||||
return
|
||||
}
|
||||
if (this.data.isRunning) return
|
||||
// Screen stays awake from here until onUnload. Deliberately NOT turned
|
||||
// off on pause (users pause to adjust posture — a screen blackout mid-set
|
||||
// is worse than a few seconds of extra backlight) nor on the completion
|
||||
// modal (they're reading their result).
|
||||
this._keepScreenOn(true)
|
||||
// 取消可能还在滚的 countUp,确保 remaining = duration
|
||||
if (this._countUpTask) {
|
||||
this._countUpTask.cancel()
|
||||
@@ -312,7 +685,7 @@ Page({
|
||||
this.setData({ remaining: 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()
|
||||
if (s.voiceGuide !== false) voice.play('start')
|
||||
},
|
||||
@@ -322,7 +695,7 @@ Page({
|
||||
this._timer.pause()
|
||||
voice.stop()
|
||||
this._clearVibrateTimers()
|
||||
this.setData({ isPaused: true, status: 'paused' })
|
||||
this.setData({ isPaused: true, status: 'paused', statusText: '已暂停', runningTip: '' })
|
||||
},
|
||||
|
||||
onStop() {
|
||||
@@ -345,7 +718,7 @@ Page({
|
||||
// 弹窗前若正在计时(被 onStop 暂停),取消后恢复
|
||||
if (!this._stopWasPaused && this.data.isRunning) {
|
||||
this._timer.resume()
|
||||
this.setData({ isRunning: true, isPaused: false, status: 'running' })
|
||||
this.setData({ isRunning: true, isPaused: false, status: 'running', statusText: '撑住', runningTip: '' })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -368,13 +741,31 @@ Page({
|
||||
return
|
||||
}
|
||||
// Completion voice (replaces the old auto-onComplete cue). No vibration.
|
||||
// 未达最低记录时长时不播完成语音——弹窗会走"未计入记录"分支,不庆祝。
|
||||
const s = storage.getSettings()
|
||||
if (s.voiceGuide !== false) {
|
||||
const minSec = config.minRecordSeconds || 20
|
||||
if (elapsed >= minSec && s.voiceGuide !== false) {
|
||||
voice.play('complete')
|
||||
}
|
||||
this._saveAndShowCompletion(elapsed)
|
||||
},
|
||||
|
||||
/**
|
||||
* 按本次实际训练时长从 config.scienceTips 选一段提示。
|
||||
* 命中第一条 maxSeconds 大于 elapsed 的配置;缺失配置时返回空(不显示)。
|
||||
*/
|
||||
_pickScienceTip(elapsed) {
|
||||
const tips = config.scienceTips || []
|
||||
const sec = Math.max(0, Number(elapsed) || 0)
|
||||
for (const t of tips) {
|
||||
const max = t && t.maxSeconds == null ? Infinity : (t && t.maxSeconds)
|
||||
if (sec < max) {
|
||||
return { value: (t && t.value) || '', risk: (t && t.risk) || '' }
|
||||
}
|
||||
}
|
||||
return { value: '', risk: '' }
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist the just-finished training and pop the big completion modal.
|
||||
* Called from finishTraining (the user manually ends; the timer keeps
|
||||
@@ -386,6 +777,15 @@ Page({
|
||||
// Re-entry guard: protects the finishTraining path so the save never
|
||||
// runs twice even if the user double-taps end.
|
||||
if (this._saving) return
|
||||
|
||||
// 未达 config.minRecordSeconds:不写入记录/连胜,弹"未计入记录"提示。
|
||||
// 兜底判断——手动结束(finishTraining)与循环自然完成(_onCircuitComplete)
|
||||
// 两条路径最终都汇聚到这里,保证任何入口都不会把短时训练落库。
|
||||
const minSec = config.minRecordSeconds || 20
|
||||
if (elapsed < minSec) {
|
||||
this._showNotRecorded(elapsed, minSec)
|
||||
return
|
||||
}
|
||||
this._saving = true
|
||||
|
||||
const { level, message } = this._detectCelebrationLevel(elapsed)
|
||||
@@ -393,12 +793,23 @@ Page({
|
||||
|
||||
// Save record + streak
|
||||
const today = storage.getToday()
|
||||
storage.saveRecord({
|
||||
const record = {
|
||||
date: today,
|
||||
duration: elapsed,
|
||||
planId: storage.getSettings().planId,
|
||||
// Circuit records use a special planId so getPlanDay() never counts
|
||||
// them toward the daily-plan progress; leaderboard/stats aggregate by
|
||||
// `duration`, so a circuit's effective held seconds count normally.
|
||||
planId: this.data.isCircuitMode ? 'circuit' : storage.getSettings().planId,
|
||||
day: this.data.planDay
|
||||
})
|
||||
}
|
||||
if (this.data.isCircuitMode && this._circuitCfg) {
|
||||
record.mode = 'circuit'
|
||||
record.sets = this._circuitCfg.sets
|
||||
record.holdPerSet = this._circuitCfg.holdPerSet
|
||||
record.restPerSet = this._circuitCfg.restPerSet
|
||||
record.sessionsPerWeek = this._circuitCfg.sessionsPerWeek
|
||||
}
|
||||
storage.saveRecord(record)
|
||||
storage.updateStreak(today)
|
||||
|
||||
// Flag for the index page's "训练完成" highlight
|
||||
@@ -407,11 +818,15 @@ Page({
|
||||
try { wx.setStorageSync('_lb_force_refresh', true) } catch (e) {}
|
||||
|
||||
const finalStreak = storage.getStreak().count
|
||||
const scienceTip = this._pickScienceTip(elapsed)
|
||||
|
||||
this.setData({
|
||||
// Background effects keep playing behind the modal
|
||||
status: 'completed',
|
||||
isCompleted: true,
|
||||
// 重置未记录标记(上一轮若走了 _showNotRecorded,这里必须回 false,
|
||||
// 否则下次正常完成的弹窗会误显示"未计入记录"且隐藏分享/记录按钮)
|
||||
completionNotRecorded: false,
|
||||
celebrationLevel: level,
|
||||
celebrationMessage: message,
|
||||
confettiPieces,
|
||||
@@ -420,12 +835,20 @@ Page({
|
||||
completionElapsed: 0,
|
||||
completionActualElapsed: elapsed,
|
||||
completionTarget: this.data.duration,
|
||||
completionOvertime: Math.max(0, elapsed - this.data.duration),
|
||||
// Circuit mode has no "overtime" concept (each set is a fixed target)
|
||||
// and `duration` here is just the last set's length, so hide it.
|
||||
completionOvertime: this.data.isCircuitMode ? 0 : Math.max(0, elapsed - this.data.duration),
|
||||
completionStreak: finalStreak,
|
||||
completionLevel: level,
|
||||
completionMessage: message
|
||||
completionMessage: message,
|
||||
// 科学提示(按时长分段;无配置时为空字符串,wx:if 不渲染)
|
||||
completionTipValue: scienceTip.value,
|
||||
completionTipRisk: scienceTip.risk
|
||||
})
|
||||
|
||||
// 预生成分享海报,点"分享"时 onShareAppMessage 直接复用,避免现场绘制延迟
|
||||
this._drawSharePoster()
|
||||
|
||||
// Animate the big number from 0 to elapsed
|
||||
if (this._completionCountUp) this._completionCountUp.cancel()
|
||||
this._completionCountUp = countUp({
|
||||
@@ -437,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() {
|
||||
if (this._completionCountUp) {
|
||||
this._completionCountUp.cancel()
|
||||
@@ -468,6 +931,15 @@ Page({
|
||||
this._completionCountUp.cancel()
|
||||
this._completionCountUp = null
|
||||
}
|
||||
// 清空上一轮分享图,避免"再来一次"后立即分享时复用旧图
|
||||
this._shareImageUrl = ''
|
||||
// Circuit mode: rebuild the FSM (start() only resets from idle, and a
|
||||
// finished/paused timer is no longer idle, so a fresh init is the clean
|
||||
// reset; _initCircuit also closes the completion modal).
|
||||
if (this.data.isCircuitMode) {
|
||||
this._initCircuit(this._initOptions)
|
||||
return
|
||||
}
|
||||
// Reset per-session state so the next run fires reminders/saves
|
||||
// correctly (previously these stayed set, muting cues on the 2nd run).
|
||||
this._saving = false
|
||||
@@ -485,9 +957,15 @@ Page({
|
||||
status: 'idle',
|
||||
isRunning: false,
|
||||
isPaused: false,
|
||||
// 复位未记录标记:若上一轮走了 _showNotRecorded,重练前必须回 false
|
||||
completionNotRecorded: false,
|
||||
remaining: this.data.duration,
|
||||
elapsed: 0,
|
||||
goalReached: false
|
||||
goalReached: false,
|
||||
statusText: '准备开始',
|
||||
runningTip: '',
|
||||
tipType: 'posture',
|
||||
tipIcon: this.data.icons.peopleFill
|
||||
})
|
||||
},
|
||||
|
||||
@@ -496,6 +974,163 @@ Page({
|
||||
// taps on the completion modal).
|
||||
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: '秒' }
|
||||
},
|
||||
|
||||
/**
|
||||
* 分享给朋友。训练完成弹窗里的分享按钮会走到这里,标题里拼上用户
|
||||
* 刚才撑了多久,转化率更高;右上角"···"菜单也共用同一份文案。
|
||||
@@ -505,7 +1140,8 @@ Page({
|
||||
const s = config.share.timer
|
||||
return {
|
||||
title: s.titleTemplate.replace('{elapsed}', elapsed),
|
||||
path: s.path
|
||||
path: s.path,
|
||||
imageUrl: this._shareImageUrl || ''
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+51
-27
@@ -15,7 +15,9 @@
|
||||
<text class="achievement-text">{{celebrationMessage}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 呼吸引导环 + 进度条 -->
|
||||
<!-- 呼吸引导环 + 进度条。循环训练的组进度不再占一块独立面板,
|
||||
而是由 progress-ring 在圆环外圈画一圈分段弧(见组件的 sets/currentSet/resting 属性),
|
||||
保证单组/自由/循环三模式共用同一套页面骨架,切换零跳动。 -->
|
||||
<view class="ring-wrapper">
|
||||
<view class="breathe-ring breathe-ring-1 {{status === 'running' ? 'fast' : ''}}"
|
||||
wx:if="{{status !== 'completed'}}"></view>
|
||||
@@ -23,18 +25,26 @@
|
||||
wx:if="{{status !== 'completed'}}"></view>
|
||||
<view class="breathe-ring breathe-ring-3 {{status === 'running' ? 'fast' : ''}}"
|
||||
wx:if="{{status !== 'completed'}}"></view>
|
||||
<!-- 呼吸光环:进度环外侧一圈可见的描边辉光,随呼吸收放(idle 慢/running 快) -->
|
||||
<view class="breath-aura {{status === 'running' ? 'fast' : ''}}"
|
||||
wx:if="{{status !== 'completed'}}"></view>
|
||||
|
||||
<progress-ring
|
||||
wx:if="{{status !== 'completed'}}"
|
||||
duration="{{duration}}"
|
||||
remaining="{{isCompleted ? 0 : remaining}}"
|
||||
elapsed="{{elapsed}}"
|
||||
size="{{360}}"
|
||||
size="{{420}}"
|
||||
ringWidth="{{22}}"
|
||||
status="{{status}}"
|
||||
primaryColor="{{theme.primary}}"
|
||||
primaryLightColor="{{theme.primaryLight}}"
|
||||
trackColor="{{elapsed >= duration ? '#00B578' : (isDark ? '#3A3A40' : '#EEEEEE')}}"
|
||||
trackColor="{{isCircuitMode ? '#EEEEEE' : (elapsed >= duration ? '#00B578' : '#EEEEEE')}}"
|
||||
ticks="{{ringTicks}}"
|
||||
subText="{{ringSubText}}"
|
||||
sets="{{totalSets}}"
|
||||
currentSet="{{currentSet}}"
|
||||
resting="{{isResting}}"
|
||||
></progress-ring>
|
||||
|
||||
<!-- 完成庆祝粒子 -->
|
||||
@@ -48,30 +58,22 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 状态提示 -->
|
||||
<view class="hint-section">
|
||||
<view class="hint-row" wx:if="{{status === 'idle'}}">
|
||||
<image class="hint-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
|
||||
<text class="hint-text">
|
||||
<block wx:if="{{isFreeMode}}">自由训练 · 目标 {{duration}} 秒</block>
|
||||
<block wx:else>今日目标 {{duration}} 秒 · 第 {{planDay}} 天</block>
|
||||
</text>
|
||||
<!-- 信息气泡:状态 + 目标摘要 + 运行提示 三行合一(三模式公用)。
|
||||
环下方原 statusText(撑住/休息中/已暂停)并入气泡顶部,圆环下只留一张卡。 -->
|
||||
<view class="info-bubble" wx:if="{{status !== 'completed'}}">
|
||||
<view class="info-line info-line--status info-line--{{status}} {{isResting ? 'info-line--rest' : ''}}">
|
||||
<view class="info-status-dot"></view>
|
||||
<text class="info-status-text">{{statusText}}</text>
|
||||
</view>
|
||||
<view class="hint-row" wx:elif="{{status === 'running' && goalReached}}">
|
||||
<image class="hint-icon running-pulse" src="{{icons.hotFill}}" mode="aspectFit"></image>
|
||||
<text class="hint-text running">目标达成,继续挑战!</text>
|
||||
<view class="info-divider"></view>
|
||||
<view class="info-line info-line--goal">
|
||||
<image class="info-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
|
||||
<text class="info-text">{{goalLine}}</text>
|
||||
</view>
|
||||
<view class="hint-row" wx:elif="{{status === 'running'}}">
|
||||
<image class="hint-icon running-pulse" src="{{icons.likeFill}}" mode="aspectFit"></image>
|
||||
<text class="hint-text running">{{hintTip}}</text>
|
||||
</view>
|
||||
<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 class="info-divider" wx:if="{{runningTip}}"></view>
|
||||
<view class="info-line info-line--tip info-line--{{tipType}}" wx:if="{{runningTip}}">
|
||||
<image class="info-icon" src="{{tipIcon}}" mode="aspectFit"></image>
|
||||
<text class="info-text">{{runningTip}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -159,7 +161,6 @@
|
||||
<text class="completion-number {{completionElapsed >= 60 ? 'completion-number--long' : ''}}">{{fmt.numPart(completionElapsed)}}</text>
|
||||
<text class="completion-unit">{{fmt.unitPart(completionElapsed)}}</text>
|
||||
</view>
|
||||
<text class="completion-label">本次训练</text>
|
||||
|
||||
<view class="completion-overtime" wx:if="{{completionOvertime > 0}}">
|
||||
<text class="overtime-plus">+{{completionOvertime}}</text>
|
||||
@@ -171,17 +172,37 @@
|
||||
<text class="streak-text">连续打卡 {{completionStreak}} 天</text>
|
||||
</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 按时长选段) -->
|
||||
<view class="completion-tip" wx:if="{{completionTipValue}}">
|
||||
<text class="completion-tip-value">{{completionTipValue}}</text>
|
||||
<view class="completion-tip-risk" wx:if="{{completionTipRisk}}">
|
||||
<text class="completion-tip-risk-tag">小贴士</text>
|
||||
<text class="completion-tip-risk-text">{{completionTipRisk}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="completion-actions">
|
||||
<view class="completion-btn completion-btn--primary" bindtap="onTrainAgain">
|
||||
<text>再来一次</text>
|
||||
</view>
|
||||
<view class="completion-btn" bindtap="onViewRecords">
|
||||
<!-- 未记录时没有成绩可看/可晒,隐藏查看记录与分享入口 -->
|
||||
<view class="completion-btn" wx:if="{{!completionNotRecorded}}" bindtap="onViewRecords">
|
||||
<text>查看记录</text>
|
||||
</view>
|
||||
<!-- openType="share" 会触发 Page.onShareAppMessage(已实现),
|
||||
没有这个属性 + onShareAppMessage 时分享按钮就是灰色的。
|
||||
ui-btn 透传 openType 到内部原生 button。 -->
|
||||
<ui-btn
|
||||
wx:if="{{!completionNotRecorded}}"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
customStyle="flex:1; height:88rpx;"
|
||||
@@ -194,4 +215,7 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 隐藏 canvas:用于绘制训练完成分享海报,onShareAppMessage 取它的导出图 -->
|
||||
<canvas type="2d" id="sharePoster" class="share-poster-canvas"></canvas>
|
||||
</view>
|
||||
|
||||
+234
-43
@@ -2,9 +2,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
padding-top: 80rpx;
|
||||
padding-bottom: calc(170rpx + env(safe-area-inset-bottom));
|
||||
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 圆环与下方(状态词/目标卡/提示)整组拉开间距,按钮靠 margin-top:auto 贴底不受影响 */
|
||||
margin-bottom: 44rpx;
|
||||
}
|
||||
|
||||
/* breathing guide rings — soft radial glows instead of hard outline circles,
|
||||
@@ -22,21 +24,53 @@
|
||||
.breathe-ring {
|
||||
position: absolute;
|
||||
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;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.breathe-ring-1 { width: 520rpx; height: 520rpx; 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-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-1 { width: 620rpx; height: 620rpx; animation-delay: 0s; }
|
||||
.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: 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.breathe-ring-1 { animation-delay: 0s; }
|
||||
.breathe-ring.fast.breathe-ring-2 { animation-delay: 0.25s; }
|
||||
.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 */
|
||||
.celebrate-burst {
|
||||
position: absolute;
|
||||
@@ -66,47 +100,110 @@
|
||||
100% { opacity: 0; transform: translate(0, -80rpx) scale(0.4); }
|
||||
}
|
||||
|
||||
/* ---- hint section ---- */
|
||||
.hint-section {
|
||||
margin-top: 36rpx;
|
||||
text-align: center;
|
||||
/* ---- 信息气泡·状态行(原环下方 statusText 并入气泡顶部) ----
|
||||
状态色:running=主色 / 休息中=绿 / idle、paused=中性灰。
|
||||
resting 时 status 字段仍是 running,额外用 .info-line--rest 覆盖为绿。
|
||||
小圆点用 currentColor 跟随状态色,纯 CSS 画(项目 SVG 图标色烤死改不动)。 */
|
||||
.info-line--status {
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.hint-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
.info-status-dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
}
|
||||
|
||||
.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);
|
||||
.info-status-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.hint-text.running { color: var(--primary); font-weight: 600; }
|
||||
.hint-text.paused { color: var(--primary-light); }
|
||||
.hint-text.completed { color: var(--success); font-weight: 600; }
|
||||
.info-line--status.status--running { color: var(--primary); }
|
||||
.info-line--status.status--running .info-status-dot {
|
||||
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 {
|
||||
margin-top: 60rpx;
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -259,7 +356,8 @@
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
margin-bottom: 8rpx;
|
||||
/* 8rpx 原间距 + 24rpx 原 completion-label 的 margin-bottom,删除标签后合并到这里 */
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.completion-number {
|
||||
@@ -284,12 +382,6 @@
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
.completion-label {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.completion-overtime {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -333,10 +425,98 @@
|
||||
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 {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.completion-tip {
|
||||
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 1.0s both;
|
||||
}
|
||||
|
||||
@keyframes tipIn {
|
||||
0% { opacity: 0; transform: translateY(12rpx); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.completion-tip-value {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.completion-tip-risk {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.completion-tip-risk-tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.4;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.14);
|
||||
border-radius: 8rpx;
|
||||
padding: 2rpx 10rpx;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.completion-tip-risk-text {
|
||||
flex: 1;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.completion-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
@@ -458,3 +638,14 @@
|
||||
color: #FFFFFF;
|
||||
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;
|
||||
}
|
||||
|
||||
+6
-4
@@ -20,11 +20,11 @@
|
||||
"compileHotReLoad": false,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"minified": false,
|
||||
"minified": true,
|
||||
"autoAudits": false,
|
||||
"newFeature": false,
|
||||
"uglifyFileName": false,
|
||||
"uploadWithSourceMap": true,
|
||||
"uglifyFileName": true,
|
||||
"uploadWithSourceMap": false,
|
||||
"useIsolateContext": true,
|
||||
"nodeModules": false,
|
||||
"enhance": true,
|
||||
@@ -49,9 +49,11 @@
|
||||
"disableUseStrict": false,
|
||||
"useCompilerPlugins": false,
|
||||
"swc": false,
|
||||
"disableSWC": true
|
||||
"disableSWC": true,
|
||||
"ignoreUploadUnusedFiles": true
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"lazyCodeLoading": "requiredComponents",
|
||||
"cloudfunctionRoot": "cloudfunctions/",
|
||||
"libVersion": "3.3.4",
|
||||
"appid": "wxb7f1bc86924869bc",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"projectname": "wx_pbzc",
|
||||
"condition": {},
|
||||
"setting": {
|
||||
"urlCheck": true,
|
||||
"urlCheck": false,
|
||||
"coverView": true,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"skylineRenderEnable": false,
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { getBadgeProgress, getBadgeCelebration } = require('../utils/badge-state')
|
||||
const {
|
||||
getBadgeProgress,
|
||||
getBadgePositions,
|
||||
getBadgeSegments,
|
||||
getBadgeDisplayLabel,
|
||||
getBadgeCelebration
|
||||
} = require('../utils/badge-state')
|
||||
|
||||
const badges = [{ days: 3 }, { days: 7 }, { days: 14 }]
|
||||
const badges = [
|
||||
{ days: 3, name: '抖登' },
|
||||
{ days: 7, name: '中登' },
|
||||
{ days: 14, name: '老登' },
|
||||
{ days: 30, name: '神登' },
|
||||
{ days: 60, name: '上古神登' },
|
||||
{ days: 80, name: '洪荒神登' },
|
||||
{ days: 100, name: '究极登祖' }
|
||||
]
|
||||
|
||||
test('badge progress remains zero before the first milestone', () => {
|
||||
assert.equal(getBadgeProgress(badges, 1), 0)
|
||||
@@ -11,6 +25,30 @@ test('badge progress remains zero before the first milestone', () => {
|
||||
assert.equal(getBadgeProgress(badges, 3), 4)
|
||||
})
|
||||
|
||||
test('milestones use equal spacing and overall fill stays aligned', () => {
|
||||
assert.deepEqual(getBadgePositions(badges), [4, 19.3, 34.7, 50, 65.3, 80.7, 96])
|
||||
assert.equal(getBadgeProgress(badges, 7), 19.3)
|
||||
assert.equal(getBadgeProgress(badges, 60), 65.3)
|
||||
assert.equal(getBadgeProgress(badges, 100), 100)
|
||||
})
|
||||
|
||||
test('only the active badge connection is partially green', () => {
|
||||
assert.deepEqual(getBadgeSegments(badges, 16), [
|
||||
{ left: 4, width: 15.3, progress: 100 },
|
||||
{ left: 19.3, width: 15.4, progress: 100 },
|
||||
{ left: 34.7, width: 15.3, progress: 12.5 },
|
||||
{ left: 50, width: 15.3, progress: 0 },
|
||||
{ left: 65.3, width: 15.4, progress: 0 },
|
||||
{ left: 80.7, width: 15.3, progress: 0 }
|
||||
])
|
||||
})
|
||||
|
||||
test('equal spacing keeps full unlocked badge names readable', () => {
|
||||
assert.equal(getBadgeDisplayLabel(badges[3], true), '神登')
|
||||
assert.equal(getBadgeDisplayLabel(badges[4], true), '上古神登')
|
||||
assert.equal(getBadgeDisplayLabel(badges[6], false), '100天')
|
||||
})
|
||||
|
||||
test('badge celebration keeps prior milestones seen after records are deleted', () => {
|
||||
const afterDeletion = getBadgeCelebration([3], [])
|
||||
assert.deepEqual(afterDeletion, { newly: [], seen: [3] })
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { getTrendData, getBestTrendData, getTrendSummary, getBarPercent } = require('../utils/util')
|
||||
|
||||
test('weekly trend combines multiple sessions from the same day', () => {
|
||||
const records = {
|
||||
'2026-07': [
|
||||
{ date: '2026-07-29 08:00:00', duration: 30 },
|
||||
{ date: '2026-07-29 20:00:00', duration: 45 },
|
||||
{ date: '2026-07-23 08:00:00', duration: 60 }
|
||||
]
|
||||
}
|
||||
|
||||
assert.deepEqual(getTrendData(records, 'week', new Date(2026, 6, 29)), [
|
||||
{ label: '四', value: 60, valueText: '01:00', highlight: false },
|
||||
{ label: '五', value: 0, valueText: '', highlight: false },
|
||||
{ label: '六', value: 0, valueText: '', highlight: false },
|
||||
{ label: '日', value: 0, valueText: '', highlight: false },
|
||||
{ label: '一', value: 0, valueText: '', highlight: false },
|
||||
{ label: '二', value: 0, valueText: '', highlight: false },
|
||||
{ label: '今天', value: 75, valueText: '01:15', highlight: true }
|
||||
])
|
||||
})
|
||||
|
||||
test('monthly trend spans a year boundary from a single record snapshot', () => {
|
||||
const records = {
|
||||
'2025-08': [{ date: '2025-08-31 08:00:00', duration: 60 }],
|
||||
'2025-12': [{ date: '2025-12-01 08:00:00', duration: 120 }],
|
||||
'2026-01': [{ date: '2026-01-02 08:00:00', duration: 3600 }]
|
||||
}
|
||||
|
||||
assert.deepEqual(getTrendData(records, 'month', new Date(2026, 0, 15)), [
|
||||
{ label: '8月', value: 60, valueText: '01:00', highlight: false },
|
||||
{ label: '9月', value: 0, valueText: '', highlight: false },
|
||||
{ label: '10月', value: 0, valueText: '', highlight: false },
|
||||
{ label: '11月', value: 0, valueText: '', highlight: false },
|
||||
{ label: '12月', value: 120, valueText: '02:00', highlight: false },
|
||||
{ label: '1月', value: 3600, valueText: '60m', highlight: true }
|
||||
])
|
||||
})
|
||||
|
||||
test('bar height remains proportional for very short sessions', () => {
|
||||
assert.equal(getBarPercent(0, 120), 0)
|
||||
assert.ok(getBarPercent(1, 120) < 1)
|
||||
assert.equal(getBarPercent(120, 120), 100)
|
||||
})
|
||||
|
||||
test('trend summary reports total, active periods, and the highest period', () => {
|
||||
const summary = getTrendSummary([
|
||||
{ label: '一', value: 60 },
|
||||
{ label: '二', value: 0 },
|
||||
{ label: '今天', value: 75 }
|
||||
], 'week')
|
||||
|
||||
assert.deepEqual(summary, {
|
||||
totalText: '02:15',
|
||||
activeCount: 2,
|
||||
activeLabel: '训练天数',
|
||||
peakTitle: '最高训练日',
|
||||
peakText: '今天 · 01:15'
|
||||
})
|
||||
})
|
||||
|
||||
test('personal best trend keeps the longest single session in each period', () => {
|
||||
const records = {
|
||||
'2026-07': [
|
||||
{ date: '2026-07-29 08:00:00', duration: 30 },
|
||||
{ date: '2026-07-29 20:00:00', duration: 45 },
|
||||
{ date: '2026-07-23 08:00:00', duration: 60 }
|
||||
]
|
||||
}
|
||||
|
||||
const data = getBestTrendData(records, 'week', new Date(2026, 6, 29))
|
||||
assert.equal(data[0].value, 60)
|
||||
assert.equal(data[6].value, 45)
|
||||
assert.equal(data[6].valueText, '00:45')
|
||||
})
|
||||
+46
-3
@@ -1,5 +1,15 @@
|
||||
const TRACK_PAD = 4
|
||||
|
||||
const roundPosition = (value) => Math.round(value * 10) / 10
|
||||
|
||||
const getBadgePositions = (badges) => {
|
||||
const defs = Array.isArray(badges) ? badges : []
|
||||
if (defs.length === 0) return []
|
||||
if (defs.length === 1) return [TRACK_PAD]
|
||||
const segmentWidth = (100 - TRACK_PAD * 2) / (defs.length - 1)
|
||||
return defs.map((_, index) => roundPosition(TRACK_PAD + index * segmentWidth))
|
||||
}
|
||||
|
||||
const getBadgeProgress = (badges, totalDays) => {
|
||||
const defs = Array.isArray(badges) ? badges : []
|
||||
if (defs.length === 0) return 0
|
||||
@@ -8,12 +18,39 @@ const getBadgeProgress = (badges, totalDays) => {
|
||||
if (days < defs[0].days) return 0
|
||||
if (defs.length === 1 || days >= defs[defs.length - 1].days) return 100
|
||||
|
||||
const segmentWidth = (100 - TRACK_PAD * 2) / (defs.length - 1)
|
||||
const positions = getBadgePositions(defs)
|
||||
let index = 0
|
||||
while (index < defs.length - 1 && days >= defs[index + 1].days) index++
|
||||
|
||||
const ratio = (days - defs[index].days) / (defs[index + 1].days - defs[index].days)
|
||||
return Math.round((TRACK_PAD + index * segmentWidth + ratio * segmentWidth) * 10) / 10
|
||||
return roundPosition(positions[index] + ratio * (positions[index + 1] - positions[index]))
|
||||
}
|
||||
|
||||
const getBadgeSegments = (badges, totalDays) => {
|
||||
const defs = Array.isArray(badges) ? badges : []
|
||||
const positions = getBadgePositions(defs)
|
||||
const days = Math.max(0, Number(totalDays) || 0)
|
||||
|
||||
return defs.slice(0, -1).map((badge, index) => {
|
||||
const nextBadge = defs[index + 1]
|
||||
const segmentDays = nextBadge.days - badge.days
|
||||
const progress = days <= badge.days
|
||||
? 0
|
||||
: days >= nextBadge.days
|
||||
? 100
|
||||
: roundPosition((days - badge.days) / segmentDays * 100)
|
||||
|
||||
return {
|
||||
left: positions[index],
|
||||
width: roundPosition(positions[index + 1] - positions[index]),
|
||||
progress
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getBadgeDisplayLabel = (badge, unlocked) => {
|
||||
if (!badge) return ''
|
||||
return unlocked && badge.name ? badge.name : `${badge.days}天`
|
||||
}
|
||||
|
||||
const getBadgeCelebration = (previous, unlockedDays) => {
|
||||
@@ -25,4 +62,10 @@ const getBadgeCelebration = (previous, unlockedDays) => {
|
||||
return { newly, seen: Array.from(new Set(previous.concat(unlocked))) }
|
||||
}
|
||||
|
||||
module.exports = { getBadgeProgress, getBadgeCelebration }
|
||||
module.exports = {
|
||||
getBadgeProgress,
|
||||
getBadgePositions,
|
||||
getBadgeSegments,
|
||||
getBadgeDisplayLabel,
|
||||
getBadgeCelebration
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Circuit (循环) training timer — a finite state machine driving
|
||||
* work / rest intervals across multiple sets.
|
||||
*
|
||||
* idle → working(set 1) → resting → working(set 2) → resting → … → done
|
||||
*
|
||||
* Distinct from utils/timer.js (which counts a single continuous hold up to
|
||||
* a goal and then keeps going until the user stops). A circuit instead has
|
||||
* discrete sets with rest gaps, so it needs its own FSM.
|
||||
*
|
||||
* `workTotal` (effective held seconds, EXCLUDING rest) is what we persist as
|
||||
* the record's `duration` — same meaning as Timer.stop()'s return value — so
|
||||
* the leaderboard / stats aggregation needs zero changes.
|
||||
*
|
||||
* Mirrors utils/timer.js: 1s interval driven by Date.now() (drift-free),
|
||||
* plus a wx.onAppShow hook so the UI snaps to the real elapsed time when the
|
||||
* mini-program returns from background (WeChat freezes setInterval there).
|
||||
*/
|
||||
class CircuitTimer {
|
||||
constructor(options = {}) {
|
||||
this.holdPerSet = Math.max(1, parseInt(options.holdPerSet) || 30)
|
||||
this.sets = Math.max(1, parseInt(options.sets) || 1)
|
||||
this.restPerSet = Math.max(0, parseInt(options.restPerSet) || 0)
|
||||
|
||||
this.onTick = options.onTick || (() => {})
|
||||
this.onPhaseChange = options.onPhaseChange || (() => {})
|
||||
this.onComplete = options.onComplete || (() => {})
|
||||
|
||||
this._intervalId = null
|
||||
this._running = false
|
||||
this._paused = false
|
||||
this._appShowBound = false
|
||||
|
||||
this.reset()
|
||||
|
||||
this._onAppShow = () => {
|
||||
if (this._running && !this._paused) this._tick()
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.phase = 'idle' // idle | working | resting | done
|
||||
this.setIndex = 0 // 1-based current set (0 before start)
|
||||
this.phaseRemaining = this.holdPerSet
|
||||
this.phaseElapsed = 0
|
||||
this.workTotal = 0 // effective held seconds (excludes rest)
|
||||
this._completedSets = 0
|
||||
this._phaseStart = 0
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.phase === 'idle') {
|
||||
this.phase = 'working'
|
||||
this.setIndex = 1
|
||||
this.phaseRemaining = this.holdPerSet
|
||||
this.phaseElapsed = 0
|
||||
this._completedSets = 0
|
||||
this._emitPhaseChange()
|
||||
}
|
||||
this._running = true
|
||||
this._paused = false
|
||||
this._phaseStart = Date.now() - this.phaseElapsed * 1000
|
||||
this._tick()
|
||||
this._intervalId = setInterval(() => this._tick(), 1000)
|
||||
this._bindAppShow()
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (!this._running || this._paused) return
|
||||
this._paused = true
|
||||
clearInterval(this._intervalId)
|
||||
this._intervalId = null
|
||||
}
|
||||
|
||||
resume() {
|
||||
if (!this._running || !this._paused) return
|
||||
this._paused = false
|
||||
this._phaseStart = Date.now() - this.phaseElapsed * 1000
|
||||
this._tick()
|
||||
this._intervalId = setInterval(() => this._tick(), 1000)
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._running = false
|
||||
this._paused = false
|
||||
clearInterval(this._intervalId)
|
||||
this._intervalId = null
|
||||
this._unbindAppShow()
|
||||
// Effective held seconds — same role as Timer.stop()'s `elapsed`.
|
||||
return this.workTotal
|
||||
}
|
||||
|
||||
_tick() {
|
||||
if (!this._running || this._paused) return
|
||||
const cap = this.phase === 'working' ? this.holdPerSet : this.restPerSet
|
||||
const elapsed = Math.floor((Date.now() - this._phaseStart) / 1000)
|
||||
this.phaseElapsed = elapsed
|
||||
this.phaseRemaining = Math.max(0, cap - elapsed)
|
||||
|
||||
if (this.phase === 'working') {
|
||||
// Total held seconds = fully-completed sets + current set progress.
|
||||
this.workTotal = this._completedSets * this.holdPerSet + elapsed
|
||||
}
|
||||
|
||||
this._emitTick()
|
||||
|
||||
if (this.phaseRemaining <= 0) {
|
||||
this._advance()
|
||||
}
|
||||
}
|
||||
|
||||
_advance() {
|
||||
if (this.phase === 'working') {
|
||||
this._completedSets += 1
|
||||
if (this._completedSets >= this.sets) {
|
||||
// All sets done.
|
||||
this.phase = 'done'
|
||||
this.setIndex = this.sets
|
||||
this._running = false
|
||||
clearInterval(this._intervalId)
|
||||
this._intervalId = null
|
||||
this._unbindAppShow()
|
||||
this.onComplete({
|
||||
workTotal: this.workTotal,
|
||||
sets: this.sets,
|
||||
holdPerSet: this.holdPerSet
|
||||
})
|
||||
return
|
||||
}
|
||||
// Move to rest (if any) or straight into the next set.
|
||||
if (this.restPerSet > 0) {
|
||||
this.phase = 'resting'
|
||||
} else {
|
||||
this.phase = 'working'
|
||||
this.setIndex += 1
|
||||
}
|
||||
this.phaseElapsed = 0
|
||||
this.phaseRemaining = this.phase === 'resting' ? this.restPerSet : this.holdPerSet
|
||||
this._phaseStart = Date.now()
|
||||
this._emitPhaseChange()
|
||||
this._emitTick()
|
||||
return
|
||||
}
|
||||
if (this.phase === 'resting') {
|
||||
this.phase = 'working'
|
||||
this.setIndex += 1
|
||||
this.phaseElapsed = 0
|
||||
this.phaseRemaining = this.holdPerSet
|
||||
this._phaseStart = Date.now()
|
||||
this._emitPhaseChange()
|
||||
this._emitTick()
|
||||
}
|
||||
}
|
||||
|
||||
_emitTick() {
|
||||
this.onTick({
|
||||
phase: this.phase,
|
||||
setIndex: this.setIndex,
|
||||
sets: this.sets,
|
||||
phaseRemaining: this.phaseRemaining,
|
||||
phaseElapsed: this.phaseElapsed,
|
||||
holdPerSet: this.holdPerSet,
|
||||
restPerSet: this.restPerSet,
|
||||
workTotal: this.workTotal
|
||||
})
|
||||
}
|
||||
|
||||
_emitPhaseChange() {
|
||||
this.onPhaseChange({
|
||||
phase: this.phase,
|
||||
setIndex: this.setIndex,
|
||||
sets: this.sets,
|
||||
isLastSet: this.setIndex >= this.sets
|
||||
})
|
||||
}
|
||||
|
||||
_bindAppShow() {
|
||||
if (this._appShowBound || typeof wx.onAppShow !== 'function') return
|
||||
try {
|
||||
wx.onAppShow(this._onAppShow)
|
||||
this._appShowBound = true
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
_unbindAppShow() {
|
||||
if (!this._appShowBound || typeof wx.offAppShow !== 'function') return
|
||||
try {
|
||||
wx.offAppShow(this._onAppShow)
|
||||
} catch (e) {}
|
||||
this._appShowBound = false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CircuitTimer
|
||||
+2
-18
@@ -25,10 +25,8 @@ const init = () => {
|
||||
try {
|
||||
wx.cloud.init({ env: ENV_ID, traceUser: true })
|
||||
_enabled = true
|
||||
console.log('[cloud] init ok, env:', ENV_ID)
|
||||
} catch (e) {
|
||||
_enabled = false
|
||||
console.warn('[cloud] init failed — cloud sync disabled:', e.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,9 +71,7 @@ const _fetchOpenid = async () => {
|
||||
try {
|
||||
const res = await wx.cloud.callFunction({ name: 'getOpenid' })
|
||||
if (res && res.result && res.result.openid) return res.result.openid
|
||||
console.warn('[cloud] getOpenid returned no openid:', JSON.stringify(res))
|
||||
} catch (e) {
|
||||
console.warn('[cloud] getOpenid call failed — cloud functions may not be deployed:', e.message || e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -109,8 +105,7 @@ const _ensureOpenid = async () => {
|
||||
}
|
||||
|
||||
const pushAll = () => {
|
||||
if (!_enabled) { console.log('[cloud] pushAll skipped (not enabled)'); return }
|
||||
console.log('[cloud] pushAll scheduled')
|
||||
if (!_enabled) { return }
|
||||
// Pre-warm openid so _doPush never sees _openid=null on a cold start
|
||||
// (which used to bypass the locate-by-openid branch and call add(),
|
||||
// creating a duplicate cloud doc every cold start).
|
||||
@@ -132,9 +127,8 @@ const _markSynced = () => {
|
||||
}
|
||||
|
||||
const _doPush = async () => {
|
||||
console.log('[cloud] _doPush starting...')
|
||||
const db = getDb()
|
||||
if (!db) { console.log('[cloud] _doPush aborted (no db)'); return }
|
||||
if (!db) { return }
|
||||
try {
|
||||
const storage = require('./storage')
|
||||
const themeMod = require('./theme')
|
||||
@@ -148,13 +142,11 @@ const _doPush = async () => {
|
||||
themeId: themeMod.getCurrentTheme().id,
|
||||
updatedAt: db.serverDate()
|
||||
}
|
||||
console.log('[cloud] _doPush records keys:', Object.keys(data.records || {}))
|
||||
|
||||
// Fast path: cached _docId update
|
||||
if (_docId) {
|
||||
try {
|
||||
await db.collection(DB_COLLECTION).doc(_docId).update({ data })
|
||||
console.log('[cloud] _doPush update ok')
|
||||
_markSynced()
|
||||
return
|
||||
} catch (e) {
|
||||
@@ -163,7 +155,6 @@ const _doPush = async () => {
|
||||
// `doc._openid == auth.openid` → `undefined == openid` → false,
|
||||
// so this surfaces as -502003 rather than "not found". Either way,
|
||||
// fall through and re-locate the doc via openid.
|
||||
console.log('[cloud] _doPush cached _docId stale, clearing:', e.message)
|
||||
_docId = null
|
||||
}
|
||||
}
|
||||
@@ -184,7 +175,6 @@ const _doPush = async () => {
|
||||
_docId = existing.data[0]._id
|
||||
try {
|
||||
await db.collection(DB_COLLECTION).doc(_docId).update({ data })
|
||||
console.log('[cloud] _doPush update ok (re-located by _openid)')
|
||||
_markSynced()
|
||||
return
|
||||
} catch (e) {
|
||||
@@ -192,7 +182,6 @@ const _doPush = async () => {
|
||||
// permission rule, race with another tab, etc.), DO NOT fall
|
||||
// through to add() — that would silently create a duplicate
|
||||
// cloud doc for the same user. Bail and let the next push retry.
|
||||
console.log('[cloud] _doPush update-by-openid failed, aborting:', e.message)
|
||||
_docId = null
|
||||
return
|
||||
}
|
||||
@@ -202,14 +191,11 @@ const _doPush = async () => {
|
||||
// Reached when: (a) openid exists but no doc found, OR (b) openid is
|
||||
// null (getOpenid not deployed). In both cases add() is safe — CloudBase
|
||||
// auto-populates _openid on document creation.
|
||||
console.log('[cloud] _doPush adding new doc (openid=' + (openid || 'null') + ')')
|
||||
const res = await db.collection(DB_COLLECTION).add({ data })
|
||||
if (res && res._id) _docId = res._id
|
||||
if (res && res._openid) _openid = res._openid
|
||||
console.log('[cloud] _doPush add ok, docId=', _docId)
|
||||
_markSynced()
|
||||
} catch (e) {
|
||||
console.log('[cloud] _doPush error:', e.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +209,6 @@ const clearAll = async () => {
|
||||
_openid = await _fetchOpenid()
|
||||
}
|
||||
if (!_openid) {
|
||||
console.log('[cloud] clearAll: cannot fetch openid, giving up')
|
||||
throw new Error('无法识别当前用户')
|
||||
}
|
||||
|
||||
@@ -239,7 +224,6 @@ const clearAll = async () => {
|
||||
},
|
||||
async (id) => {
|
||||
await db.collection(DB_COLLECTION).doc(id).remove()
|
||||
console.log('[cloud] clearAll: removed doc', id)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ const PATHS = {
|
||||
pauseFill: '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>',
|
||||
time: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" fill-rule="evenodd"/>',
|
||||
timeFill: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>',
|
||||
// Loop / repeat (circuit training entry)
|
||||
repeat: '<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>',
|
||||
repeatFill: '<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>',
|
||||
// Markers & targets
|
||||
target: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" fill-rule="evenodd"/><circle cx="12" cy="12" r="5" fill-rule="evenodd"/><circle cx="12" cy="12" r="2"/>',
|
||||
targetFill: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>',
|
||||
@@ -63,6 +66,20 @@ const PATHS = {
|
||||
moon: '<path d="M12 21a9 9 0 1 1 9-9 7 7 0 0 0-9 9z"/>',
|
||||
diamond: '<path d="M7 2h10l4 7-11 13L3 9l4-7z"/>',
|
||||
star: '<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>',
|
||||
// Phosphor medal — scaled from 256 to 24 viewBox (a 命令的 large-arc/sweep
|
||||
// 标志位保持 0/1,仅坐标缩放)。试点勋章图标,挂在 30 天「神登」上。
|
||||
medal: '<path d="M20.2524 9.4419C20.2545 5.7942 17.7443 2.6254 14.193 1.7927 10.6417 0.96 6.9845 2.6827 5.365 5.9511 3.7455 9.2195 4.5901 13.1728 7.4039 15.4941V22.2903C7.4037 22.5379 7.5317 22.7678 7.7423 22.898 7.9528 23.0282 8.2157 23.04 8.4371 22.9292L12.4005 20.952 16.3648 22.9337C16.4642 22.9812 16.5732 23.0053 16.6833 23.0042 17.0776 23.0042 17.3971 22.6847 17.3971 22.2904V15.4942C19.2055 14.0048 20.2529 11.7846 20.2524 9.4419M5.9763 9.4419C5.9763 5.8938 8.8525 3.0176 12.4005 3.0176 15.9485 3.0176 18.8248 5.8938 18.8248 9.4419 18.8248 12.9899 15.9485 15.8661 12.4005 15.8661 8.8541 15.8622 5.9802 12.9882 5.9763 9.4419M15.9695 21.1358L12.7191 19.511C12.518 19.4103 12.2812 19.4103 12.0802 19.511L8.8315 21.1358V16.4345C11.0729 17.5801 13.7281 17.5801 15.9695 16.4345ZM12.4005 14.4385C15.1601 14.4385 17.3971 12.2014 17.3971 9.4419 17.3971 6.6823 15.1601 4.4452 12.4005 4.4452 9.641 4.4452 7.4039 6.6823 7.4039 9.4419 7.4068 12.2002 9.6422 14.4355 12.4005 14.4385M12.4005 5.8728C14.3716 5.8728 15.9695 7.4707 15.9695 9.4419 15.9695 11.413 14.3716 13.0109 12.4005 13.0109 10.4294 13.0109 8.8315 11.413 8.8315 9.4419 8.8315 7.4707 10.4294 5.8728 12.4005 5.8728"/>',
|
||||
// Phosphor 勋章系全套(同为 256→24 缩放)。7 级递进:
|
||||
// sealCheck(3天)→medal(7天)→medalMilitary(14天)→trophyPh(30天)
|
||||
// →certificate(60天)→shieldStar(80天)→crownPh(100天)。
|
||||
// trophyPh/crownPh 带 Ph 后缀避免与下方 Material 版 trophy/crown 冲突
|
||||
// (后者仍被完成弹窗/排行榜使用)。
|
||||
sealCheck: '<path d="M21.6462 9.518C21.2747 9.1296 20.8901 8.7294 20.7453 8.3775 20.6112 8.0551 20.6033 7.5209 20.5954 7.0034 20.5806 6.0413 20.5648 4.9511 19.8069 4.1931 19.0489 3.4352 17.9587 3.4194 16.9966 3.4046 16.4791 3.3967 15.9449 3.3888 15.6225 3.2547 15.2716 3.1099 14.8704 2.7255 14.482 2.3538 13.8019 1.7003 13.029 0.96 12 0.96 10.971 0.96 10.1991 1.7003 9.518 2.3538 9.1296 2.7253 8.7294 3.1099 8.3775 3.2547 8.0571 3.3888 7.5209 3.3967 7.0034 3.4046 6.0413 3.4194 4.9511 3.4352 4.1931 4.1931 3.4352 4.9511 3.4243 6.0413 3.4046 7.0034 3.3967 7.5209 3.3888 8.0551 3.2547 8.3775 3.1099 8.7284 2.7255 9.1296 2.3538 9.518 1.7003 10.1981 0.96 10.971 0.96 12 0.96 13.029 1.7003 13.8009 2.3538 14.482 2.7253 14.8704 3.1099 15.2706 3.2547 15.6225 3.3888 15.9449 3.3967 16.4791 3.4046 16.9966 3.4194 17.9587 3.4352 19.0489 4.1931 19.8069 4.9511 20.5648 6.0413 20.5806 7.0034 20.5954 7.5209 20.6033 8.0551 20.6112 8.3775 20.7453 8.7284 20.8901 9.1296 21.2745 9.518 21.6462 10.1982 22.2998 10.971 23.04 12 23.04 13.029 23.04 13.8009 22.2997 14.482 21.6462 14.8704 21.2747 15.2706 20.8901 15.6225 20.7453 15.9449 20.6112 16.4791 20.6033 16.9966 20.5954 17.9587 20.5806 19.0489 20.5648 19.8069 19.8069 20.5648 19.0489 20.5806 17.9587 20.5954 16.9966 20.6033 16.4791 20.6112 15.9449 20.7453 15.6225 20.8901 15.2716 21.2745 14.8704 21.6462 14.482 22.2998 13.8018 23.04 13.029 23.04 12 23.04 10.971 22.2997 10.1991 21.6462 9.518M20.5077 13.3908C20.0355 13.8837 19.5466 14.3933 19.2875 15.0193 19.039 15.6206 19.0282 16.3076 19.0184 16.9729 19.0085 17.663 18.9977 18.3854 18.6912 18.6911 18.3847 18.9967 17.667 19.0085 16.973 19.0183 16.3077 19.0282 15.6206 19.039 15.0194 19.2873 14.3935 19.5466 13.8838 20.0355 13.3909 20.5076 12.898 20.9797 12.3943 21.4629 12 21.4629 11.6057 21.4629 11.0981 20.9779 10.6092 20.5077 10.1203 20.0375 9.6067 19.5466 8.9807 19.2875 8.3794 19.039 7.6924 19.0282 7.0271 19.0184 6.337 19.0085 5.6146 18.9977 5.3089 18.6912 5.0033 18.3847 4.9915 17.667 4.9817 16.973 4.9718 16.3077 4.961 15.6206 4.7127 15.0194 4.4534 14.3935 3.9645 13.8838 3.4924 13.3909 3.0203 12.898 2.5371 12.3943 2.5371 12 2.5371 11.6057 3.0221 11.0981 3.4923 10.6092 3.9625 10.1203 4.4534 9.6067 4.7125 8.9807 4.961 8.3794 4.9718 7.6924 4.9816 7.0271 4.9915 6.337 5.0023 5.6146 5.3088 5.3089 5.6153 5.0033 6.333 4.9915 7.027 4.9817 7.6923 4.9718 8.3794 4.961 8.9806 4.7127 9.6065 4.4534 10.1162 3.9645 10.6091 3.4924 11.102 3.0203 11.6057 2.5371 12 2.5371 12.3943 2.5371 12.9019 3.0221 13.3908 3.4923 13.8797 3.9625 14.3933 4.4534 15.0193 4.7125 15.6206 4.961 16.3076 4.9718 16.9729 4.9816 17.663 4.9915 18.3854 5.0023 18.6911 5.3088 18.9967 5.6153 19.0085 6.333 19.0183 7.027 19.0282 7.6923 19.039 8.3794 19.2873 8.9806 19.5466 9.6065 20.0355 10.1162 20.5076 10.6091 20.9797 11.102 21.4629 11.6057 21.4629 12 21.4629 12.3943 20.9779 12.9019 20.5077 13.3908M16.5009 9.0764C16.6489 9.2243 16.7322 9.425 16.7322 9.6343 16.7322 9.8436 16.6489 10.0444 16.5009 10.1923L10.9809 15.7123C10.8329 15.8604 10.6322 15.9436 10.4229 15.9436 10.2136 15.9436 10.0129 15.8604 9.865 15.7123L7.4993 13.3466C7.1911 13.0384 7.1911 12.5388 7.4993 12.2307 7.8074 11.9225 8.307 11.9225 8.6151 12.2307L10.4229 14.0395 15.385 9.0764C15.5329 8.9283 15.7336 8.8451 15.9429 8.8451 16.1522 8.8451 16.3529 8.9283 16.5009 9.0764"/>',
|
||||
medalMilitary: '<path d="M20.7209 0.96H3.2792C2.2428 0.96 1.4025 1.8002 1.4025 2.8367V8.269C1.4035 9.0055 1.8352 9.6734 2.5064 9.9767L9.4169 13.1173C7.3125 14.2949 6.2687 16.7449 6.8772 19.0783 7.4857 21.4117 9.593 23.04 12.0045 23.04 14.4159 23.04 16.5233 21.4117 17.1317 19.0783 17.7402 16.7449 16.6964 14.2949 14.592 13.1173L21.4936 9.9767C22.1648 9.6734 22.5965 9.0055 22.5975 8.269V2.8367C22.5975 2.339 22.3997 1.8616 22.0478 1.5097 21.6958 1.1577 21.2185 0.96 20.7208 0.96M15.5324 2.7262V10.7483L11.9999 12.3533 8.4674 10.7483V2.7262ZM3.1688 8.269V2.8367C3.1688 2.7757 3.2182 2.7262 3.2792 2.7262H6.7014V9.9446L3.234 8.3694C3.1945 8.3516 3.169 8.3123 3.1689 8.269M12 21.2718C10.0491 21.2718 8.4675 19.6903 8.4675 17.7393 8.4675 15.7884 10.0491 14.2068 12 14.2068 13.9509 14.2068 15.5325 15.7884 15.5325 17.7393 15.5325 19.6903 13.9509 21.2718 12 21.2718M20.8312 8.269C20.8311 8.3123 20.8056 8.3516 20.7661 8.3694L17.2987 9.9446V2.7262H20.7209C20.7502 2.7262 20.7783 2.7379 20.799 2.7586 20.8197 2.7793 20.8313 2.8074 20.8313 2.8367Z"/>',
|
||||
trophyPh: '<path d="M21.568 5.376H19.36V3.904C19.36 3.4975 19.0305 3.168 18.624 3.168H5.376C4.9695 3.168 4.64 3.4975 4.64 3.904V5.376H2.432C1.619 5.376 0.96 6.035 0.96 6.848V8.32C0.96 10.3524 2.6076 12 4.64 12H4.9758C5.862 14.8082 8.3343 16.8197 11.264 17.1161V19.36H9.056C8.6495 19.36 8.32 19.6895 8.32 20.096 8.32 20.5025 8.6495 20.832 9.056 20.832H14.944C15.3505 20.832 15.68 20.5025 15.68 20.096 15.68 19.6895 15.3505 19.36 14.944 19.36H12.736V17.1133C15.6745 16.8162 18.1124 14.7544 18.9994 12H19.36C21.3924 12 23.04 10.3524 23.04 8.32V6.848C23.04 6.035 22.381 5.376 21.568 5.376M4.64 10.528C3.4206 10.528 2.432 9.5394 2.432 8.32V6.848H4.64V9.792Q4.64 10.16 4.6759 10.528ZM17.888 9.7092C17.888 12.977 15.22 15.6561 12 15.68 8.7481 15.68 6.112 13.0439 6.112 9.792V4.64H17.888ZM21.568 8.32C21.568 9.5394 20.5794 10.528 19.36 10.528H19.314C19.3442 10.2561 19.3595 9.9827 19.36 9.7092V6.848H21.568Z"/>',
|
||||
certificate: '<path d="M11.2115 11.9785C11.2115 12.414 10.8585 12.7671 10.423 12.7671H5.6915C5.256 12.7671 4.9029 12.414 4.9029 11.9785 4.9029 11.543 5.256 11.1899 5.6915 11.1899H10.423C10.8585 11.1899 11.2115 11.543 11.2115 11.9785M10.423 8.0356H5.6915C5.256 8.0356 4.9029 8.3887 4.9029 8.8242 4.9029 9.2597 5.256 9.6128 5.6915 9.6128H10.423C10.8585 9.6128 11.2115 9.2597 11.2115 8.8242 11.2115 8.3887 10.8585 8.0356 10.423 8.0356M21.4631 14.4891V20.6529C21.4657 20.9359 21.3164 21.1986 21.0719 21.3412 20.8275 21.4838 20.5253 21.4845 20.2802 21.3429L17.9145 19.9886 15.5487 21.3429C15.3036 21.4845 15.0015 21.4838 14.7571 21.3412 14.5126 21.1986 14.3633 20.9359 14.3659 20.6529V18.2871H2.5372C1.6661 18.2871 0.96 17.581 0.96 16.71V4.0927C0.96 3.2217 1.6661 2.5155 2.5372 2.5155H19.8859C20.757 2.5155 21.4631 3.2217 21.4631 4.0927V7.1021C22.4704 8.0668 23.04 9.4009 23.04 10.7956 23.04 12.1903 22.4704 13.5245 21.4631 14.4891M14.3659 16.71V14.4891C12.5931 12.7794 12.2869 10.0506 13.6367 7.9905 14.9864 5.9304 17.6106 5.1215 19.8859 6.0641V4.0927H2.5372V16.71ZM19.8859 15.5271C18.6245 16.0541 17.2045 16.0541 15.943 15.5271V19.2945L17.5202 18.3916C17.7627 18.2529 18.0604 18.2529 18.3029 18.3916L19.88 19.2945ZM21.4631 10.7956C21.4631 8.8358 19.8743 7.247 17.9145 7.247 15.9546 7.247 14.3659 8.8358 14.3659 10.7956 14.3659 12.7555 15.9546 14.3442 17.9145 14.3442 19.8743 14.3442 21.4631 12.7555 21.4631 10.7956"/>',
|
||||
shieldStar: '<path d="M6.7661 9.4569C6.8536 9.2391 7.0242 9.0652 7.2401 8.9734 7.4561 8.8817 7.6997 8.8797 7.9172 8.9679L11.1173 10.249V7.1395C11.1173 6.652 11.5126 6.2567 12.0001 6.2567 12.4877 6.2567 12.8829 6.652 12.8829 7.1395V10.2492L16.0831 8.9681C16.5359 8.7852 17.0512 9.0041 17.234 9.4569 17.4168 9.9097 17.198 10.425 16.7452 10.6078L13.4005 11.9452 15.3547 14.5549C15.5439 14.8072 15.5842 15.1413 15.4603 15.4314 15.3364 15.7214 15.0672 15.9233 14.7541 15.961 14.4409 15.9988 14.1315 15.8666 13.9422 15.6143L12 13.0244 10.0579 15.6143C9.8686 15.8666 9.5592 15.9988 9.246 15.961 8.9329 15.9233 8.6637 15.7214 8.5398 15.4314 8.4159 15.1413 8.4562 14.8072 8.6454 14.5549L10.603 11.9452 7.2551 10.6078C7.0374 10.5203 6.8634 10.3498 6.7717 10.1338 6.68 9.9179 6.678 9.6743 6.7661 9.4569M22.5934 2.7256V8.9051C22.5934 14.7226 19.7774 18.2483 17.4148 20.1816 14.8701 22.2627 12.3388 22.9701 12.2283 22.9987 12.0766 23.04 11.9166 23.04 11.7648 22.9987 11.6544 22.97 9.1264 22.2627 6.5784 20.1816 4.2226 18.2483 1.4066 14.7226 1.4066 8.9051V2.7256C1.4066 1.7505 2.197 0.96 3.1721 0.96H20.8279C21.803 0.96 22.5934 1.7505 22.5934 2.7256M20.8279 2.7256H3.1721V8.9051C3.1721 13.0211 4.6971 16.3547 7.7041 18.8155 8.9845 19.8625 10.4385 20.677 12 21.2222 13.5823 20.6675 15.0544 19.8383 16.3488 18.7725 19.3205 16.3161 20.8279 12.9957 20.8279 8.9051Z"/>',
|
||||
crownPh: '<path d="M23.039 8.7106C23.04 7.6107 22.3381 6.6332 21.2956 6.2826 20.2531 5.932 19.1031 6.2868 18.4393 7.1637 17.7755 8.0407 17.7462 9.2438 18.3667 10.1519L15.918 13.1682 13.7162 8.1074C14.5389 7.4151 14.841 6.2824 14.4725 5.2723 14.104 4.2623 13.1435 3.5902 12.0683 3.5902 10.9931 3.5902 10.0326 4.2623 9.6641 5.2723 9.2956 6.2824 9.5978 7.4151 10.4204 8.1074L8.2241 13.1655 5.7754 10.1492C6.4218 9.2021 6.3593 7.9407 5.6225 7.0621 4.8857 6.1835 3.6544 5.9023 2.6092 6.3738 1.564 6.8453 0.96 7.9545 1.131 9.0883 1.302 10.2221 2.2064 11.1038 3.3441 11.246L4.6677 19.1878C4.7852 19.8929 5.3952 20.4096 6.1099 20.4098H18.0321C18.7469 20.4097 19.357 19.8929 19.4745 19.1878L20.7971 11.2497C22.0777 11.0897 23.0388 10.0012 23.039 8.7106M12.071 5.0546C12.6768 5.0546 13.1678 5.5457 13.1678 6.1514 13.1678 6.7572 12.6768 7.2482 12.071 7.2482 11.4653 7.2482 10.9742 6.7572 10.9742 6.1514 10.9742 5.5457 11.4653 5.0546 12.071 5.0546M2.5655 8.7106C2.5655 8.1049 3.0565 7.6138 3.6623 7.6138 4.268 7.6138 4.7591 8.1049 4.7591 8.7106 4.7591 9.3164 4.268 9.8074 3.6623 9.8074 3.0565 9.8074 2.5655 9.3164 2.5655 8.7106M18.0322 18.9474H6.1099L4.8376 11.3174 7.8474 15.0172C7.9855 15.1897 8.1941 15.2905 8.4151 15.2915 8.4481 15.2916 8.4811 15.2894 8.5138 15.2851 8.7672 15.2506 8.9841 15.0863 9.086 14.8518L11.764 8.6914C11.968 8.717 12.1743 8.717 12.3782 8.6914L15.0563 14.8518C15.1581 15.0863 15.3751 15.2506 15.6285 15.2851 15.6612 15.2894 15.6942 15.2916 15.7272 15.2915 15.9481 15.2905 16.1568 15.1897 16.2948 15.0172L19.3046 11.3137ZM20.4798 9.8074C19.8741 9.8074 19.383 9.3164 19.383 8.7106 19.383 8.1049 19.8741 7.6138 20.4798 7.6138 21.0856 7.6138 21.5766 8.1049 21.5766 8.7106 21.5766 9.3164 21.0856 9.8074 20.4798 9.8074"/>',
|
||||
// Party popper (first-time celebrations) — Material celebration
|
||||
party: '<path d="M2 22l14-5-9-9-5 14zm12.53-9.47l1.41-1.41 2.83 2.83-1.41 1.41-2.83-2.83zm.53-6.53L16.47 4.59l1.41-1.41 1.41 1.41-1.41 1.42zM21 11h-3v-2h3v2zm-5.65-4.35L14.24 5.54 15.65 4.1l1.11 1.11-1.41 1.44zM13 3V0h2v3h-2zM8.35 5.65l-1.41-1.44L8.06 3.1l1.41 1.41 1.12 1.14z" fill-rule="evenodd"/>',
|
||||
partyFill: '<path d="M2 22l14-5-9-9-5 14zm12.53-9.47l1.41-1.41 2.83 2.83-1.41 1.41-2.83-2.83zm.53-6.53L16.47 4.59l1.41-1.41 1.41 1.41-1.41 1.42zM21 11h-3v-2h3v2zm-5.65-4.35L14.24 5.54 15.65 4.1l1.11 1.11-1.41 1.44zM13 3V0h2v3h-2zM8.35 5.65l-1.41-1.44L8.06 3.1l1.41 1.41 1.12 1.14z"/>',
|
||||
@@ -117,6 +134,17 @@ const _svg = (path, color) => {
|
||||
const INACTIVE = '#999999'
|
||||
const DANGER = '#E53935'
|
||||
|
||||
// 勋章主题色变体(个人资料昵称旁浅底展示用)。复用同一 path 对象引用,
|
||||
// build 循环对 `*Fill` 命名自动生成主题色版 out.xxxFill —— 浅色背景上
|
||||
// 白版会隐形,灰版像未解锁,主题色版才像"已获得的荣誉勋章"。
|
||||
PATHS.sealCheckFill = PATHS.sealCheck
|
||||
PATHS.medalFill = PATHS.medal
|
||||
PATHS.medalMilitaryFill = PATHS.medalMilitary
|
||||
PATHS.trophyPhFill = PATHS.trophyPh
|
||||
PATHS.certificateFill = PATHS.certificate
|
||||
PATHS.shieldStarFill = PATHS.shieldStar
|
||||
PATHS.crownPhFill = PATHS.crownPh
|
||||
|
||||
/**
|
||||
* Build an icon map keyed by name. Two variants per name:
|
||||
* <name> = inactive (gray)
|
||||
@@ -167,6 +195,15 @@ const build = (theme) => {
|
||||
out.diamondWhite = _svg(PATHS.diamond, '#FFFFFF')
|
||||
out.starWhite = _svg(PATHS.star, '#FFFFFF')
|
||||
out.crownWhite = _svg(PATHS.crown, '#FFFFFF')
|
||||
// Phosphor medal white (trial badge icon)
|
||||
out.medalWhite = _svg(PATHS.medal, '#FFFFFF')
|
||||
// Phosphor 勋章系全套 white(解锁后坐主题色圆盘)
|
||||
out.sealCheckWhite = _svg(PATHS.sealCheck, '#FFFFFF')
|
||||
out.medalMilitaryWhite = _svg(PATHS.medalMilitary, '#FFFFFF')
|
||||
out.trophyPhWhite = _svg(PATHS.trophyPh, '#FFFFFF')
|
||||
out.certificateWhite = _svg(PATHS.certificate, '#FFFFFF')
|
||||
out.shieldStarWhite = _svg(PATHS.shieldStar, '#FFFFFF')
|
||||
out.crownPhWhite = _svg(PATHS.crownPh, '#FFFFFF')
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
+50
-2
@@ -6,6 +6,7 @@ const SETTINGS_KEY = 'user_settings'
|
||||
const STREAK_KEY = 'current_streak'
|
||||
const CUSTOM_PLANS_KEY = 'custom_plans'
|
||||
const PROFILE_KEY = 'user_profile'
|
||||
const CIRCUIT_CONFIG_KEY = 'circuit_config'
|
||||
|
||||
let _idSeq = 0
|
||||
|
||||
@@ -67,7 +68,6 @@ const saveRecord = (record) => {
|
||||
// a 1-minute tolerance for normal clock skew.
|
||||
const recordTime = new Date(record.date.replace(/-/g, '/')).getTime()
|
||||
if (recordTime > Date.now() + 60 * 1000) {
|
||||
console.warn('[storage] saveRecord rejected: future date', record.date)
|
||||
wx.showToast({ title: '系统时间异常,请检查', icon: 'none' })
|
||||
return
|
||||
}
|
||||
@@ -78,7 +78,6 @@ const saveRecord = (record) => {
|
||||
records[month].push(record)
|
||||
records[month].sort((a, b) => b.date.localeCompare(a.date))
|
||||
wx.setStorageSync(RECORDS_KEY, records)
|
||||
console.log('[storage] saveRecord date=' + record.date + ' duration=' + record.duration)
|
||||
cloud.pushAll()
|
||||
}
|
||||
|
||||
@@ -230,6 +229,53 @@ const saveProfile = (profile) => {
|
||||
|
||||
const getStreak = () => wx.getStorageSync(STREAK_KEY) || { count: 0, lastDate: '' }
|
||||
|
||||
/**
|
||||
* Circuit (循环) training configuration — persisted locally (device-only,
|
||||
* NOT cloud-synced; it's a per-device training preference, not account
|
||||
* data, so no cloud.pushAll()). This is the "持久化" the simplified
|
||||
* circuit-train feature needs: the user's { 每组时长 / 组数 / 休息 / 每周次数 }
|
||||
* survives restarts and pre-fills the config panel next time.
|
||||
*
|
||||
* Shape: {
|
||||
* holdPerSet: number // seconds held per set (>=5)
|
||||
* sets: number // sets per session (>=1)
|
||||
* restPerSet: number // rest seconds between sets (>=0; 0 = none)
|
||||
* sessionsPerWeek: number // weekly target (>=1) — DISPLAY ONLY in the
|
||||
* simplified version, no progress tracking yet
|
||||
* }
|
||||
* Any missing / out-of-range field falls back to a sensible default, so a
|
||||
* corrupt or partial config never crashes the timer.
|
||||
*/
|
||||
const DEFAULT_CIRCUIT_CONFIG = { holdPerSet: 30, sets: 4, restPerSet: 15, sessionsPerWeek: 3 }
|
||||
|
||||
const _clampInt = (v, min, max, fallback) => {
|
||||
const n = parseInt(v)
|
||||
if (!Number.isFinite(n)) return fallback
|
||||
return Math.max(min, Math.min(max, n))
|
||||
}
|
||||
|
||||
const getCircuitConfig = () => {
|
||||
const raw = wx.getStorageSync(CIRCUIT_CONFIG_KEY)
|
||||
if (!raw || typeof raw !== 'object') return { ...DEFAULT_CIRCUIT_CONFIG }
|
||||
return {
|
||||
holdPerSet: _clampInt(raw.holdPerSet, 5, 600, DEFAULT_CIRCUIT_CONFIG.holdPerSet),
|
||||
sets: _clampInt(raw.sets, 1, 50, DEFAULT_CIRCUIT_CONFIG.sets),
|
||||
restPerSet: _clampInt(raw.restPerSet, 0, 600, DEFAULT_CIRCUIT_CONFIG.restPerSet),
|
||||
sessionsPerWeek: _clampInt(raw.sessionsPerWeek, 1, 14, DEFAULT_CIRCUIT_CONFIG.sessionsPerWeek)
|
||||
}
|
||||
}
|
||||
|
||||
const saveCircuitConfig = (cfg) => {
|
||||
if (!cfg || typeof cfg !== 'object') return
|
||||
const clean = {
|
||||
holdPerSet: _clampInt(cfg.holdPerSet, 5, 600, DEFAULT_CIRCUIT_CONFIG.holdPerSet),
|
||||
sets: _clampInt(cfg.sets, 1, 50, DEFAULT_CIRCUIT_CONFIG.sets),
|
||||
restPerSet: _clampInt(cfg.restPerSet, 0, 600, DEFAULT_CIRCUIT_CONFIG.restPerSet),
|
||||
sessionsPerWeek: _clampInt(cfg.sessionsPerWeek, 1, 14, DEFAULT_CIRCUIT_CONFIG.sessionsPerWeek)
|
||||
}
|
||||
wx.setStorageSync(CIRCUIT_CONFIG_KEY, clean)
|
||||
}
|
||||
|
||||
/**
|
||||
* User-customized training plans, keyed by planId (e.g. 'beginner').
|
||||
* Each value is a full plan object built by `utils/plan.generatePlanDays`
|
||||
@@ -474,6 +520,8 @@ module.exports = {
|
||||
getProfile,
|
||||
saveProfile,
|
||||
getStreak,
|
||||
getCircuitConfig,
|
||||
saveCircuitConfig,
|
||||
validateStreak,
|
||||
recomputeStreak,
|
||||
updateStreak,
|
||||
|
||||
@@ -89,7 +89,6 @@ const setTheme = (id) => {
|
||||
wx.setStorageSync('user_settings', s)
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
console.error('Failed to save theme:', e)
|
||||
}
|
||||
const theme = getThemeById(id)
|
||||
_setNavBarColor(theme.primary)
|
||||
|
||||
+124
@@ -54,6 +54,126 @@ const formatDate = (date) => {
|
||||
/** Strip the time portion of a formatDate string → "YYYY-MM-DD". */
|
||||
const _dateOnly = (s) => (typeof s === 'string' && s.length >= 10) ? s.substring(0, 10) : ''
|
||||
|
||||
const _normalizeDuration = (value) => Math.max(0, Math.floor(Number(value) || 0))
|
||||
|
||||
const _formatTrendDuration = (seconds) => {
|
||||
const duration = _normalizeDuration(seconds)
|
||||
if (duration >= 3600) return `${Math.floor(duration / 60)}m`
|
||||
return formatTime(duration)
|
||||
}
|
||||
|
||||
/** Build daily or monthly duration totals from one training-record snapshot. */
|
||||
const getTrendData = (recordGroups, mode, now = new Date()) => {
|
||||
const dateTotals = {}
|
||||
const groups = recordGroups && typeof recordGroups === 'object' ? recordGroups : {}
|
||||
|
||||
Object.values(groups).forEach((records) => {
|
||||
if (!Array.isArray(records)) return
|
||||
records.forEach((record) => {
|
||||
const date = _dateOnly(record && record.date)
|
||||
if (!date) return
|
||||
dateTotals[date] = (dateTotals[date] || 0) + _normalizeDuration(record.duration)
|
||||
})
|
||||
})
|
||||
|
||||
const today = new Date(now)
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
|
||||
if (mode === 'month') {
|
||||
const monthTotals = {}
|
||||
Object.keys(dateTotals).forEach((date) => {
|
||||
const month = date.substring(0, 7)
|
||||
monthTotals[month] = (monthTotals[month] || 0) + dateTotals[date]
|
||||
})
|
||||
return Array.from({ length: 6 }, (_, index) => {
|
||||
const offset = 5 - index
|
||||
const date = new Date(today.getFullYear(), today.getMonth() - offset, 1)
|
||||
const monthKey = `${date.getFullYear()}-${pad(date.getMonth() + 1)}`
|
||||
const value = monthTotals[monthKey] || 0
|
||||
return {
|
||||
label: `${date.getMonth() + 1}月`,
|
||||
value,
|
||||
valueText: value > 0 ? _formatTrendDuration(value) : '',
|
||||
highlight: offset === 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const offset = 6 - index
|
||||
const date = new Date(today)
|
||||
date.setDate(date.getDate() - offset)
|
||||
const dateKey = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
const value = dateTotals[dateKey] || 0
|
||||
return {
|
||||
label: offset === 0 ? '今天' : weekdays[date.getDay()],
|
||||
value,
|
||||
valueText: value > 0 ? _formatTrendDuration(value) : '',
|
||||
highlight: offset === 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getBestTrendData = (recordGroups, mode, now = new Date()) => {
|
||||
const peaks = {}
|
||||
const groups = recordGroups && typeof recordGroups === 'object' ? recordGroups : {}
|
||||
Object.values(groups).forEach((records) => {
|
||||
if (!Array.isArray(records)) return
|
||||
records.forEach((record) => {
|
||||
const date = _dateOnly(record && record.date)
|
||||
if (!date) return
|
||||
const key = mode === 'month' ? date.substring(0, 7) : date
|
||||
peaks[key] = Math.max(peaks[key] || 0, _normalizeDuration(record.duration))
|
||||
})
|
||||
})
|
||||
|
||||
const today = new Date(now)
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const count = mode === 'month' ? 6 : 7
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const offset = count - 1 - index
|
||||
const date = mode === 'month'
|
||||
? new Date(today.getFullYear(), today.getMonth() - offset, 1)
|
||||
: new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset)
|
||||
const key = mode === 'month'
|
||||
? `${date.getFullYear()}-${pad(date.getMonth() + 1)}`
|
||||
: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
const value = peaks[key] || 0
|
||||
return {
|
||||
label: mode === 'month' ? `${date.getMonth() + 1}月` : offset === 0 ? '今天' : weekdays[date.getDay()],
|
||||
value,
|
||||
valueText: value > 0 ? _formatTrendDuration(value) : '',
|
||||
highlight: offset === 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getBarPercent = (value, max) => {
|
||||
const amount = _normalizeDuration(value)
|
||||
const maximum = _normalizeDuration(max)
|
||||
return maximum > 0 ? amount / maximum * 100 : 0
|
||||
}
|
||||
|
||||
const getTrendSummary = (trendData, mode) => {
|
||||
const items = Array.isArray(trendData) ? trendData : []
|
||||
const total = items.reduce((sum, item) => sum + _normalizeDuration(item && item.value), 0)
|
||||
const activeItems = items.filter(item => _normalizeDuration(item && item.value) > 0)
|
||||
const peak = activeItems.reduce((current, item) => {
|
||||
return !current || _normalizeDuration(item.value) > _normalizeDuration(current.value) ? item : current
|
||||
}, null)
|
||||
const isMonth = mode === 'month'
|
||||
|
||||
return {
|
||||
totalText: _formatTrendDuration(total),
|
||||
activeCount: activeItems.length,
|
||||
activeLabel: isMonth ? '活跃月份' : '训练天数',
|
||||
peakTitle: isMonth ? '最高训练月' : '最高训练日',
|
||||
peakText: peak ? `${peak.label} · ${_formatTrendDuration(peak.value)}` : '暂无训练数据'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a local file path (typically `wxfile://...` from chooseAvatar) to a
|
||||
* base64 data URI. Used to make temporary file URLs survive the WeChat
|
||||
@@ -101,5 +221,9 @@ module.exports = {
|
||||
getMonthCalendar,
|
||||
formatDate,
|
||||
dateOnly: _dateOnly,
|
||||
getTrendData,
|
||||
getBestTrendData,
|
||||
getTrendSummary,
|
||||
getBarPercent,
|
||||
fileToDataURI
|
||||
}
|
||||
|
||||
+14
-4
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Voice prompt utility for the timer.
|
||||
*
|
||||
* 4 fixed, polished prompts live in `cloudfunctions/tts`. We call that
|
||||
* A fixed set of polished prompts lives in `cloudfunctions/tts` (PROMPTS
|
||||
* below must stay in sync with the cloud function's copy). We call that
|
||||
* cloud function with a key, get back an audio URL and permanent fileID,
|
||||
* and play it via createInnerAudioContext.
|
||||
*
|
||||
@@ -25,9 +26,19 @@ const PROMPTS = {
|
||||
last30: '最后30秒,保持呼吸,稳住姿势!',
|
||||
last10: '最后10秒,再加把劲!',
|
||||
goal: '目标达成,继续挑战!',
|
||||
complete: '太棒了!今天的目标已完成,继续加油!'
|
||||
complete: '太棒了!今天的目标已完成,继续加油!',
|
||||
// --- 循环训练(circuit)阶段切换专用 ---
|
||||
// 刻意不含组号数字,这样固定 3 条就能覆盖任意组数,无需按 N 合成 N 份音频。
|
||||
restStart: '休息一下,调整呼吸',
|
||||
nextSet: '下一组,准备开始',
|
||||
lastSet: '最后一组,全力冲刺!'
|
||||
}
|
||||
|
||||
// 单组(hold)与循环(circuit)各自需要的词条。preload 按模式取,避免
|
||||
// 单组训练白白预载 3 条循环语音(每条一次云函数调用 + 一次下载)。
|
||||
const HOLD_KEYS = ['start', 'halfway', 'last30', 'last10', 'goal', 'complete']
|
||||
const CIRCUIT_KEYS = ['start', 'halfway', 'last30', 'last10', 'complete', 'restStart', 'nextSet', 'lastSet']
|
||||
|
||||
const FILEID_CACHE_KEY = 'tts_fileid_cache'
|
||||
const LOCAL_CACHE_KEY = 'tts_local_cache' // { cacheKey: savedFilePath } - 持久本地文件,跨重启
|
||||
|
||||
@@ -100,7 +111,6 @@ const _getCtx = () => {
|
||||
// actually hear training cues during a session.
|
||||
_ctx.obeyMuteSwitch = false
|
||||
_ctx.onError((err) => {
|
||||
console.log('[voice] audio error:', err.errCode, err.errMsg)
|
||||
})
|
||||
}
|
||||
return _ctx
|
||||
@@ -238,4 +248,4 @@ const destroy = () => {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { play, preload, stop, destroy, PROMPTS }
|
||||
module.exports = { play, preload, stop, destroy, PROMPTS, HOLD_KEYS, CIRCUIT_KEYS }
|
||||
|
||||
Reference in New Issue
Block a user