From b52db87ddfaa6af83370edde2eb4969f0ec14e52 Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Tue, 4 Aug 2026 11:23:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=92=E8=A1=8C=E6=A6=9C=E8=99=9A?= =?UTF-8?q?=E6=8B=9F=E9=A2=86=E5=A5=96=E5=8F=B0=20+=20=E5=8B=8B=E7=AB=A0?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E4=B8=8E=E5=B0=BA=E5=AF=B8=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 领奖台数据不足 3 名时显示虚拟空位(虚线轮廓+虚位以待) - 领奖台勋章与昵称对齐改为等高盒结构性修复(移除 margin 盲推) - 全 app 成就勋章图标尺寸统一为 30rpx(前三名/列表/设置页/记录页一致) - 勋章 SVG 按包围盒精确居中(utils/icons.js) - 设置页/记录页勋章展示配套调整 --- .workbuddy/memory/2026-08-04.md | 133 ++++++++++++++++++++++++++++ .workbuddy/memory/MEMORY.md | 4 + cloudfunctions/leaderboard/index.js | 17 ++++ config.js | 48 +++++++--- docs/问题分析_2026-08-04.md | 75 ++++++++++++++++ pages/leaderboard/leaderboard.js | 74 +++++++++++++++- pages/leaderboard/leaderboard.wxml | 116 ++++++++++++++++++------ pages/leaderboard/leaderboard.wxss | 82 +++++++++++++++-- pages/records/records.js | 25 ++++-- pages/records/records.wxss | 4 +- pages/settings/settings.js | 20 ++++- pages/settings/settings.wxml | 8 +- pages/settings/settings.wxss | 25 ++++++ pages/timer/timer.js | 25 +++++- pages/timer/timer.wxml | 10 ++- pages/timer/timer.wxss | 57 ++++++++++-- utils/icons.js | 34 +++++++ 17 files changed, 690 insertions(+), 67 deletions(-) create mode 100644 .workbuddy/memory/2026-08-04.md create mode 100644 docs/问题分析_2026-08-04.md diff --git a/.workbuddy/memory/2026-08-04.md b/.workbuddy/memory/2026-08-04.md new file mode 100644 index 0000000..95f2997 --- /dev/null +++ b/.workbuddy/memory/2026-08-04.md @@ -0,0 +1,133 @@ +# 2026-08-04 工作日志 + +## 修复(治本):勋章图形精确居中 + 领奖台对齐(第二轮) +- 用户反馈第一轮 CSS 微调后勋章"还是偏上"。深挖发现真因:**7 枚 Phosphor 勋章图形在 24 viewBox 内垂直重心各不相同**(medalMilitary/shieldStar 偏下 1.13、trophyPh 偏下 0.75、crownPh 偏上 1.13),CSS margin 只能整体挪 `` 盒子,永远修不齐不同勋章。 +- **关键坑**:自写 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 `` 直接绑定 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 各自用 `` 真实内容 / `` 虚拟空位(虚线轮廓头像 + 灰化 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)完全一致。纯样式无语法风险。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 1a3e034..ae2d6ce 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -3,10 +3,14 @@ ## 关键技术陷阱(最高优先级,改样式/加组件前必读) - **组件 style isolation = `isolated`**:页面 wxss 改不了组件内部类;唯一能跨边界的是 **CSS 变量**(沿 DOM 继承进 slot)。渐变/主色容器内反白文字必须用 `var(--text)`,**绝不**用 `var(--primary)`(橙字画橙底=隐形)。进度条填充用 `var(--on-primary,...)` 让容器可覆盖为白。 - **图标色烤死在 SVG data URI**(`utils/icons.js`:`*Fill`=主题色、`*`(无Fill)=灰 `#999`),CSS 改不了 ``。主色/渐变底(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` + `` 自身 `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**:页面给 `` 等标签加 `border`/`transform`/`box-shadow` 会碎裂或静默失效(仅 `ui-card` 已修 `:host{display:block}`)。描边优先用 `box-shadow:0 0 0 Nrpx` 并补 `border-radius`,别用 `border`。跨组件边界的 `nth-child` 选择器一律失效,交错延迟由页面传 `index` 驱动。 +- **隐私协议在 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 / 可优化 / 合理设计"。 ## 排行榜架构(2026-07-28+,云函数 `cloudfunctions/leaderboard`) - **预计算快照**:定时触发器(每 4min)`_rebuildSnapshots()` 扫 `plank_data` 算 day/month/year/endurance 四榜写 `leaderboard_snapshot`;客户端 99% 走 `_serveFromSnapshot()`(≈50ms),仅训练后 `force=true` 那次实时重算+回写。**改后须重新部署云函数**(定时器随部署注册)。 diff --git a/cloudfunctions/leaderboard/index.js b/cloudfunctions/leaderboard/index.js index 9e7db53..ba0dc74 100644 --- a/cloudfunctions/leaderboard/index.js +++ b/cloudfunctions/leaderboard/index.js @@ -150,6 +150,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 +186,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => { duration: bestDuration, bestDate, sessions: total, + totalDays, nickname: profile.nickname || '', avatarUrl: profile.avatarUrl || '' }) @@ -210,6 +224,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => { openid, duration, sessions, + totalDays, nickname: profile.nickname || '', avatarUrl: profile.avatarUrl || '' }) @@ -240,6 +255,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 +280,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 diff --git a/config.js b/config.js index a1bfdcc..e4f452b 100644 --- a/config.js +++ b/config.js @@ -4,10 +4,10 @@ */ module.exports = { /** 应用版本号,外显在设置页「关于」 */ - version: 'v3.1', + version: 'v3.2', /** 最后更新日期,外显在设置页「关于」 */ - updatedAt: '2026-08-03', + updatedAt: '2026-08-04', /** 开发者名称 / 微信号,设置页点击可复制 */ developer: '三口一瓶', @@ -36,8 +36,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 +60,32 @@ module.exports = { /** 姿势要领每条显示的秒数 */ tipInterval: 10, + /** + * 卡路里估算系数(千卡/秒)。平板支撑按体重约 3-4 千卡/分钟估算, + * 取均值约 0.068 千卡/秒。仅记录页点日期查看「约消耗」时使用。 + * 改这里即可调整,无需动逻辑代码。 + */ + caloriesPerSecond: 0.068, + + /** + * 完成弹窗科学提示(按本次训练时长分段)。 + * 完成时按实际撑的秒数命中第一段 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 +93,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' } ], /** diff --git a/docs/问题分析_2026-08-04.md b/docs/问题分析_2026-08-04.md new file mode 100644 index 0000000..51e89b1 --- /dev/null +++ b/docs/问题分析_2026-08-04.md @@ -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 抽离等有双环境需求时再做。 diff --git a/pages/leaderboard/leaderboard.js b/pages/leaderboard/leaderboard.js index fb90bbd..c40c7ba 100644 --- a/pages/leaderboard/leaderboard.js +++ b/pages/leaderboard/leaderboard.js @@ -65,6 +65,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 @@ -131,6 +162,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 +189,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 +220,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 +244,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 +273,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 { diff --git a/pages/leaderboard/leaderboard.wxml b/pages/leaderboard/leaderboard.wxml index 20ffb7b..4df587f 100644 --- a/pages/leaderboard/leaderboard.wxml +++ b/pages/leaderboard/leaderboard.wxml @@ -44,38 +44,75 @@ {{podiumSlogan}} - - + + - - - 2 - - {{rankedList[1].name}} - {{rankedList[1].durationText}} - {{rankedList[1].subDate}}{{rankedList[1].subTime}}{{rankedList[1].subText}} + + + + 2 + + + {{rankedList[1].name}} + + + {{rankedList[1].durationText}} + {{rankedList[1].subDate}}{{rankedList[1].subTime}}{{rankedList[1].subText}} + + + + + 2 + + 虚位以待 + - - - - 1 - - {{rankedList[0].name}} - {{rankedList[0].durationText}} - {{rankedList[0].subDate}}{{rankedList[0].subTime}}{{rankedList[0].subText}} + + + + + 1 + + + {{rankedList[0].name}} + + + {{rankedList[0].durationText}} + {{rankedList[0].subDate}}{{rankedList[0].subTime}}{{rankedList[0].subText}} + + + + + + 1 + + 虚位以待 + - - - 3 - - {{rankedList[2].name}} - {{rankedList[2].durationText}} - {{rankedList[2].subDate}}{{rankedList[2].subTime}}{{rankedList[2].subText}} + + + + 3 + + + {{rankedList[2].name}} + + + {{rankedList[2].durationText}} + {{rankedList[2].subDate}}{{rankedList[2].subTime}}{{rankedList[2].subText}} + + + + + 3 + + 虚位以待 + @@ -84,7 +121,7 @@ @@ -93,7 +130,20 @@ - {{item.name}} + + {{item.name}} + + + {{item.subText}} {{item.durationText}} @@ -106,7 +156,19 @@ {{myEntry.rank}} - 我 ({{myEntry.name}}) + + 我 ({{myEntry.name}}) + + + {{myEntry.subText}} {{myEntry.durationText}} diff --git a/pages/leaderboard/leaderboard.wxss b/pages/leaderboard/leaderboard.wxss index b470c9b..3ba39bf 100644 --- a/pages/leaderboard/leaderboard.wxss +++ b/pages/leaderboard/leaderboard.wxss @@ -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); diff --git a/pages/records/records.js b/pages/records/records.js index 24c5a79..9dd28bd 100644 --- a/pages/records/records.js +++ b/pages/records/records.js @@ -156,14 +156,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() }, @@ -216,7 +225,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: { diff --git a/pages/records/records.wxss b/pages/records/records.wxss index 8c8d1c4..1c7c7fa 100644 --- a/pages/records/records.wxss +++ b/pages/records/records.wxss @@ -147,8 +147,8 @@ } .badge-node-icon { - width: 32rpx; - height: 32rpx; + width: 30rpx; + height: 30rpx; } /* 已解锁节点的 ✓ 角标:纯 CSS 画勾(图标 SVG 颜色烤死,无法用变量改色) */ diff --git a/pages/settings/settings.js b/pages/settings/settings.js index 3354bd5..81ff457 100644 --- a/pages/settings/settings.js +++ b/pages/settings/settings.js @@ -67,6 +67,7 @@ Page({ icons: iconsMod.build(), nickName: '', avatarUrl: '', + unlockedBadges: [], // Dark mode darkModePref: 'system', darkModeOptions: [ @@ -125,10 +126,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) }, diff --git a/pages/settings/settings.wxml b/pages/settings/settings.wxml index 3a6324b..80756f8 100644 --- a/pages/settings/settings.wxml +++ b/pages/settings/settings.wxml @@ -13,7 +13,13 @@ - {{nickName || '未设置'}} + + {{nickName || '未设置'}} + + + + + 排行榜将显示此昵称 diff --git a/pages/settings/settings.wxss b/pages/settings/settings.wxss index 2e7279a..67a3f61 100644 --- a/pages/settings/settings.wxss +++ b/pages/settings/settings.wxss @@ -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); diff --git a/pages/timer/timer.js b/pages/timer/timer.js index 4484d9a..e60c6b7 100644 --- a/pages/timer/timer.js +++ b/pages/timer/timer.js @@ -56,6 +56,9 @@ Page({ completionStreak: 0, completionLevel: 'normal', completionMessage: '', + // 完成弹窗科学提示(config.scienceTips 按本次时长选段) + completionTipValue: '', + completionTipRisk: '', icons: iconsMod.build() }, @@ -700,6 +703,22 @@ Page({ 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 @@ -743,6 +762,7 @@ 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 @@ -761,7 +781,10 @@ 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 }) // Animate the big number from 0 to elapsed diff --git a/pages/timer/timer.wxml b/pages/timer/timer.wxml index 61e85f9..da08624 100644 --- a/pages/timer/timer.wxml +++ b/pages/timer/timer.wxml @@ -197,7 +197,6 @@ {{fmt.numPart(completionElapsed)}} {{fmt.unitPart(completionElapsed)}} - 本次训练 +{{completionOvertime}} @@ -209,6 +208,15 @@ 连续打卡 {{completionStreak}} 天 + + + {{completionTipValue}} + + 小贴士 + {{completionTipRisk}} + + + 再来一次 diff --git a/pages/timer/timer.wxss b/pages/timer/timer.wxss index a475a07..f08d245 100644 --- a/pages/timer/timer.wxss +++ b/pages/timer/timer.wxss @@ -474,7 +474,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 +500,6 @@ margin-left: 8rpx; } -.completion-label { - font-size: 26rpx; - color: var(--text-secondary); - margin-bottom: 24rpx; -} - .completion-overtime { display: flex; align-items: baseline; @@ -552,6 +547,54 @@ 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; diff --git a/utils/icons.js b/utils/icons.js index bbdfd76..9e53abe 100644 --- a/utils/icons.js +++ b/utils/icons.js @@ -66,6 +66,20 @@ const PATHS = { moon: '', diamond: '', star: '', + // Phosphor medal — scaled from 256 to 24 viewBox (a 命令的 large-arc/sweep + // 标志位保持 0/1,仅坐标缩放)。试点勋章图标,挂在 30 天「神登」上。 + medal: '', + // 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: '', + medalMilitary: '', + trophyPh: '', + certificate: '', + shieldStar: '', + crownPh: '', // Party popper (first-time celebrations) — Material celebration party: '', partyFill: '', @@ -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: * = 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 }