Compare commits
15 Commits
bce796d893
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 346d46cb27 | |||
| 9d2c752043 | |||
| 8ca709b86b | |||
| 845a1bfd8d | |||
| aa905b1d09 | |||
| b6d94b21cd | |||
| f1ad3eeb95 | |||
| 178a51d261 | |||
| 05c4763e49 | |||
| 0464eeaba6 | |||
| 6f89494c0b | |||
| 938a7d1276 | |||
| ca1793b4c0 | |||
| 187c426243 | |||
| b52db87ddf |
@@ -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,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` 即可。前端改动不发云函数。
|
||||
@@ -3,10 +3,16 @@
|
||||
## 关键技术陷阱(最高优先级,改样式/加组件前必读)
|
||||
- **组件 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+,云函数 `cloudfunctions/leaderboard`)
|
||||
- **预计算快照**:定时触发器(每 4min)`_rebuildSnapshots()` 扫 `plank_data` 算 day/month/year/endurance 四榜写 `leaderboard_snapshot`;客户端 99% 走 `_serveFromSnapshot()`(≈50ms),仅训练后 `force=true` 那次实时重算+回写。**改后须重新部署云函数**(定时器随部署注册)。
|
||||
@@ -22,4 +28,7 @@
|
||||
## 约定
|
||||
- 页面叫法: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,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)
|
||||
@@ -150,6 +149,19 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
|
||||
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 作为"产生时间"。不做日/月/年过滤、不累加。
|
||||
@@ -173,6 +185,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
|
||||
duration: bestDuration,
|
||||
bestDate,
|
||||
sessions: total,
|
||||
totalDays,
|
||||
nickname: profile.nickname || '',
|
||||
avatarUrl: profile.avatarUrl || ''
|
||||
})
|
||||
@@ -210,6 +223,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
|
||||
openid,
|
||||
duration,
|
||||
sessions,
|
||||
totalDays,
|
||||
nickname: profile.nickname || '',
|
||||
avatarUrl: profile.avatarUrl || ''
|
||||
})
|
||||
@@ -240,6 +254,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
|
||||
duration: item.duration,
|
||||
sessions: item.sessions,
|
||||
bestDate: item.bestDate || '',
|
||||
totalDays: item.totalDays || 0,
|
||||
avatarUrl: item.avatarUrl || ''
|
||||
}))
|
||||
|
||||
@@ -264,6 +279,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
|
||||
duration: myEntryRaw.duration,
|
||||
sessions: myEntryRaw.sessions,
|
||||
bestDate: myEntryRaw.bestDate || '',
|
||||
totalDays: myEntryRaw.totalDays || 0,
|
||||
avatarUrl: myEntryRaw.avatarUrl || ''
|
||||
} : null
|
||||
|
||||
@@ -288,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +342,6 @@ const _rebuildSnapshots = async () => {
|
||||
const latestByOpenid = await _fetchLatestByOpenid(true)
|
||||
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() }
|
||||
|
||||
@@ -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,38 +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 strictly proportional to value/max. Zero values render a
|
||||
* faint stub so empty dates remain visible without distorting the data.
|
||||
* 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: [] },
|
||||
variant: { type: String, value: 'total' }
|
||||
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) => {
|
||||
@@ -45,7 +60,49 @@ Component({
|
||||
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 {{variant === 'peak' ? 'peak' : ''}}">
|
||||
<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>
|
||||
|
||||
@@ -92,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;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,23 @@
|
||||
*/
|
||||
module.exports = {
|
||||
/** 应用版本号,外显在设置页「关于」 */
|
||||
version: 'v3.1',
|
||||
version: 'v3.3',
|
||||
|
||||
/** 最后更新日期,外显在设置页「关于」 */
|
||||
updatedAt: '2026-08-03',
|
||||
updatedAt: '2026-08-14',
|
||||
|
||||
/**
|
||||
* 新版本更新通知(What's New)要点。首页冷启动检测 config.version 与本地
|
||||
* 记录版本不一致时,弹一次居中通知弹窗展示这些要点。每次发版改 version
|
||||
* 的同时更新本数组即可(措辞尽量短,每条一行)。
|
||||
*/
|
||||
releaseNotes: [
|
||||
'循环训练分段弧优化:光点居中、与进度弧间距拉大,看得更清楚',
|
||||
'暗黑模式切换后,各页面底部栏同步变暗',
|
||||
'循环训练偶数组数:休息语音不再被「完成一半」提示覆盖',
|
||||
'训练完成新增分享海报,成绩展示更完整',
|
||||
'训练时长不足 20 秒不计入记录'
|
||||
],
|
||||
|
||||
/** 开发者名称 / 微信号,设置页点击可复制 */
|
||||
developer: '三口一瓶',
|
||||
@@ -25,7 +38,7 @@ module.exports = {
|
||||
* 耐力榜领奖台顶部文案。哲理注脚,点出"撑得越久不一定越好"的价值观,
|
||||
* 与耐力榜(比谁单次撑最久)形成反差。仅耐力榜领奖台顶部显示,改这里即可。
|
||||
*/
|
||||
leaderboardPodiumSlogan: '平板支撑不是一场和时间的恋爱,撑得越久,不一定越好。真正重要的,是每一秒都没有辜负身体。',
|
||||
leaderboardPodiumSlogan: '平板支撑不是计时器比赛,是肌肉的精细控制。宁做30秒的钢板,不做5分钟的烂泥。',
|
||||
|
||||
/**
|
||||
* 语音合成(TTS)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。
|
||||
@@ -36,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 }
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -60,6 +73,41 @@ 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: '腰椎肩关节持续受压,不建议作为常规时长' }
|
||||
],
|
||||
|
||||
/**
|
||||
* 累计训练天数里程碑勋章,按累计去重训练日(可中断,非连续打卡)解锁。
|
||||
* 记录页用等距里程碑进度条展示,每个连接段按当前阶段进度填充为绿色。
|
||||
@@ -67,13 +115,13 @@ module.exports = {
|
||||
* 口径=累计去重训练日(可中断);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,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 抽离等有双环境需求时再做。
|
||||
+105
-60
@@ -15,25 +15,31 @@ Page({
|
||||
todayDone: false,
|
||||
todayDuration: 0,
|
||||
todayTargetText: '',
|
||||
// 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,
|
||||
@@ -45,55 +51,18 @@ Page({
|
||||
|
||||
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) {
|
||||
@@ -130,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()
|
||||
@@ -148,11 +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) : []
|
||||
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),
|
||||
@@ -164,12 +161,60 @@ Page({
|
||||
planName: plan.name,
|
||||
planDay: clampedDay,
|
||||
planTotal: plan.totalDays,
|
||||
planProgress,
|
||||
useSegments,
|
||||
planDays
|
||||
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' })
|
||||
},
|
||||
|
||||
+62
-80
@@ -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" index="0" 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" index="1">
|
||||
<view class="target-section">
|
||||
<view class="target-header">
|
||||
<image class="section-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
|
||||
<text class="section-title">今日目标</text>
|
||||
</view>
|
||||
<view class="target-display">
|
||||
<view class="target-ring">
|
||||
<text class="target-time" style="font-size: {{targetTimeFontSize}}rpx;">{{todayTargetText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="today-status">
|
||||
<block wx:if="{{todayDone}}">
|
||||
<view class="done-badge">
|
||||
<image class="done-icon" src="{{icons.successFill}}" mode="aspectFit"></image>
|
||||
<text class="done-text">今日已完成 {{todayDuration}} 秒</text>
|
||||
</view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<image class="pending-icon" src="{{icons.time}}" mode="aspectFit"></image>
|
||||
<text class="pending-text">还未开始训练</text>
|
||||
</block>
|
||||
</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,10 +50,10 @@
|
||||
variant="primary"
|
||||
size="xl"
|
||||
block
|
||||
text="{{todayDone ? '再次训练' : '开始训练'}}"
|
||||
text="{{todayDone ? '再战一次 ' : '立即挑战 '}}{{todayTargetText}}"
|
||||
icon-src="{{icons.playWhite}}"
|
||||
bindtap="onStartTrain"
|
||||
custom-style="border-radius:16rpx;"
|
||||
custom-style="border-radius:16rpx;box-shadow:0 8rpx 20rpx rgba(var(--primary-rgb),0.35);animation:ctaPulse 2s ease-in-out infinite;"
|
||||
></ui-btn>
|
||||
|
||||
<view class="action-row">
|
||||
@@ -172,9 +136,9 @@
|
||||
<view class="circuit-row">
|
||||
<text class="circuit-row-label">每组时长</text>
|
||||
<view class="stepper">
|
||||
<view class="stepper-btn" data-field="hold" data-delta="-5" bindtap="onCircuitStep">−</view>
|
||||
<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="5" bindtap="onCircuitStep">+</view>
|
||||
<view class="stepper-btn" data-field="hold" data-delta="10" bindtap="onCircuitStep">+</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -190,9 +154,9 @@
|
||||
<view class="circuit-row">
|
||||
<text class="circuit-row-label">组间休息</text>
|
||||
<view class="stepper">
|
||||
<view class="stepper-btn" data-field="rest" data-delta="-5" bindtap="onCircuitStep">−</view>
|
||||
<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="5" bindtap="onCircuitStep">+</view>
|
||||
<view class="stepper-btn" data-field="rest" data-delta="10" bindtap="onCircuitStep">+</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -215,4 +179,22 @@
|
||||
></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>
|
||||
|
||||
+281
-255
@@ -1,316 +1,267 @@
|
||||
/* ---- 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;
|
||||
}
|
||||
|
||||
/* 自由训练 / 循环训练 并排一行 */
|
||||
@@ -324,7 +275,13 @@
|
||||
min-width: 0; /* 防止图标+文字在窄屏撑破 flex 项 */
|
||||
}
|
||||
|
||||
/* ---- picker ---- */
|
||||
/* 主按钮脉冲发光(加在 ui-btn 宿主上,__usePrivacyCheck__ 不影响) */
|
||||
@keyframes ctaPulse {
|
||||
0%, 100% { box-shadow: 0 8rpx 20rpx rgba(var(--primary-rgb), 0.35); }
|
||||
50% { box-shadow: 0 8rpx 30rpx rgba(var(--primary-rgb), 0.6); }
|
||||
}
|
||||
|
||||
/* ===== picker ===== */
|
||||
.picker-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
@@ -415,7 +372,7 @@
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
/* ---- circuit (循环训练) config panel ---- */
|
||||
/* ===== circuit (循环训练) config panel ===== */
|
||||
.picker-sub {
|
||||
display: block;
|
||||
text-align: center;
|
||||
@@ -473,7 +430,7 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- 训练完成高亮 (3 秒后淡出) ---- */
|
||||
/* ===== 训练完成高亮 (3 秒后淡出) ===== */
|
||||
/* 这个 class 落在 <ui-card> 宿主节点上(ui-card.wxss 已把宿主设为 display:block,
|
||||
否则 border/box-shadow 只会碎成两根竖条、transform 完全不生效)。
|
||||
描边用 box-shadow 的 spread 而非 border:border 会撑大宿主 8rpx,高亮出现/消失
|
||||
@@ -489,3 +446,72 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -36,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
|
||||
@@ -65,6 +68,37 @@ 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
|
||||
@@ -107,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) {
|
||||
@@ -131,6 +164,17 @@ Page({
|
||||
} 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 ? (() => {
|
||||
@@ -147,6 +191,16 @@ Page({
|
||||
} 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
|
||||
@@ -168,6 +222,17 @@ Page({
|
||||
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
|
||||
@@ -181,7 +246,11 @@ Page({
|
||||
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
|
||||
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 {
|
||||
@@ -206,7 +275,10 @@ Page({
|
||||
const entry = {
|
||||
rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '',
|
||||
duration, durationText: util.formatDuration(duration), sessions,
|
||||
subText: `${sessions}次`
|
||||
subText: `${sessions}次`,
|
||||
// 同耐力榜:本地兜底 entry 必须带 isMe,否则勋章不渲染
|
||||
isMe: true,
|
||||
...localBadgeFields
|
||||
}
|
||||
this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false })
|
||||
} else {
|
||||
|
||||
@@ -44,38 +44,75 @@
|
||||
<!-- 耐力榜领奖台顶部文案:哲理注脚,点出"撑得越久不一定越好" -->
|
||||
<view class="podium-slogan" wx:if="{{activePeriod === 'endurance'}}">{{podiumSlogan}}</view>
|
||||
|
||||
<!-- Podium: top 3 (only when there are at least 3 entries) -->
|
||||
<view class="podium" wx:if="{{rankedList.length >= 3}}">
|
||||
<!-- 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"><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 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"><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 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"><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 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>
|
||||
|
||||
@@ -84,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;"
|
||||
>
|
||||
@@ -93,7 +130,20 @@
|
||||
</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>
|
||||
<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>
|
||||
@@ -106,7 +156,19 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -194,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
|
||||
@@ -243,7 +269,8 @@
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2rpx;
|
||||
/* line-height 收紧后行盒缩短约 8rpx,此处补偿保持与名字的视觉间距 */
|
||||
margin-top: 10rpx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -277,6 +304,30 @@
|
||||
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;
|
||||
@@ -340,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);
|
||||
|
||||
+36
-11
@@ -35,6 +35,7 @@ Page({
|
||||
trendSummary: {},
|
||||
bestTrendData: [],
|
||||
bestTrendSummary: {},
|
||||
bestLineColor: '#FF6B35',
|
||||
showDayDetail: false,
|
||||
dayDetail: {},
|
||||
icons: iconsMod.build()
|
||||
@@ -49,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
|
||||
@@ -138,7 +142,8 @@ Page({
|
||||
trendData,
|
||||
trendSummary: { ...util.getTrendSummary(trendData, trendMode), streakCount: Number(streak.count) || 0 },
|
||||
bestTrendData,
|
||||
bestTrendSummary: util.getTrendSummary(bestTrendData, trendMode),
|
||||
bestTrendSummary: this._buildBestSummary(bestTrendData, trendMode),
|
||||
bestLineColor: theme.primary,
|
||||
loading: false
|
||||
})
|
||||
},
|
||||
@@ -156,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()
|
||||
},
|
||||
|
||||
@@ -190,7 +204,8 @@ Page({
|
||||
trendData,
|
||||
trendSummary: { ...util.getTrendSummary(trendData, mode), streakCount: Number(storage.getStreak().count) || 0 },
|
||||
bestTrendData,
|
||||
bestTrendSummary: util.getTrendSummary(bestTrendData, mode)
|
||||
bestTrendSummary: this._buildBestSummary(bestTrendData, mode),
|
||||
bestLineColor: themeMod.getCurrentTheme().primary
|
||||
})
|
||||
},
|
||||
|
||||
@@ -208,6 +223,16 @@ Page({
|
||||
return util.getBestTrendData(storage.getRecords(), mode)
|
||||
},
|
||||
|
||||
/**
|
||||
* 个人最佳趋势的峰值摘要:复用通用 getTrendSummary,但把 peakTitle 从
|
||||
* "最高训练日/月" 覆盖成 "个人最佳日/月",贴合"个人最佳趋势"区块语义。
|
||||
*/
|
||||
_buildBestSummary(bestTrendData, mode) {
|
||||
const s = util.getTrendSummary(bestTrendData, mode)
|
||||
s.peakTitle = mode === 'month' ? '个人最佳月' : '个人最佳日'
|
||||
return s
|
||||
},
|
||||
|
||||
onDayTap(e) {
|
||||
const { year, month, day } = e.detail
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
@@ -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": "训练记录"
|
||||
}
|
||||
|
||||
@@ -125,9 +125,13 @@
|
||||
<image class="history-title-icon" src="{{icons.crownFill}}" mode="aspectFit"></image>
|
||||
<text class="best-trend-title">个人最佳趋势</text>
|
||||
</view>
|
||||
<text class="best-trend-best">{{bestTrendSummary.peakText}}</text>
|
||||
</view>
|
||||
<trend-chart chart-data="{{bestTrendData}}" variant="peak"></trend-chart>
|
||||
<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>
|
||||
|
||||
@@ -147,8 +147,8 @@
|
||||
}
|
||||
|
||||
.badge-node-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
}
|
||||
|
||||
/* 已解锁节点的 ✓ 角标:纯 CSS 画勾(图标 SVG 颜色烤死,无法用变量改色) */
|
||||
@@ -360,12 +360,6 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.best-trend-best {
|
||||
color: var(--primary);
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
+335
-44
@@ -37,14 +37,13 @@ Page({
|
||||
workTotal: 0, // effective held seconds (excludes rest)
|
||||
isResting: false,
|
||||
phaseTip: '', // 阶段切换提示文本(如"最后一组!"/"休息 15 秒")
|
||||
// 顶部循环进度面板:组进度胶囊点阵
|
||||
circuitDots: [], // [1..sets],仅用于 wx:for 渲染,组数过多时不渲染
|
||||
circuitShowDots: true, // 组数 > DOTS_MAX 时退化为线性进度条
|
||||
circuitTotalWork: 0, // sets × holdPerSet,面板里作为"目标秒数"展示
|
||||
dotsDone: 0, // 已完成组数
|
||||
dotsActive: 0, // 正在进行的组序号(休息时为 0)
|
||||
dotsNext: 0, // 休息时即将开始的组序号(工作时为 0)
|
||||
// 组进度改由 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)
|
||||
@@ -56,6 +55,17 @@ Page({
|
||||
completionStreak: 0,
|
||||
completionLevel: 'normal',
|
||||
completionMessage: '',
|
||||
// 完成弹窗科学提示(config.scienceTips 按本次时长选段)
|
||||
completionTipValue: '',
|
||||
completionTipRisk: '',
|
||||
// --- 计时页改版派生字段(状态文案 / 目标信息卡 / 环刻度 / 环内副标题) ---
|
||||
ringTicks: 0,
|
||||
ringSubText: '',
|
||||
statusText: '准备开始',
|
||||
goalLine: '',
|
||||
runningTip: '',
|
||||
tipType: 'posture',
|
||||
tipIcon: '',
|
||||
icons: iconsMod.build()
|
||||
},
|
||||
|
||||
@@ -117,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,
|
||||
@@ -124,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) {
|
||||
@@ -146,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)
|
||||
@@ -158,7 +179,7 @@ Page({
|
||||
if (s.voiceGuide !== false) {
|
||||
voice.play('goal')
|
||||
}
|
||||
this.setData({ goalReached: true })
|
||||
this.setData({ goalReached: true, runningTip: '目标达成,继续挑战!', tipType: 'done', tipIcon: this.data.icons.successFill })
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -200,8 +221,8 @@ Page({
|
||||
sets,
|
||||
restPerSet: rest,
|
||||
onTick: (t) => {
|
||||
// 组进度点阵三态。休息阶段 CircuitTimer 的 setIndex 仍指向"刚做完
|
||||
// 的那一组",所以 resting 时 setIndex 组算已完成、下一组标为 next。
|
||||
// 组进度语义与旧点阵一致:休息时 setIndex 仍指向"刚做完的那一组",
|
||||
// 组件按 resting 自行推导已完成/进行中/下一组三态(外圈分段弧)。
|
||||
const resting = t.phase === 'resting'
|
||||
this.setData({
|
||||
currentSet: t.setIndex,
|
||||
@@ -211,14 +232,17 @@ Page({
|
||||
phaseElapsed: t.phaseElapsed,
|
||||
workTotal: t.workTotal,
|
||||
isResting: resting,
|
||||
dotsDone: resting ? t.setIndex : Math.max(0, t.setIndex - 1),
|
||||
dotsActive: resting ? 0 : t.setIndex,
|
||||
dotsNext: resting ? t.setIndex + 1 : 0,
|
||||
// Reuse the single-hold display fields so progress-ring / completion
|
||||
// number keep working: the ring shows the CURRENT set's countdown.
|
||||
remaining: t.phaseRemaining,
|
||||
elapsed: t.workTotal,
|
||||
duration: t.phase === 'working' ? hold : rest
|
||||
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)
|
||||
},
|
||||
@@ -226,13 +250,10 @@ Page({
|
||||
onComplete: () => this._onCircuitComplete()
|
||||
})
|
||||
|
||||
// 点阵超过 DOTS_MAX 个就挤成头发丝了,退化成一条线性进度条。
|
||||
const DOTS_MAX = 15
|
||||
const dots = []
|
||||
if (sets <= DOTS_MAX) {
|
||||
for (let i = 1; i <= sets; i++) dots.push(i)
|
||||
}
|
||||
|
||||
// 组数上限 50(见 _int 的 clamp),环形外圈 360° 空间充裕,即使 50 段也
|
||||
// 不至于挤成线(旧点阵的 DOTS_MAX=15 是受横向卡片宽度限制,环形无此问题),
|
||||
// 因此分段弧不做数量降级,页面也无须维护点阵渲染数据。
|
||||
const durText = this._durationText(hold)
|
||||
this.setData({
|
||||
isCircuitMode: true,
|
||||
isFreeMode: false,
|
||||
@@ -241,12 +262,6 @@ Page({
|
||||
circuitHold: hold,
|
||||
circuitRest: rest,
|
||||
circuitSessions: this._circuitCfg.sessionsPerWeek,
|
||||
circuitDots: dots,
|
||||
circuitShowDots: sets <= DOTS_MAX,
|
||||
circuitTotalWork: sets * hold,
|
||||
dotsDone: 0,
|
||||
dotsActive: 0,
|
||||
dotsNext: 0,
|
||||
currentSet: 0,
|
||||
totalSets: sets,
|
||||
phase: 'idle',
|
||||
@@ -264,7 +279,16 @@ Page({
|
||||
isRunning: false,
|
||||
isPaused: false,
|
||||
isCompleted: false,
|
||||
showCompletion: false
|
||||
showCompletion: false,
|
||||
goalLine: `每组 ${durText} · 共 ${sets} 组`,
|
||||
ringTicks: Math.min(hold, 60),
|
||||
// idle 时环内副标题承载配置摘要(组数 + 休息时长),运行时由 onTick
|
||||
// 换成"第 X/Y 组"。休息时长为 0 时不展示"休息"段。
|
||||
ringSubText: `共 ${sets} 组${rest > 0 ? ' · 休息 ' + rest + ' 秒' : ''}`,
|
||||
statusText: '准备开始',
|
||||
runningTip: '',
|
||||
tipType: 'posture',
|
||||
tipIcon: this.data.icons.peopleFill
|
||||
})
|
||||
},
|
||||
|
||||
@@ -274,6 +298,18 @@ Page({
|
||||
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().
|
||||
@@ -283,7 +319,7 @@ Page({
|
||||
if (s.vibrate === false) return
|
||||
const intensity = s.vibrateIntensity || 'heavy'
|
||||
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 })
|
||||
@@ -318,21 +354,31 @@ Page({
|
||||
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 (voiceOn) this._playProgressVoice('halfway')
|
||||
if (!(atRestBoundary && restWillSpeak) && voiceOn) this._playProgressVoice('halfway')
|
||||
this._vibrateOnce(2, 150)
|
||||
}
|
||||
if (total > 60 && left <= 30 && !this._last30Signaled) {
|
||||
this._last30Signaled = true
|
||||
if (voiceOn) this._playProgressVoice('last30')
|
||||
if (!(atRestBoundary && restWillSpeak) && voiceOn) this._playProgressVoice('last30')
|
||||
}
|
||||
if (total > 20 && left <= 10 && !this._last10Signaled) {
|
||||
this._last10Signaled = true
|
||||
if (voiceOn) this._playProgressVoice('last10')
|
||||
if (!(atRestBoundary && restWillSpeak) && voiceOn) this._playProgressVoice('last10')
|
||||
}
|
||||
|
||||
// Countdown buzz over the last 5s of the FINAL set (the real finish line).
|
||||
@@ -403,7 +449,9 @@ Page({
|
||||
_onCircuitComplete() {
|
||||
this._clearVibrateTimers()
|
||||
const s = storage.getSettings()
|
||||
if (s.voiceGuide !== false) voice.play('complete')
|
||||
// 有效撑持秒数未达最低记录时长时不播完成语音(弹窗走"未计入记录"分支)
|
||||
const minSec = config.minRecordSeconds || 20
|
||||
if (this.data.workTotal >= minSec && s.voiceGuide !== false) voice.play('complete')
|
||||
this._saveAndShowCompletion(this.data.workTotal)
|
||||
},
|
||||
|
||||
@@ -449,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 })
|
||||
@@ -621,7 +669,7 @@ Page({
|
||||
// Re-assert: if the user backgrounded the app while paused, WeChat has
|
||||
// already cleared the flag for us.
|
||||
this._keepScreenOn(true)
|
||||
this.setData({ isRunning: true, isPaused: false, status: 'running' })
|
||||
this.setData({ isRunning: true, isPaused: false, status: 'running', statusText: '撑住', runningTip: '' })
|
||||
return
|
||||
}
|
||||
if (this.data.isRunning) return
|
||||
@@ -637,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')
|
||||
},
|
||||
@@ -647,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() {
|
||||
@@ -670,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: '' })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -693,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
|
||||
@@ -711,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)
|
||||
@@ -743,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,
|
||||
@@ -761,9 +840,15 @@ Page({
|
||||
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({
|
||||
@@ -775,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()
|
||||
@@ -806,6 +931,8 @@ 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).
|
||||
@@ -830,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
|
||||
})
|
||||
},
|
||||
|
||||
@@ -841,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: '秒' }
|
||||
},
|
||||
|
||||
/**
|
||||
* 分享给朋友。训练完成弹窗里的分享按钮会走到这里,标题里拼上用户
|
||||
* 刚才撑了多久,转化率更高;右上角"···"菜单也共用同一份文案。
|
||||
@@ -850,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 || ''
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+50
-64
@@ -15,44 +15,9 @@
|
||||
<text class="achievement-text">{{celebrationMessage}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 循环训练进度面板:组点阵 + 第 X/Y 组 + 阶段徽章 + 累计秒数。
|
||||
层次分工:这里管「第几组」(宏观),中间的进度环管「本组还剩几秒」(微观)。 -->
|
||||
<view
|
||||
class="circuit-bar {{isResting ? 'is-rest' : ''}} {{status === 'paused' ? 'is-paused' : ''}}"
|
||||
wx:if="{{isCircuitMode && status !== 'completed'}}"
|
||||
>
|
||||
<!-- 组进度点阵:已完成 / 进行中 / 下一组 三态 -->
|
||||
<view class="circuit-dots" wx:if="{{circuitShowDots}}">
|
||||
<view
|
||||
wx:for="{{circuitDots}}"
|
||||
wx:key="*this"
|
||||
class="circuit-dot {{item <= dotsDone ? 'is-done' : ''}} {{item === dotsActive ? 'is-active' : ''}} {{item === dotsNext ? 'is-next' : ''}}"
|
||||
></view>
|
||||
</view>
|
||||
<!-- 组数过多(>15)时退化为线性进度条,避免点阵挤成头发丝 -->
|
||||
<view class="circuit-track" wx:else>
|
||||
<view class="circuit-track-fill" style="width: {{dotsDone * 100 / totalSets}}%"></view>
|
||||
</view>
|
||||
|
||||
<view class="circuit-meta">
|
||||
<view class="circuit-count">
|
||||
<text class="circuit-count-num">{{status === 'idle' ? totalSets : currentSet}}</text>
|
||||
<text class="circuit-count-total" wx:if="{{status !== 'idle'}}">/{{totalSets}}</text>
|
||||
<text class="circuit-count-unit">组</text>
|
||||
</view>
|
||||
<view class="circuit-phase circuit-phase--{{status === 'paused' ? 'pause' : (status === 'idle' ? 'idle' : (isResting ? 'rest' : 'work'))}}">
|
||||
<view class="circuit-phase-dot"></view>
|
||||
<text class="circuit-phase-text">{{status === 'paused' ? '已暂停' : (status === 'idle' ? '准备开始' : (isResting ? '休息中' : '撑住'))}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="circuit-sub">
|
||||
<block wx:if="{{status === 'idle'}}">每组 {{circuitHold}} 秒{{circuitRest > 0 ? ' · 休息 ' + circuitRest + ' 秒' : ''}}</block>
|
||||
<block wx:else>已撑 {{workTotal}} 秒 · 目标 {{circuitTotalWork}} 秒</block>
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 呼吸引导环 + 进度条 -->
|
||||
<!-- 呼吸引导环 + 进度条。循环训练的组进度不再占一块独立面板,
|
||||
而是由 progress-ring 在圆环外圈画一圈分段弧(见组件的 sets/currentSet/resting 属性),
|
||||
保证单组/自由/循环三模式共用同一套页面骨架,切换零跳动。 -->
|
||||
<view class="ring-wrapper">
|
||||
<view class="breathe-ring breathe-ring-1 {{status === 'running' ? 'fast' : ''}}"
|
||||
wx:if="{{status !== 'completed'}}"></view>
|
||||
@@ -60,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="{{isCircuitMode ? '#EEEEEE' : (elapsed >= duration ? '#00B578' : '#EEEEEE')}}"
|
||||
ticks="{{ringTicks}}"
|
||||
subText="{{ringSubText}}"
|
||||
sets="{{totalSets}}"
|
||||
currentSet="{{currentSet}}"
|
||||
resting="{{isResting}}"
|
||||
></progress-ring>
|
||||
|
||||
<!-- 完成庆祝粒子 -->
|
||||
@@ -85,31 +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="{{isCircuitMode}}">循环训练 · 共需撑 {{circuitTotalWork}} 秒</block>
|
||||
<block wx:elif="{{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">{{isCircuitMode ? phaseTip : 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>
|
||||
|
||||
@@ -197,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>
|
||||
@@ -209,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;"
|
||||
@@ -232,4 +215,7 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 隐藏 canvas:用于绘制训练完成分享海报,onShareAppMessage 取它的导出图 -->
|
||||
<canvas type="2d" id="sharePoster" class="share-poster-canvas"></canvas>
|
||||
</view>
|
||||
|
||||
+234
-258
@@ -2,234 +2,21 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* ---- 循环训练进度面板 ----
|
||||
注意:整块不设固定 height,靠 padding 撑开。真机大字体档下文字会被微信
|
||||
放大,固定高度 + overflow 会把数字裁掉(项目里踩过这个坑)。 */
|
||||
.circuit-bar {
|
||||
/* 抬一层:呼吸光晕(.breathe-ring, z-index:0)在 DOM 里排在本面板之后,
|
||||
不抬层会把橙色晕染盖到卡片上。 */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 620rpx;
|
||||
max-width: 86%;
|
||||
box-sizing: border-box;
|
||||
padding: 26rpx 32rpx 22rpx;
|
||||
margin-bottom: 12rpx;
|
||||
background: var(--card-bg);
|
||||
border: 1rpx solid rgba(var(--primary-rgb), 0.14);
|
||||
border-radius: 28rpx;
|
||||
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.05);
|
||||
transition: border-color 0.35s ease, box-shadow 0.35s ease;
|
||||
animation: circuitBarIn 0.45s cubic-bezier(0.34, 1.4, 0.64, 1) both;
|
||||
}
|
||||
|
||||
/* 休息态整块降温:边框转成 success 绿,和"撑住"的主色态形成对比 */
|
||||
.circuit-bar.is-rest {
|
||||
border-color: rgba(var(--success-rgb), 0.32);
|
||||
box-shadow: 0 6rpx 20rpx rgba(var(--success-rgb), 0.10);
|
||||
}
|
||||
|
||||
@keyframes circuitBarIn {
|
||||
0% { opacity: 0; transform: translateY(-16rpx); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* --- 组进度点阵 ---
|
||||
整行铺满面板内宽,每段按组数严格等分(分段进度条式)。 */
|
||||
.circuit-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
/* 锁住行高:当前组比其它段高 6rpx,不锁行高会在 idle→running 时整块跳一下 */
|
||||
min-height: 18rpx;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
/* flex:1 1 0 + min-width:0 → 每段等分剩余宽度,与内容无关。
|
||||
不设 max-width:一旦封顶,组数少时点阵就只占左边一截(此前的问题)。
|
||||
当前组不再靠"加宽"强调(那会破坏等分),改为增高+发光,宽度恒等分。 */
|
||||
.circuit-dot {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 12rpx;
|
||||
border-radius: 6rpx;
|
||||
background: var(--bg-soft);
|
||||
transition: height 0.3s ease, background 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.circuit-dot.is-done {
|
||||
background: var(--primary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 当前组:增高 + 渐变 + 发光呼吸,一眼锁定进度位置(宽度仍等分) */
|
||||
.circuit-dot.is-active {
|
||||
height: 18rpx;
|
||||
border-radius: 9rpx;
|
||||
opacity: 1;
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
||||
box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.55);
|
||||
animation: circuitDotPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 休息时高亮"下一组",给用户一个即将开始的预告焦点 */
|
||||
.circuit-dot.is-next {
|
||||
height: 18rpx;
|
||||
border-radius: 9rpx;
|
||||
background: rgba(var(--success-rgb), 0.45);
|
||||
animation: circuitDotPulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes circuitDotPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
|
||||
/* 暂停时冻结所有呼吸动画,静止本身就是"已暂停"的信号 */
|
||||
.circuit-bar.is-paused .circuit-dot {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* --- 组数过多时的线性降级条 --- */
|
||||
.circuit-track {
|
||||
height: 12rpx;
|
||||
border-radius: 6rpx;
|
||||
background: var(--bg-soft);
|
||||
overflow: hidden;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.circuit-track-fill {
|
||||
height: 100%;
|
||||
border-radius: 6rpx;
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* --- 主信息行:大数字 + 阶段徽章 --- */
|
||||
.circuit-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.circuit-count {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.circuit-count-num {
|
||||
font-size: 56rpx;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -1rpx;
|
||||
}
|
||||
|
||||
.circuit-count-total {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1;
|
||||
margin-left: 2rpx;
|
||||
}
|
||||
|
||||
.circuit-count-unit {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
/* --- 阶段徽章 --- */
|
||||
.circuit-phase {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 30rpx;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
/* 状态指示灯用 CSS 画而非 icon:项目里的 SVG 图标颜色烤死在 data URI 里,
|
||||
CSS 改不动;这里需要跟随状态换色,纯 CSS 圆点是唯一干净的做法。 */
|
||||
.circuit-phase-dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.circuit-phase-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 工作态:主色渐变实心 + 白灯白字 */
|
||||
.circuit-phase--work {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||||
box-shadow: 0 4rpx 14rpx rgba(var(--primary-rgb), 0.32);
|
||||
}
|
||||
.circuit-phase--work .circuit-phase-dot {
|
||||
background: #FFFFFF;
|
||||
animation: phaseDotBlink 1.2s ease-in-out infinite;
|
||||
}
|
||||
.circuit-phase--work .circuit-phase-text { color: #FFFFFF; }
|
||||
|
||||
/* 休息态:绿色淡底,视觉降温 */
|
||||
.circuit-phase--rest {
|
||||
background: rgba(var(--success-rgb), 0.14);
|
||||
}
|
||||
.circuit-phase--rest .circuit-phase-dot {
|
||||
background: var(--success);
|
||||
animation: phaseDotBlink 1.8s ease-in-out infinite;
|
||||
}
|
||||
.circuit-phase--rest .circuit-phase-text { color: var(--success); }
|
||||
|
||||
/* 待机态 / 暂停态:中性灰,不抢视觉 */
|
||||
.circuit-phase--idle,
|
||||
.circuit-phase--pause {
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
.circuit-phase--idle .circuit-phase-dot,
|
||||
.circuit-phase--pause .circuit-phase-dot {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
.circuit-phase--idle .circuit-phase-text,
|
||||
.circuit-phase--pause .circuit-phase-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@keyframes phaseDotBlink {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
/* --- 副信息行 --- */
|
||||
.circuit-sub {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 24rpx;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ---- ring wrapper ---- */
|
||||
.ring-wrapper {
|
||||
position: relative;
|
||||
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,
|
||||
@@ -237,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;
|
||||
@@ -281,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;
|
||||
@@ -474,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 {
|
||||
@@ -499,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;
|
||||
@@ -548,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;
|
||||
@@ -673,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;
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>首页按钮:圆角 vs 直角 对照预览</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #FF6B35;
|
||||
--primary-light: #FF8C5A;
|
||||
--primary-bg: #FFF3ED;
|
||||
--primary-rgb: 255,107,53;
|
||||
--text-secondary: #999999;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
|
||||
background: #f4f5f7;
|
||||
color: #222;
|
||||
padding: 32px 16px 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
h1 { font-size: 20px; font-weight: 700; margin-bottom: 6px; }
|
||||
.sub { font-size: 13px; color: #888; margin-bottom: 28px; text-align: center; line-height: 1.6; }
|
||||
|
||||
.compare {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.col {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 24px 22px 28px;
|
||||
width: 320px;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,0.06);
|
||||
}
|
||||
.col h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.col .tag {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.col.round .tag { background: var(--primary-bg); color: var(--primary); }
|
||||
.col.square .tag { background: #eee; color: #666; }
|
||||
|
||||
/* ---- button replica (matches ui-btn) ---- */
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
width: 100%;
|
||||
margin-bottom: 14px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.btn .dot { width: 18px; height: 18px; border-radius: 50%; background: currentColor; opacity: .85; }
|
||||
|
||||
.btn--xl { height: 54px; font-size: 18px; }
|
||||
.btn--md { height: 40px; font-size: 15px; }
|
||||
.btn--lg { height: 48px; font-size: 17px; }
|
||||
|
||||
.btn--primary {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.3);
|
||||
}
|
||||
.btn--ghost {
|
||||
background: rgba(var(--primary-rgb), 0.14);
|
||||
color: var(--primary);
|
||||
border: 1px solid rgba(var(--primary-rgb), 0.22);
|
||||
}
|
||||
|
||||
/* the only difference that matters: */
|
||||
.round .btn { border-radius: 24px; }
|
||||
.square .btn { border-radius: 8px; } /* ≈16rpx */
|
||||
|
||||
/* two secondary buttons side by side */
|
||||
.btn-row { display: flex; gap: 8px; }
|
||||
.btn-row .btn { margin-bottom: 14px; }
|
||||
|
||||
.context-note {
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
line-height: 1.5;
|
||||
border-top: 1px dashed #eee;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.verdict {
|
||||
margin-top: 30px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
max-width: 660px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.verdict b { color: var(--primary); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>首页功能按钮 · 圆角 vs 直角</h1>
|
||||
<p class="sub">左侧是小程序现状(圆角 48rpx),右侧是本次改后的效果(直角 0)。<br/>同一套按钮、同一套配色,方便你直接对比「好不好看」。</p>
|
||||
|
||||
<div class="compare">
|
||||
<!-- 圆角现状 -->
|
||||
<div class="col round">
|
||||
<h2>现状 · 圆角</h2>
|
||||
<span class="tag">border-radius: 48rpx</span>
|
||||
|
||||
<div class="btn btn--primary btn--xl"><span class="dot"></span>开始训练</div>
|
||||
<div class="btn-row">
|
||||
<div class="btn btn--ghost btn--xl"><span class="dot"></span>自由训练</div>
|
||||
<div class="btn btn--ghost btn--md"><span class="dot"></span>循环训练</div>
|
||||
</div>
|
||||
<hr style="border:none;border-top:1px solid #f0f0f0;margin:18px 0 16px;">
|
||||
<div class="btn btn--primary btn--lg">开始自由训练</div>
|
||||
<div class="btn btn--primary btn--lg">保存并开始</div>
|
||||
|
||||
<div class="context-note">页面其余元素仍是圆角:卡片 24rpx、进度条 6rpx、目标环是正圆。</div>
|
||||
</div>
|
||||
|
||||
<!-- 直角改后 -->
|
||||
<div class="col square">
|
||||
<h2>改后 · 小圆角</h2>
|
||||
<span class="tag">border-radius: 16rpx</span>
|
||||
|
||||
<div class="btn btn--primary btn--xl"><span class="dot"></span>开始训练</div>
|
||||
<div class="btn-row">
|
||||
<div class="btn btn--ghost btn--xl"><span class="dot"></span>自由训练</div>
|
||||
<div class="btn btn--ghost btn--md"><span class="dot"></span>循环训练</div>
|
||||
</div>
|
||||
<hr style="border:none;border-top:1px solid #f0f0f0;margin:18px 0 16px;">
|
||||
<div class="btn btn--primary btn--lg">开始自由训练</div>
|
||||
<div class="btn btn--primary btn--lg">保存并开始</div>
|
||||
|
||||
<div class="context-note">仅首页这几个按钮变直角,全局组件与其他页面不受影响。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="verdict">
|
||||
小圆角(16rpx)比 10rpx 更圆润一点,更接近卡片的 24rpx 圆角语言,但仍不是全胶囊,保留了清爽感。<br/>
|
||||
已应用到首页 5 个按钮(仅首页、不动全局组件)。若还想微调,往上可贴近卡片的 24rpx、往下回到 10rpx,说一声即可。
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
+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,
|
||||
|
||||
+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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -66,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"/>',
|
||||
@@ -120,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)
|
||||
@@ -170,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
|
||||
}
|
||||
|
||||
|
||||
@@ -68,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
|
||||
}
|
||||
@@ -79,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -111,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
|
||||
|
||||
Reference in New Issue
Block a user