feat: 排行榜虚拟领奖台 + 勋章对齐与尺寸统一

- 领奖台数据不足 3 名时显示虚拟空位(虚线轮廓+虚位以待)
- 领奖台勋章与昵称对齐改为等高盒结构性修复(移除 margin 盲推)
- 全 app 成就勋章图标尺寸统一为 30rpx(前三名/列表/设置页/记录页一致)
- 勋章 SVG 按包围盒精确居中(utils/icons.js)
- 设置页/记录页勋章展示配套调整
This commit is contained in:
2026-08-04 11:23:48 +08:00
parent bce796d893
commit b52db87ddf
17 changed files with 690 additions and 67 deletions
+133
View File
@@ -0,0 +1,133 @@
# 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+translateround(4))。`unarc` 把弧线展开为贝塞尔,path 变长(medal 344→1153 字符)但形状无损,7 枚共约 +9KB 可忽略。
- 同时 CSS`.podium-badge` margin-top 2rpx→6rpxflex 行盒中心与中文 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 里 setDatatimer.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-bottom8→32rpx)保持呼吸感。grep 确认无残留。
## 试点:勋章图标换成 Phosphor medal30天「神登」)
- 对比 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'setDatasettings.wxml 昵称包 profile-name-rowflex),右侧 wx:for 渲染勋章组;settings.wxss 加 profile-name-row/profile-badges/profile-badge32rpx,昵称可收缩省略、勋章组 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 + 勋章 imageis-me 行 iconFill、my-bar iconWhitewx:if myLatestBadge);wxss 加 rank-name-row/rank-badge30rpx)。
- 数据纯本地,零云函数改动(勋章是"我的"荣誉,别人看不到)。
- 验证:语法通过;边界 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`:删掉 myLatestBadgedata/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/daysmy-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)直接赋给了 badgeIconWXML 直接 `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-rowflex 居中),右侧加 podium-badge(第 1 名用 podium-badge--first 34rpx2/3 名 30rpx),带 onBadgeTapleaderboard.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)完全一致。纯样式无语法风险。
+4
View File
@@ -3,10 +3,14 @@
## 关键技术陷阱(最高优先级,改样式/加组件前必读) ## 关键技术陷阱(最高优先级,改样式/加组件前必读)
- **组件 style isolation = `isolated`**:页面 wxss 改不了组件内部类;唯一能跨边界的是 **CSS 变量**(沿 DOM 继承进 slot)。渐变/主色容器内反白文字必须用 `var(--text)`**绝不**用 `var(--primary)`(橙字画橙底=隐形)。进度条填充用 `var(--on-primary,...)` 让容器可覆盖为白。 - **组件 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。 - **图标色烤死在 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,图形重心可能偏上/偏下 ±1rpxmedalMilitary 偏下 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://)裁不掉。 - **圆形头像裁剪**:必须「外层 `overflow:hidden` + `<image>` 自身 `border-radius:50%`」双保险,否则远程照片(cloud://)裁不掉。
- **系统字体缩放致文字溢出固定盒**(真机重现、模拟器不重现):`getAppBaseInfo().host.env==='devtools'` 时跳过补偿,真机/PC 才把字号 `÷factor` 并 clamp。凡文字进固定圆/方盒且靠 `overflow:hidden` 裁切,都要考虑。 - **系统字体缩放致文字溢出固定盒**(真机重现、模拟器不重现):`getAppBaseInfo().host.env==='devtools'` 时跳过补偿,真机/PC 才把字号 `÷factor` 并 clamp。凡文字进固定圆/方盒且靠 `overflow:hidden` 裁切,都要考虑。
- **云函数写库必须 `{ data: {...} }` 包裹**wx-server-sdk 2.6.3):`set/update/add` 漏包裹报 `parameter.data should be object`。读操作 `get()` 无此限制。 - **云函数写库必须 `{ data: {...} }` 包裹**wx-server-sdk 2.6.3):`set/update/add` 漏包裹报 `parameter.data should be object`。读操作 `get()` 无此限制。
- **自定义组件宿主默认 inline**:页面给 `<ui-card>` 等标签加 `border`/`transform`/`box-shadow` 会碎裂或静默失效(仅 `ui-card` 已修 `:host{display:block}`)。描边优先用 `box-shadow:0 0 0 Nrpx` 并补 `border-radius`,别用 `border`。跨组件边界的 `nth-child` 选择器一律失效,交错延迟由页面传 `index` 驱动。 - **自定义组件宿主默认 inline**:页面给 `<ui-card>` 等标签加 `border`/`transform`/`box-shadow` 会碎裂或静默失效(仅 `ui-card` 已修 `:host{display:block}`)。描边优先用 `box-shadow:0 0 0 Nrpx` 并补 `border-radius`,别用 `border`。跨组件边界的 `nth-child` 选择器一律失效,交错延迟由页面传 `index` 驱动。
- **隐私协议在 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` ## 排行榜架构(2026-07-28+,云函数 `cloudfunctions/leaderboard`
- **预计算快照**:定时触发器(每 4min)`_rebuildSnapshots()``plank_data` 算 day/month/year/endurance 四榜写 `leaderboard_snapshot`;客户端 99% 走 `_serveFromSnapshot()`(≈50ms),仅训练后 `force=true` 那次实时重算+回写。**改后须重新部署云函数**(定时器随部署注册)。 - **预计算快照**:定时触发器(每 4min)`_rebuildSnapshots()``plank_data` 算 day/month/year/endurance 四榜写 `leaderboard_snapshot`;客户端 99% 走 `_serveFromSnapshot()`(≈50ms),仅训练后 `force=true` 那次实时重算+回写。**改后须重新部署云函数**(定时器随部署注册)。
+17
View File
@@ -150,6 +150,19 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
const openid = doc._openid || 'unknown' const openid = doc._openid || 'unknown'
const records = doc.records || {} 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') { if (period === 'endurance') {
// 全局单次最久:遍历该用户全部月份的 records,取 max(duration) 那条, // 全局单次最久:遍历该用户全部月份的 records,取 max(duration) 那条,
// 并记下其 r.date 作为"产生时间"。不做日/月/年过滤、不累加。 // 并记下其 r.date 作为"产生时间"。不做日/月/年过滤、不累加。
@@ -173,6 +186,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
duration: bestDuration, duration: bestDuration,
bestDate, bestDate,
sessions: total, sessions: total,
totalDays,
nickname: profile.nickname || '', nickname: profile.nickname || '',
avatarUrl: profile.avatarUrl || '' avatarUrl: profile.avatarUrl || ''
}) })
@@ -210,6 +224,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
openid, openid,
duration, duration,
sessions, sessions,
totalDays,
nickname: profile.nickname || '', nickname: profile.nickname || '',
avatarUrl: profile.avatarUrl || '' avatarUrl: profile.avatarUrl || ''
}) })
@@ -240,6 +255,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
duration: item.duration, duration: item.duration,
sessions: item.sessions, sessions: item.sessions,
bestDate: item.bestDate || '', bestDate: item.bestDate || '',
totalDays: item.totalDays || 0,
avatarUrl: item.avatarUrl || '' avatarUrl: item.avatarUrl || ''
})) }))
@@ -264,6 +280,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
duration: myEntryRaw.duration, duration: myEntryRaw.duration,
sessions: myEntryRaw.sessions, sessions: myEntryRaw.sessions,
bestDate: myEntryRaw.bestDate || '', bestDate: myEntryRaw.bestDate || '',
totalDays: myEntryRaw.totalDays || 0,
avatarUrl: myEntryRaw.avatarUrl || '' avatarUrl: myEntryRaw.avatarUrl || ''
} : null } : null
+37 -11
View File
@@ -4,10 +4,10 @@
*/ */
module.exports = { module.exports = {
/** 应用版本号,外显在设置页「关于」 */ /** 应用版本号,外显在设置页「关于」 */
version: 'v3.1', version: 'v3.2',
/** 最后更新日期,外显在设置页「关于」 */ /** 最后更新日期,外显在设置页「关于」 */
updatedAt: '2026-08-03', updatedAt: '2026-08-04',
/** 开发者名称 / 微信号,设置页点击可复制 */ /** 开发者名称 / 微信号,设置页点击可复制 */
developer: '三口一瓶', developer: '三口一瓶',
@@ -36,8 +36,8 @@ module.exports = {
* speed: 语速 -2~2(正数加快,0 为默认)。 * speed: 语速 -2~2(正数加快,0 为默认)。
*/ */
tts: { tts: {
female: { voiceType: 501004, volume: 9, speed: 0 }, female: { voiceType: 501004, volume: 10, speed: 0 },
male: { voiceType: 502005, volume: 9, speed: 0 } male: { voiceType: 502005, volume: 10, speed: 0 }
}, },
/** /**
@@ -60,6 +60,32 @@ module.exports = {
/** 姿势要领每条显示的秒数 */ /** 姿势要领每条显示的秒数 */
tipInterval: 10, 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 仅作趣味命名,不暗示连续打卡。 * 口径=累计去重训练日(可中断);name 仅作趣味命名,不暗示连续打卡。
*/ */
trainingDayBadges: [ trainingDayBadges: [
{ days: 3, name: '抖登', icon: 'hot', iconWhite: 'hotWhite' }, { days: 3, name: '抖登', icon: 'sealCheck', iconWhite: 'sealCheckWhite' },
{ days: 7, name: '中登', icon: 'bolt', iconWhite: 'boltWhite' }, { days: 7, name: '中登', icon: 'medal', iconWhite: 'medalWhite' },
{ days: 14, name: '老登', icon: 'moon', iconWhite: 'moonWhite' }, { days: 14, name: '老登', icon: 'medalMilitary', iconWhite: 'medalMilitaryWhite' },
{ days: 30, name: '神登', icon: 'trophy', iconWhite: 'trophyWhite' }, { days: 30, name: '神登', icon: 'trophyPh', iconWhite: 'trophyPhWhite' },
{ days: 60, name: '上古神登', icon: 'diamond', iconWhite: 'diamondWhite' }, { days: 60, name: '上古神登', icon: 'certificate', iconWhite: 'certificateWhite' },
{ days: 80, name: '洪荒神登', icon: 'star', iconWhite: 'starWhite' }, { days: 80, name: '洪荒神登', icon: 'shieldStar', iconWhite: 'shieldStarWhite' },
{ days: 100, name: '究极登祖', icon: 'crown', iconWhite: 'crownWhite' } { days: 100, name: '究极登祖', icon: 'crownPh', iconWhite: 'crownPhWhite' }
], ],
/** /**
+75
View File
@@ -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.js18 处)、cloudfunctions/leaderboard/index.js5 处)等
- **核实事实**:小程序生产环境的 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 抽离等有双环境需求时再做。
+72 -2
View File
@@ -65,6 +65,37 @@ Page({
this.fetchRank() 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) { fetchRank(cb, force) {
// 序列号:快速切换周期时丢弃旧响应,避免旧 period 的数据覆盖新 period // 序列号:快速切换周期时丢弃旧响应,避免旧 period 的数据覆盖新 period
this._fetchSeq = (this._fetchSeq || 0) + 1 this._fetchSeq = (this._fetchSeq || 0) + 1
@@ -131,6 +162,17 @@ Page({
} else { } else {
base.subText = `${item.sessions}` 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 return base
}) })
const myEntry = result.myEntry ? (() => { const myEntry = result.myEntry ? (() => {
@@ -147,6 +189,16 @@ Page({
} else { } else {
base.subText = `${result.myEntry.sessions}` 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 return base
})() : null })() : null
const empty = list.length === 0 && !myEntry const empty = list.length === 0 && !myEntry
@@ -168,6 +220,17 @@ Page({
const profile = storage.getProfile() const profile = storage.getProfile()
const localName = profile.nickname || '我' 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') { if (period === 'endurance') {
let bestDuration = 0 let bestDuration = 0
@@ -181,7 +244,11 @@ Page({
const entry = { const entry = {
rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '', rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '',
duration: bestDuration, durationText: util.formatDuration(bestDuration), 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 }) this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false })
} else { } else {
@@ -206,7 +273,10 @@ Page({
const entry = { const entry = {
rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '', rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '',
duration, durationText: util.formatDuration(duration), sessions, duration, durationText: util.formatDuration(duration), sessions,
subText: `${sessions}` subText: `${sessions}`,
// 同耐力榜:本地兜底 entry 必须带 isMe,否则勋章不渲染
isMe: true,
...localBadgeFields
} }
this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false }) this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false })
} else { } else {
+89 -27
View File
@@ -44,38 +44,75 @@
<!-- 耐力榜领奖台顶部文案:哲理注脚,点出"撑得越久不一定越好" --> <!-- 耐力榜领奖台顶部文案:哲理注脚,点出"撑得越久不一定越好" -->
<view class="podium-slogan" wx:if="{{activePeriod === 'endurance'}}">{{podiumSlogan}}</view> <view class="podium-slogan" wx:if="{{activePeriod === 'endurance'}}">{{podiumSlogan}}</view>
<!-- Podium: top 3 (only when there are at least 3 entries) --> <!-- Podium: top 3 (shown whenever there is at least 1 entry; empty slots render as virtual placeholders) -->
<view class="podium" wx:if="{{rankedList.length >= 3}}"> <view class="podium" wx:if="{{rankedList.length >= 1}}">
<!-- 2nd --> <!-- 2nd -->
<view class="podium-slot podium-slot--second rank-item--enter" style="animation-delay: 60ms;"> <view class="podium-slot podium-slot--second rank-item--enter" style="animation-delay: 60ms;">
<view class="podium-avatar-wrap"> <block wx:if="{{rankedList[1]}}">
<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-avatar-wrap">
<view class="podium-medal podium-medal--silver">2</view> <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> <view class="podium-medal podium-medal--silver">2</view>
<text class="podium-name">{{rankedList[1].name}}</text> </view>
<text class="podium-dur">{{rankedList[1].durationText}}</text> <view class="podium-name-row">
<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> <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> </view>
<!-- 1st --> <!-- 1st -->
<view class="podium-slot podium-slot--first rank-item--enter" style="animation-delay: 0ms;"> <view class="podium-slot podium-slot--first rank-item--enter" style="animation-delay: 0ms;">
<image class="podium-crown" src="{{icons.crownFill}}" mode="aspectFit"></image> <block wx:if="{{rankedList[0]}}">
<view class="podium-avatar-wrap"> <image class="podium-crown" src="{{icons.crownFill}}" mode="aspectFit"></image>
<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-avatar-wrap">
<view class="podium-medal podium-medal--gold">1</view> <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> <view class="podium-medal podium-medal--gold">1</view>
<text class="podium-name podium-name--first">{{rankedList[0].name}}</text> </view>
<text class="podium-dur podium-dur--first">{{rankedList[0].durationText}}</text> <view class="podium-name-row">
<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> <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> </view>
<!-- 3rd --> <!-- 3rd -->
<view class="podium-slot podium-slot--third rank-item--enter" style="animation-delay: 120ms;"> <view class="podium-slot podium-slot--third rank-item--enter" style="animation-delay: 120ms;">
<view class="podium-avatar-wrap"> <block wx:if="{{rankedList[2]}}">
<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-avatar-wrap">
<view class="podium-medal podium-medal--bronze">3</view> <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> <view class="podium-medal podium-medal--bronze">3</view>
<text class="podium-name">{{rankedList[2].name}}</text> </view>
<text class="podium-dur">{{rankedList[2].durationText}}</text> <view class="podium-name-row">
<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> <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>
</view> </view>
@@ -84,7 +121,7 @@
<view <view
wx:for="{{rankedList}}" wx:for="{{rankedList}}"
wx:key="rank" wx:key="rank"
wx:if="{{rankedList.length < 3 || index >= 3}}" wx:if="{{index >= 3}}"
class="rank-item {{item.isMe ? 'is-me' : ''}} rank-item--enter" class="rank-item {{item.isMe ? 'is-me' : ''}} rank-item--enter"
style="animation-delay: {{index < 10 ? index * 60 : 0}}ms;" style="animation-delay: {{index < 10 ? index * 60 : 0}}ms;"
> >
@@ -93,7 +130,20 @@
</view> </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="avatar avatar--sm {{item.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{item.avatarUrl || icons.peopleFill}}" mode="{{item.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="rank-info"> <view class="rank-info">
<text class="rank-name">{{item.name}}</text> <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> <text class="rank-sessions">{{item.subText}}</text>
</view> </view>
<text class="rank-dur">{{item.durationText}}</text> <text class="rank-dur">{{item.durationText}}</text>
@@ -106,7 +156,19 @@
<view class="rank-num my-bar__num">{{myEntry.rank}}</view> <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="avatar avatar--sm my-bar__avatar {{myEntry.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{myEntry.avatarUrl || icons.peopleFill}}" mode="{{myEntry.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="rank-info"> <view class="rank-info">
<text class="rank-name">我 ({{myEntry.name}})</text> <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> <text class="rank-sessions">{{myEntry.subText}}</text>
</view> </view>
<text class="rank-dur">{{myEntry.durationText}}</text> <text class="rank-dur">{{myEntry.durationText}}</text>
+76 -6
View File
@@ -194,16 +194,42 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
line-height: 1.3; /* 关键对齐修复:行盒高度设为与勋章等高(30rpx)。这样 flex 的 align-items:center
/* 用 name 的 margin-top 直接控制与上方头像/角标的间距:它是 flex 子元素, 对齐的是"昵称盒中心"与"勋章盒中心"两个等高盒,而非 line-height:1 下偏矮、
margin 必定生效不折叠,不依赖 avatar margin-bottom 的 collapse 行为 且中文 glyph 视觉重心偏下的行盒,图标不会再飘在昵称上方。 */
(WebView/Skyline 下不一致,曾导致角标压住昵称)。 */ line-height: 30rpx;
margin-top: 30rpx;
} }
.podium-name--first { .podium-name--first {
font-size: 28rpx; font-size: 28rpx;
font-weight: 600; 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 /* session count printed on the podium base. Pinned a fixed offset above the
@@ -243,7 +269,8 @@
font-size: 24rpx; font-size: 24rpx;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
margin-top: 2rpx; /* line-height 收紧后行盒缩短约 8rpx,此处补偿保持与名字的视觉间距 */
margin-top: 10rpx;
font-variant-numeric: tabular-nums; 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); 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 */
.rank-list { .rank-list {
padding: 16rpx 24rpx; padding: 16rpx 24rpx;
@@ -340,6 +391,25 @@
white-space: nowrap; 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 { .rank-sessions {
font-size: 22rpx; font-size: 22rpx;
color: var(--text-secondary); color: var(--text-secondary);
+17 -8
View File
@@ -156,14 +156,23 @@ Page({
}, },
onNextMonth() { onNextMonth() {
let { currentYear, currentMonth } = this.data const { currentYear, currentMonth } = this.data
if (currentMonth === 12) { // 计算下一月
currentYear++ let nextYear = currentYear
currentMonth = 1 let nextMonth = currentMonth + 1
} else { if (nextMonth > 12) {
currentMonth++ 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() this.refresh()
}, },
@@ -216,7 +225,7 @@ Page({
// 日期部分,直接 === 永远不匹配,必须用 dateOnly 截取后再比。 // 日期部分,直接 === 永远不匹配,必须用 dateOnly 截取后再比。
const sessions = allRecords.filter(r => util.dateOnly(r.date) === dateStr) const sessions = allRecords.filter(r => util.dateOnly(r.date) === dateStr)
const totalDur = sessions.reduce((sum, r) => sum + r.duration, 0) 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({ this.setData({
showDayDetail: true, showDayDetail: true,
dayDetail: { dayDetail: {
+2 -2
View File
@@ -147,8 +147,8 @@
} }
.badge-node-icon { .badge-node-icon {
width: 32rpx; width: 30rpx;
height: 32rpx; height: 30rpx;
} }
/* 已解锁节点的 ✓ 角标:纯 CSS 画勾(图标 SVG 颜色烤死,无法用变量改色) */ /* 已解锁节点的 ✓ 角标:纯 CSS 画勾(图标 SVG 颜色烤死,无法用变量改色) */
+19 -1
View File
@@ -67,6 +67,7 @@ Page({
icons: iconsMod.build(), icons: iconsMod.build(),
nickName: '', nickName: '',
avatarUrl: '', avatarUrl: '',
unlockedBadges: [],
// Dark mode // Dark mode
darkModePref: 'system', darkModePref: 'system',
darkModeOptions: [ darkModeOptions: [
@@ -125,10 +126,27 @@ Page({
plans: buildPlansList(storage.getCustomPlans()), plans: buildPlansList(storage.getCustomPlans()),
nickName: profile.nickname || '', nickName: profile.nickname || '',
avatarUrl: profile.avatarUrl || '', avatarUrl: profile.avatarUrl || '',
darkModePref: darkMod.getPref() darkModePref: darkMod.getPref(),
// 已解锁勋章:个人资料昵称旁展示(主题色版,浅底可见)
unlockedBadges: this._getUnlockedBadges()
}) })
}, },
/**
* 已解锁勋章列表 = 累计去重训练日达到阈值的 badge。
* iconFill 是 icons.build 生成的主题色变体 keyicons.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() { applyThemeToPage() {
themeMod.applyThemeToPage(this) themeMod.applyThemeToPage(this)
}, },
+7 -1
View File
@@ -13,7 +13,13 @@
</view> </view>
</button> </button>
<view class="profile-info"> <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> <text class="profile-sub">排行榜将显示此昵称</text>
</view> </view>
</view> </view>
+25
View File
@@ -380,6 +380,31 @@
text-overflow: ellipsis; 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 { .profile-sub {
font-size: 22rpx; font-size: 22rpx;
color: var(--text-secondary); color: var(--text-secondary);
+24 -1
View File
@@ -56,6 +56,9 @@ Page({
completionStreak: 0, completionStreak: 0,
completionLevel: 'normal', completionLevel: 'normal',
completionMessage: '', completionMessage: '',
// 完成弹窗科学提示(config.scienceTips 按本次时长选段)
completionTipValue: '',
completionTipRisk: '',
icons: iconsMod.build() icons: iconsMod.build()
}, },
@@ -700,6 +703,22 @@ Page({
this._saveAndShowCompletion(elapsed) 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. * Persist the just-finished training and pop the big completion modal.
* Called from finishTraining (the user manually ends; the timer keeps * Called from finishTraining (the user manually ends; the timer keeps
@@ -743,6 +762,7 @@ Page({
try { wx.setStorageSync('_lb_force_refresh', true) } catch (e) {} try { wx.setStorageSync('_lb_force_refresh', true) } catch (e) {}
const finalStreak = storage.getStreak().count const finalStreak = storage.getStreak().count
const scienceTip = this._pickScienceTip(elapsed)
this.setData({ this.setData({
// Background effects keep playing behind the modal // Background effects keep playing behind the modal
@@ -761,7 +781,10 @@ Page({
completionOvertime: this.data.isCircuitMode ? 0 : Math.max(0, elapsed - this.data.duration), completionOvertime: this.data.isCircuitMode ? 0 : Math.max(0, elapsed - this.data.duration),
completionStreak: finalStreak, completionStreak: finalStreak,
completionLevel: level, completionLevel: level,
completionMessage: message completionMessage: message,
// 科学提示(按时长分段;无配置时为空字符串,wx:if 不渲染)
completionTipValue: scienceTip.value,
completionTipRisk: scienceTip.risk
}) })
// Animate the big number from 0 to elapsed // Animate the big number from 0 to elapsed
+9 -1
View File
@@ -197,7 +197,6 @@
<text class="completion-number {{completionElapsed >= 60 ? 'completion-number--long' : ''}}">{{fmt.numPart(completionElapsed)}}</text> <text class="completion-number {{completionElapsed >= 60 ? 'completion-number--long' : ''}}">{{fmt.numPart(completionElapsed)}}</text>
<text class="completion-unit">{{fmt.unitPart(completionElapsed)}}</text> <text class="completion-unit">{{fmt.unitPart(completionElapsed)}}</text>
</view> </view>
<text class="completion-label">本次训练</text>
<view class="completion-overtime" wx:if="{{completionOvertime > 0}}"> <view class="completion-overtime" wx:if="{{completionOvertime > 0}}">
<text class="overtime-plus">+{{completionOvertime}}</text> <text class="overtime-plus">+{{completionOvertime}}</text>
@@ -209,6 +208,15 @@
<text class="streak-text">连续打卡 {{completionStreak}} 天</text> <text class="streak-text">连续打卡 {{completionStreak}} 天</text>
</view> </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-actions">
<view class="completion-btn completion-btn--primary" bindtap="onTrainAgain"> <view class="completion-btn completion-btn--primary" bindtap="onTrainAgain">
<text>再来一次</text> <text>再来一次</text>
+50 -7
View File
@@ -474,7 +474,8 @@
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: center; justify-content: center;
margin-bottom: 8rpx; /* 8rpx 原间距 + 24rpx 原 completion-label 的 margin-bottom,删除标签后合并到这里 */
margin-bottom: 32rpx;
} }
.completion-number { .completion-number {
@@ -499,12 +500,6 @@
margin-left: 8rpx; margin-left: 8rpx;
} }
.completion-label {
font-size: 26rpx;
color: var(--text-secondary);
margin-bottom: 24rpx;
}
.completion-overtime { .completion-overtime {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -552,6 +547,54 @@
font-weight: 500; 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 { .completion-actions {
display: flex; display: flex;
gap: 20rpx; gap: 20rpx;
+34
View File
@@ -66,6 +66,20 @@ const PATHS = {
moon: '<path d="M12 21a9 9 0 1 1 9-9 7 7 0 0 0-9 9z"/>', 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"/>', 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"/>', 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 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"/>', 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"/>', 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 INACTIVE = '#999999'
const DANGER = '#E53935' 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: * Build an icon map keyed by name. Two variants per name:
* <name> = inactive (gray) * <name> = inactive (gray)
@@ -170,6 +195,15 @@ const build = (theme) => {
out.diamondWhite = _svg(PATHS.diamond, '#FFFFFF') out.diamondWhite = _svg(PATHS.diamond, '#FFFFFF')
out.starWhite = _svg(PATHS.star, '#FFFFFF') out.starWhite = _svg(PATHS.star, '#FFFFFF')
out.crownWhite = _svg(PATHS.crown, '#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 return out
} }