feat: 首页按钮样式调整(小圆角/并排/高度对齐)

- 主按钮与自由/循环训练按钮圆角统一为 16rpx(直角→折中方案)
- 自由训练/循环训练两个次按钮改为并排一行,文字精简为「自由训练」「循环训练」
- 两个次按钮 size 由 md 改为 xl,与主按钮高度一致
- 新增 preview-buttons.html 对照预览
- 更新工作记忆
This commit is contained in:
2026-08-03 17:04:05 +08:00
parent 6ff70971d8
commit bce796d893
5 changed files with 224 additions and 91 deletions
+19 -75
View File
@@ -1,81 +1,25 @@
# 项目长期记忆(wx_pbzc 平板支撑训练小程序)
## 关键技术陷阱
- **小程序组件 style isolation**:自定义组件默认 `isolated`。组件内 `variant` 拼出的类(`gradient`/`in` 等)在组件作用域,页面里写的 `.组件类 <slot后代>` 跨作用域选择器**全部失效**(如 `.ui-card.gradient .streak-num`)。唯一能跨边界传递的是 **CSS 自定义属性**沿 DOM 继承进 slot)。
- 推论:渐变/主色容器内要反白的文字,必须用 `var(--text)/var(--text-secondary)`(容器在 `.gradient` 里重定义过即会变白),**绝不能用 `var(--primary)`**——否则橙色字画在橙色底上=看不见
- 进度条填充同理:用 `var(--on-primary, <原渐变>)` 让容器可覆盖为白,而非依赖失效的后代选择器
- **图标 SVG 颜色是烤死在 data URI 里的**(`utils/icons.js``build()``*Fill``primary` 主题色、`*`(无 Fill)用灰 `#999`)。CSS 的 `color`/`currentColor`/变量**都改不了 `<image src>` 这种图标**。所以:
- 放在**主色/渐变底**gradient hero 卡、primary 实心按钮)上的图标必须用**白色变体**(`hotWhite`/`formWhite`/`playWhite`/`trophyWhite` 等),否则同色隐形
- 加新图标或新彩色容器时,先想清楚底色:浅色底用 `*Fill`(主题色),彩色/主色底用 `*White` 变体
- 主按钮 `ui-btn--primary` 背景是橙渐变,`playFill`(橙) 放上去即隐形——须用 `playWhite`。ghost/outline 按钮底色浅或灰,用 `*Fill`/灰色变体即可。
- `components/ui-btn` 图标尺寸 `1.3em`(随按钮字号自适应),不要回到 `1em` 以免显得过小。
- **需要「随状态换色」的小装饰件,一律用纯 CSS 画,别用 icon**:图标色改不动,而状态灯/圆点要在 白/绿/灰 间切换。`timer` 页循环训练徽章的 `.circuit-phase-dot` 即是此解(14rpx CSS 圆点 + BEM 状态类控制 background)。
- **`timer` 页新增顶部元素必须 `position:relative; z-index:1`**`.breathe-ring`(呼吸光晕,`z-index:0`)在 DOM 里排在页面上方元素之后,同 stacking 上下文下会反盖上去,把主色光晕晕染到白卡片上。循环训练进度面板 `.circuit-bar` 踩过。
- 深色模式有两套事实源,改配色前先统一:`theme.json` 的 dark 与 `app.wxss`/`utils/theme.js` 要一致(已统一到 `#131316` 系)。
- **微信圆形头像/图片裁剪陷阱(用户基础库实测)**:本项目的圆形头像在用户机器上表现如下——
- 单独给 `<image>``border-radius:50%`(无 overflow 父层)→ 圆框里露方图,失效。
- 外层 `<view>``overflow:hidden`+`border-radius:50%` 包裹 → **能裁切 SVG 占位图,但裁不掉远程照片(cloud:// 真实头像)**,照片仍方。所以日榜(多为占位图)正常、月榜/年榜(真实照片)却方。
- **唯一可靠写法**:外层 `overflow:hidden` 父层 **且** `<image>` 自身也加 `border-radius:50%`(双保险)。设置页 `.avatar-btn`(button,overflow:hidden)+`.avatar-img`(image,border-radius:50%) 即是此写法且实测正常。排行榜 `.avatar`/`.avatar__img` 已对齐这一写法。
- 推论:以后做圆形头像,**必须** image 自身 `border-radius:50%`,不能只靠父层 overflow。
- **微信系统字体缩放导致文字溢出固定盒子(模拟器不重现、真机重现)**:微信会按用户「设置→通用→字体大小」自动放大**所有文字(含 rpx 字号)**,但**不放大 rpx 盒子尺寸**width/height/padding)。症状:固定尺寸的圆形/方形容器里的文字在真机被放大、超出后被 `overflow:hidden` 裁掉,模拟器(标准档倍率 1.0)正常。
- 获取缩放:`wx.getAppBaseInfo().fontSizeScaleFactor`(当前字号÷标准 17px);旧接口 `getSystemInfoSync().fontSizeSetting`px,标准 17)。
- **真机/模拟器不对称(关键坑)**:`getAppBaseInfo().host.env` 在模拟器是 `'devtools'`、真机是 `'wechat'`。模拟器会**原样返回你手机的真实缩放倍率**,但**渲染时并不会把文字放大**;真机才会真的放大。所以若不做区分,补偿把字号缩成 `design/factor` 后:真机放大回 design(正常),模拟器按缩小值渲染(数字变小=「不正常」)。→ **必须在 `host.env === 'devtools'` 时跳过补偿、直接用设计字号**;仅在真机/PC 微信才补偿。首页 `_readFontScale()` 已加此判断。
- 补偿法:把容器内的关键文字字号反向除以该倍率(`designRpx / factor`),并在 JS 里 clamp 到安全区间(如 48–96),使真机渲染成和模拟器一致的视觉大小、永不溢出。首页 `.target-time` 已用此法(`index.js` `_readFontScale()`)。
- 配套 CSS`white-space:nowrap` 防换行顶出圆圈。
- 推论:以后凡把文字放进**固定尺寸的圆形/方形容器**且依赖 `overflow:hidden` 裁切,都必须考虑字体缩放补偿,否则真机大字体档必溢出。
- **排行榜头像 1–2s 延迟根因(设计使然,非 bug)**:`profile.avatarUrl` 存的是 `cloud://` 云存储 fileID`settings._uploadAvatar``wx.cloud.uploadFile` 返回 fileID);云函数 `leaderboard` 直接透传,前端 `<image src="cloud://...">``setData` 渲染时才把 fileID 解析成临时 https URL(等效一次 `getTempFileURL` 网络往返)再下载。文字(昵称/时长/排名)是本地字符串瞬时渲染 → 故"文字先出、头像后出",延迟正是头像专属的「云存储解析 + 原图下载」段,叠加云函数冷启动。
- 占位图 `peopleFill` 是本地 SVG data URI 瞬时,所以日榜(多为占位)无感、月/年榜(真实照片)延迟明显。
- 优化方向:①云函数返回前对 `cloud://``avatarUrl` 批量 `cloud.getTempFileURL` 预解析成 https;②`_uploadAvatar` 上传前把 `chooseAvatar` 原图压缩/裁剪到最长边 ~200px、转 jpg/webp,体积降一个数量级;③云函数保活降冷启动。
- **云函数数据库写操作必须 `{ data: {...} }` 包裹(wx-server-sdk 关键坑)**`wx-server-sdk`(本项目 2.6.3) 的 `db.collection().doc(id).set(x)` / `.update(x)` / `.add(x)` **写操作**,参数必须是 `{ data: realObj }` 包裹形式——SDK 内部读 `parameter.data` 当作要写入的文档。直接传裸对象 `set(realObj)` 会让 SDK 读 `realObj.data` === `undefined`,报 `parameter.data should be object instead of undefined`(读操作 `get()` 不受影响,裸对象/无参都行)。`leaderboard` 云函数 `_persistSnapshot` 踩过此坑:写快照全失败、集合一直空。
- 修复:`docRef.set({ data: { period, ranked, myOpenid, updatedAt } })`。写入后文档内容即 `realObj`(data 外套被 SDK 解开),读取 `doc.data` 仍是 `realObj`,读逻辑无需改。
- 推论:**以后任何云函数写库代码,先写 `{ data: ... }`**,别沿用客户端 SDK 的裸对象习惯;出错时先怀疑是不是漏了 data 包裹,而不是怀疑部署。
## 关键技术陷阱(最高优先级,改样式/加组件前必读)
- **组件 style isolation = `isolated`**:页面 wxss 改不了组件内部类;唯一能跨边界的是 **CSS 变量**(沿 DOM 继承进 slot)。渐变/主色容器内反白文字必须用 `var(--text)`**绝不**用 `var(--primary)`(橙字画橙底=隐形)。进度条填充用 `var(--on-primary,...)` 让容器可覆盖为白。
- **图标色烤死在 SVG data URI**`utils/icons.js``*Fill`=主题色、`*`(无Fill)=灰 `#999`),CSS 改不了 `<image>`。主色/渐变底(hero 卡、primary 按钮)须用 `*White` 变体(如 `playWhite`),浅底用 `*Fill`。**随状态换色的小装饰用纯 CSS 画**(如 `.circuit-phase-dot`),别用 icon
- **圆形头像裁剪**:必须「外层 `overflow:hidden` + `<image>` 自身 `border-radius:50%`」双保险,否则远程照片(cloud://)裁不掉
- **系统字体缩放致文字溢出固定盒**(真机重现、模拟器不重现):`getAppBaseInfo().host.env==='devtools'` 时跳过补偿,真机/PC 才把字号 `÷factor` 并 clamp。凡文字进固定圆/方盒且靠 `overflow:hidden` 裁切,都要考虑。
- **云函数写库必须 `{ data: {...} }` 包裹**wx-server-sdk 2.6.3):`set/update/add` 漏包裹报 `parameter.data should be object`。读操作 `get()` 无此限制
- **自定义组件宿主默认 inline**:页面给 `<ui-card>` 等标签加 `border`/`transform`/`box-shadow` 会碎裂或静默失效(仅 `ui-card` 已修 `:host{display:block}`)。描边优先用 `box-shadow:0 0 0 Nrpx` 并补 `border-radius`,别用 `border`。跨组件边界的 `nth-child` 选择器一律失效,交错延迟由页面传 `index` 驱动
## 排行榜架构(2026-07-28 落地 P0/P1/P2 后
- **预计算快照**`cloudfunctions/leaderboard``exports.main` 现在区分两种调用——
- **定时触发器**`config.json``triggers``snapshotTimer` / `0 */4 * * * * *`,每 4 分钟)进入 `_rebuildSnapshots()`:扫描一次 `plank_data``_fetchLatestByOpenid`,走 updatedAt 索引+投影),复用 `_buildFromScan()` 算 day/month/year/endurance 四榜,各自写进 `leaderboard_snapshot` 集合(doc._id=周期,字段 `period/ranked/myOpenid/updatedAt`;首次写自动建集合)。
- **客户端请求**:默认走 `_serveFromSnapshot()`1 次 `get` ≈ 50ms),快照缺失/过期(>6min)才 `_computeBoard()` 实时重算并回写。**首开/冷启动已从 2-3s 降到 ≈50ms。**
- **force 语义**:仅训练后那一次(客户端 `onShow``_lb_force_refresh``force=true`)走 `_computeBoard(force:true,persist:true)` 实时重算+回写快照;其他 99% 请求读快照,已满足"force 不再绕过云缓存"诉求。
- **P1 启动预热**`app.js` `onLaunch``cloud.init()` 后 fire-and-forget 调一次 `leaderboard`(day),提前暖云实例/快照。
- **头像延迟**:云函数内 `getTempFileURL` 预解析已落地(在 `_buildFromScan` 里),前端不再懒加载,故"文字先出头像后出"的 1-2s 延迟已大幅消除;快照每 4min 重写,临时 URL 始终新鲜。
- **改动注意**:要生效必须**重新部署 `leaderboard` 云函数**(定时器随部署注册)。`plank_data.updatedAt` 单字段索引需在控制台已建(用户已建)。响应结构 `{period,ranked,myOpenid,myEntry,updatedAt}` 不变,客户端 `leaderboard.js` 无需改逻辑(仅注释更新)。
- **耐力榜(第 4 个 tab2026-07-31 新增)**:全局单次最久。`_buildFromScan``endurance` 分支遍历每人全部 records 取 `max(duration)` 并记下该条 `r.date` 作"产生时间"→ `bestDate` 字段靠 `publicEntry``...publicData` 展开透传进快照/响应(无需改 data.js)。客户端 `periods``{key:'endurance',label:'耐力',maxRank:10}`;行/领奖台/my-bar 的副标签统一改用 `subText`(耐力=格式化日期 `YYYY-MM-DD HH:MM`,其余=`N次`)。本人未进 top10 时由既有 my-bar`myEntry.rank > rankedList.length`)在底部显示其最佳成绩+日期——依赖快照存 top500、`findMyEntry` 在完整 500 条里定位本人,故即便排 200 也能被找到。**改云函数后须重新部署**(定时器随部署注册 `endurance`)。
## 排行榜架构(2026-07-28+,云函数 `cloudfunctions/leaderboard`
- **预计算快照**定时触发器(每 4min`_rebuildSnapshots()``plank_data` 算 day/month/year/endurance 四榜写 `leaderboard_snapshot`;客户端 99% 走 `_serveFromSnapshot()`(≈50ms),仅训练后 `force=true` 那次实时重算+回写。**改后须重新部署云函数**(定时器随部署注册)。
- **耐力榜(endurance)**:全局单次最久,取 `max(duration)` 并记 `bestDate`;本人未进 top10 由 my-bar 在底部显示(依赖快照存 top500)。
- 头像延迟已通过云函数 `getTempFileURL` 预解析解决;`plank_data.updatedAt` 需建单字段索引。
- **TTS 语音词条是「两份白名单」,必须同步**:`utils/voice.js``PROMPTS``cloudfunctions/tts/index.js``PROMPTS` 各存一份。客户端有、云端没有 → 云函数返回 `Unknown prompt key``_fetchUrl``success:false` 后返回 null`play()` 静默 return**不报错、不弹 toast**,只是不出声,极难察觉)。加词条后**必须重新部署 tts 云函数**。词条设计上要**避免包含数字/变量**(如"第3组"),否则得按 N 合成 N 份音频;用"下一组,准备开始"这类通用句即可覆盖任意组数。
- **`voice.play` 会截断前一条语音**`_playSeq` 机制:新 play 令旧的作废)。凡是可能同一 tick 触发两条语音的地方,必须定优先级。已知必撞场景:circuit 的 halfway 触发点 = `sets×hold/2`,**偶数组时必然落在第 sets/2 组最后一秒**,与 rest 阶段切换同刻 → 用 `_playProgressVoice``_voiceBusyUntil = now+2600`,让阶段语音在窗口内让位。类似地,每组 <10 秒时不播过场语音(一句话约 2 秒,否则连珠炮)
- **circuit 的进度播报基准必须是整场(`sets × holdPerSet`,不能是每组**:否则 4 组会播 4 次"已完成一半啦"。判据用 `<=` 而非 `===`,容忍小程序后台回前台的跳秒补偿。只在 `phase==='working'` 求值——休息时 `workTotal` 冻结,天然不会重复触发
- **`wx.setKeepScreenOn` 是「小程序级」不是「页面级」,且切后台会失效**(训练页已用,`pages/timer/timer.js``_keepScreenOn`):
- 开了不关 → 用户离开训练页后,首页/记录/榜单全程常亮耗电,只有**退出小程序**才自动失效。故 `onUnload` **必须**显式 `keepScreenOn:false`
- 反过来,**切后台再回前台会被系统清掉**,所以 `onShow` 里要 `if (this.data.isRunning) 重新武装`,否则后半程又开始息屏丢语音/震动。
- `fail` 必须静默(部分 Android ROM 省电模式拒绝该调用),低基础库用 `typeof wx.setKeepScreenOn !== 'function'` 降级。
- 背景:息屏/后台后 JS 定时器被节流,`Timer`/`CircuitTimer``Date.now` 补偿秒数不会错,但**实时提示(语音/震动)错过就没了**——平板支撑用户手撑地不碰屏,60 秒必息屏,所以常亮实际是在修 bug 而非锦上添花。
- **自定义组件宿主节点默认是 inline —— 页面侧给组件标签加 border/box-shadow/transform 会碎裂或静默失效**:本项目 `components/` 下 7 个组件**没有任何一个**写了 `:host { display: block }`(全局 grep `:host`/`display:block` 零匹配),组件 wxss 只给内部那个 view(如 `.ui-card`)写样式。宿主节点(页面 wxml 里的 `<ui-card>` 标签)因此是 inline 盒。
- 页面 wxss **可以**命中宿主节点上的 classstyle isolation 只隔离组件内部),所以样式确实生效了——但按 inline 盒规则渲染:
- `border` → inline 盒被内部 block 打断成前/后两个**零宽行盒**,只在碎片上画左右边框(各 4rpx 贴合成一根竖线)→ 视觉是 block **左上角、左下角各一条竖条**,而不是包一圈。首页 `.just-completed`(训练完成 3 秒高亮)就是这个症状。
- `transform: scale()`**完全无效**(不适用于非替换 inline 元素),脉冲/缩放动画静默不生效。
- `box-shadow` → 同样只画在零宽碎片上,等于看不见。
- **诊断特征**:只要是"某个自定义组件标签上加了装饰样式,结果出现零散短线/动画不动",先怀疑宿主 inline,而不是怀疑选择器没命中。
- **修法**:组件 wxss 加 `:host { display: block; }`(小程序支持 `:host`)。改前需回归各页面该组件的间距布局(inline→block 会吃掉行内空白、宽度变 100%);`ui-btn` 这类可能被当行内用的组件要单独评估。
- **同源坑**`ui-card.wxss``.ui-card.in:nth-child(1..4)` 交错入场延迟也是失效的——`.ui-card` 在组件内部永远是根下第 1 个子元素,恒匹配 `nth-child(1)`。跨组件边界做"第 N 个"选择器一律不可靠,要交错必须由页面传 index 或内联 `animation-delay`
- **已修(2026-08-03,仅 `ui-card`**:加了 `:host { display:block; margin-bottom:24rpx; box-sizing:border-box }`,并把 `margin-bottom` 从内部 `.ui-card` **上提到宿主**——否则页面侧加的边框会把 24rpx 外边距一起圈进框里,框比卡片高一截。交错延迟改为 `index` 属性驱动的内联 `animation-delay``50 + min(index,3)*100` ms),14 处调用点全部显式传 `index``index` 默认 0,漏传也只是退回旧行为,零回归。
- **其余 6 个组件宿主仍是 inline,不要盲目批量加 `:host{display:block}`**`ui-btn``pages/timer/timer.wxml` 里靠 `custom-style="margin-right:28rpx"` 并排布局,改 block 会直接撑成整行。要改必须逐个回归。
- **页面侧给组件标签做描边,优先用 `box-shadow: 0 0 0 Nrpx <color>` 而不是 `border`**:border 会改变宿主盒尺寸(撑大或在 border-box 下向内压窄内容),高亮出现/消失时布局跳动;box-shadow 不占布局空间。但宿主自身没有圆角,必须显式补上与内部卡片一致的 `border-radius`,否则描边是直角框套在圆角卡片外面。
## 语音 / 常亮 / 循环训练
- **TTS 词条两份白名单**`utils/voice.js``cloudfunctions/tts/index.js`)须同步;加词条后**必须重新部署 tts 云函数**。词条避免含数字/变量(如"第3组"),用通用句
- **`voice.play` 截断前一条**`_playSeq`circuit halfway 与 rest 切换同刻须用 `_voiceBusyUntil` 让位;每组<10s 不播过场
- **`wx.setKeepScreenOn` 是小程序级、切后台失效**`onUnload` 须显式关,`onShow``isRunning` 须重新武装(否则后半程息屏丢语音/震动)。
- **循环训练 circuit**2026-08-03):自由训练的循环形态,不碰 `plan.js`。入口首页 ghost 按钮→配置弹窗→`timer?circuit=1&...``duration`=组数×每组秒(不含休息);记录 `mode:'circuit'``planId:'circuit'`(计入连胜不计入计划进度)。状态机 `utils/circuitTimer.js`
## 约定
- UI 动词说法:index=首页/训练首页,timer=训练页,leaderboard=排行榜,records=记录,settings=设置。
- 美化按"见效快优先"分批,每轮 node --check + grep 残留再交付。
- **git 推送需走 sandbox 外**:本机 Bash 工具默认 sandbox 隔离网络,`git push` 到 github.com 会 `SSL connection timeout`(约 5 分钟才报错)。必须用 `dangerouslyDisableSandbox: true` 在沙箱外执行推送(或 `run_in_background` + 沙箱外)才能连上。无 SSH keyremote 仅 HTTPS
## 功能:循环训练 circuit2026-08-03 落地,简化版 + 配置持久化)
- 定位:**「自由训练」的循环形态**,不是新计划体系。不碰现有 `utils/plan.js` 的 hold 模式与每日计划进度。
- 入口:首页「循环训练 · 分组练习」ghost 按钮 → 底部配置弹窗(stepper 调 每组时长/组数/休息/每周目标)→ `saveCircuitConfig` + 跳 `timer?circuit=1&hold=&sets=&rest=&sessions=`
- 配置持久化:`utils/storage.js``getCircuitConfig/saveCircuitConfig`(本地 `circuit_config` 键,含默认值+范围校验,**不进云同步**)。下次打开弹窗预填上次值。
- 状态机:`utils/circuitTimer.js`(新)`CircuitTimer` 类,接口仿 `Timer``start/stop/pause/resume` + `onTick/onPhaseChange/onComplete``stop()` 返回 `workTotal`(有效撑总秒,不含休息)。
- **关键不变量 1`duration` 始终 = 有效撑总秒(组数×每组秒,不含休息)** → 排行榜/里程碑/统计零改动。
- **关键不变量 2:循环记录 `planId``'circuit'`** → `getPlanDay` 不会把它算进每日计划进度;但 `updateStreak` 仍计入连胜(用户确实练了)。
- 记录落库字段:`mode:'circuit'` + `sets/holdPerSet/restPerSet/sessionsPerWeek`records 页按 `r.mode` 显示「循环 N组×Ms」)。
- 阶段提示:TTS 只有 6 个固定 key 无"第N组/休息"词 → circuit 阶段切换用**震动 + 屏上 phaseTip 文本**,完成复用 `complete` 语音。
- 进度环:circuit 模式轨道恒中性灰(否则每阶段都按"累计≥阶段长"误判变绿)。
- 未做(可选后续):周频次硬追踪(仅存 sessionsPerWeek 作显示)、逐周进阶、设置页统一管理。
- 验证:6 个 JS 全过 `node --check`;未动云函数/榜单/云同步。
- 页面叫法:index=训练首页,timer=训练页,leaderboard=排行榜,records=记录,settings=设置。
- 美化按"见效快优先"分批,每轮 `node --check` + grep 残留再交付。
- **git push 须 `dangerouslyDisableSandbox:true`**(沙箱隔离致 github SSL 超时);无 SSH,仅 HTTPS remote