循环训练(circuit)
- 新增 utils/circuitTimer.js 状态机: 工作->休息->...->完成, stop() 返回有效撑总秒(不含休息)
- 首页新增「循环训练·分组练习」入口 + stepper 配置弹窗
- 配置本地持久化 circuit_config(每组时长/组数/休息/每周目标), 下次自动预填
- 记录以 planId:'circuit' + mode:'circuit' 落库, duration 语义不变 -> 排行榜/统计/每日计划进度零改动
- 记录页展示「循环 N组xMs·休Rs」
计时器顶部循环进度面板
- 组进度点阵按组数等分铺满(flex:1 1 0, 去掉 max-width 封顶), 当前组以增高而非加宽强调
- 大号「X/Y 组」+ 四态阶段徽章(撑住/休息中/准备开始/已暂停) + 累计秒数副行
- 状态灯用纯 CSS 圆点(SVG 图标颜色烤死在 data URI 里, CSS 改不动)
- 超过 15 组自动降级为线性进度条; 面板不设固定高度, 规避真机大字体档裁字
修复: 循环训练全程无语音
- 根因: circuit 的 onTick 走 _circuitCue(只有震动), 从未调用 _remind
- 接入整场进度播报, 基准为 sets x holdPerSet(而非每组), 避免 4 组播 4 次「已完成一半」
- 新增 restStart/nextSet/lastSet 三条不含数字的词条, 客户端与 tts 云函数词表同步
- 修复必然撞车的时序: 偶数组时 halfway 与休息切换同一 tick, 加 2.6s 优先窗口让位
- 防吵闸门: 每组 <10s 不播过场语音, 休息 <5s 不播; 预载按模式取词条
训练期间屏幕常亮
- wx.setKeepScreenOn: onStart 开 / onUnload 关 / onShow 在 isRunning 时重新武装
- 切后台再回来该标志会被系统清掉, 故必须重新武装, 否则后半程丢语音与震动
- 低基础库与 Android 省电模式静默降级, 不弹 toast
修复: 首页打卡卡片左侧两个竖条
- 根因: ui-card 宿主节点无 display 声明为 inline, 页面侧加的 border 被打断成两个零宽行盒碎片
- ui-card 加 :host{display:block}, margin-bottom 从内部 view 上提到宿主
- 高亮由 border 改为 box-shadow spread 描边(不占布局, 无跳动) + 补 border-radius
- 连带修复失效的交错入场: nth-child 跨组件边界恒匹配 1, 改为 index 属性驱动内联 animation-delay, 14 处调用点补序号
注意: tts 云函数需重新部署, 否则新增的 3 条词条返回 Unknown prompt key(静默降级不报错)
17 KiB
项目长期记忆(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)。
- 放在主色/渐变底(gradient hero 卡、primary 实心按钮)上的图标必须用白色变体(
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 包裹,而不是怀疑部署。
- 修复:
排行榜架构(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.jsonLaunch在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 个 tab,2026-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)。 -
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 可以命中宿主节点上的 class(style 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)*100ms),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,否则描边是直角框套在圆角卡片外面。
- 页面 wxss 可以命中宿主节点上的 class(style isolation 只隔离组件内部),所以样式确实生效了——但按 inline 盒规则渲染:
约定
- UI 动词说法:index=首页/训练首页,timer=训练页,leaderboard=排行榜,records=记录,settings=设置。
- 美化按"见效快优先"分批做,每轮 node --check + grep 残留再交付。
- git 推送需走 sandbox 外:本机 Bash 工具默认 sandbox 隔离网络,
git push到 github.com 会SSL connection timeout(约 5 分钟才报错)。必须用dangerouslyDisableSandbox: true在沙箱外执行推送(或run_in_background+ 沙箱外)才能连上。无 SSH key,remote 仅 HTTPS。
功能:循环训练 circuit(2026-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;未动云函数/榜单/云同步。