feat(timer): 循环训练组进度改为圆环分段弧 + 信息气泡三行合一

- progress-ring 新增 sets/currentSet/resting/successColor 属性,
  圆环外圈绘制分段弧(已完成=主色/当前=呼吸光点/休息下一组=绿),单组模式零渲染
- timer 删除顶部 circuit-bar 进度面板,布局骨架三模式统一
- 呼吸环淡化(描边 0.55→0.20,柔光渐变强度减半)
- 目标卡与提示气泡合并为 info-bubble,状态文案(撑住/休息中/已暂停)并入气泡顶部
This commit is contained in:
2026-08-12 09:39:41 +08:00
parent 0464eeaba6
commit 05c4763e49
18 changed files with 720 additions and 497 deletions
+45
View File
@@ -0,0 +1,45 @@
// 一次性脚本:清除小程序/云函数中的调试 console.* 输出
// 安全策略:
// 1) 箭头函数体 `const x = (e) => console.xxx(...)` → 替换为 `const x = () => {}`
// 2) 独立 console 语句整行删除(含无分号情况)
// 3) 收敛多余空行(>=3 个连续换行压成 2 个)
// 删除后由调用方逐个 node --check 验证语法。
const fs = require('fs')
const files = [
'/Users/liubleed/Documents/wx_pbzc/utils/cloud.js',
'/Users/liubleed/Documents/wx_pbzc/utils/storage.js',
'/Users/liubleed/Documents/wx_pbzc/utils/voice.js',
'/Users/liubleed/Documents/wx_pbzc/utils/theme.js',
'/Users/liubleed/Documents/wx_pbzc/app.js',
'/Users/liubleed/Documents/wx_pbzc/pages/leaderboard/leaderboard.js',
'/Users/liubleed/Documents/wx_pbzc/pages/timer/timer.js',
'/Users/liubleed/Documents/wx_pbzc/pages/settings/settings.js',
'/Users/liubleed/Documents/wx_pbzc/cloudfunctions/leaderboard/index.js',
]
const INDEP = /^\s*console\.(log|warn|error|debug)\(.*\)\s*;?\s*$/
const ARROW = /^(\s*const\s+\w+\s*=\s*\([^)]*\)\s*=>\s*)console\.(log|warn|error|debug)\(/
let total = 0
for (const f of files) {
if (!fs.existsSync(f)) { console.log('SKIP (missing):', f); continue }
const lines = fs.readFileSync(f, 'utf8').split('\n')
const out = []
let removed = 0
for (const line of lines) {
const a = line.match(ARROW)
if (a) {
out.push(a[1].replace(/=>\s*$/, '=> {}'))
removed++
continue
}
if (INDEP.test(line)) { removed++; continue }
out.push(line)
}
const cleaned = out.join('\n').replace(/\n{3,}/g, '\n\n')
fs.writeFileSync(f, cleaned)
total += removed
console.log(`cleaned ${removed} ${f.replace('/Users/liubleed/Documents/wx_pbzc/', '')}`)
}
console.log('TOTAL removed:', total)
+20
View File
@@ -0,0 +1,20 @@
# 2026-08-06
## 记录页新增「里程碑」tab — 设计已锁定(尚未写代码)
用户要求:在 records 页「记录日历 / 趋势」后加第三个 tab「里程碑」,展示用户锻炼旅程中的里程碑事件。
**关键决策(用户逐项确认):**
- 语义=**「第一次完成任务」**,不做累计/极值(明确否决"累计X小时/累计N次""单次最久""最长连续N天"这类历史最佳)。
- 排序=**倒序**(最新里程碑在最上)。
- 范围=**5 家族克制版+模式首秀**:
1. `first_train` 第一次训练 → min(record.date)
2. `badge` ×7 勋章首解锁(复用 config.trainingDayBadges 的 name/iconWhite)→ 第 N 个去重训练日=解锁日
3. `first_duration` 时长首突破,门槛 config.milestoneFirsts.durations=[60,180,300,600]s → 正序第一条 duration≥T 的记录日期
4. `first_streak` 连续首突破,门槛 config.milestoneFirsts.streaks=[7,14,30] → 正序扫去重日期,连段首次达 N 的当天
5. 模式首秀:`first_circuit`(首条 mode==='circuit')、`first_plan`(首条 day>=1 的计划训练)
- 节点**不可点**(纯展示)。
- 视觉:竖向时间线(实色圆+白图标+右侧卡),顶部汇总卡+底部"下一个目标"条;空态复用页面级 totalSessions===0 引导。
**架构(待实现):** 新建 `utils/milestones.js` 纯函数 `computeMilestones(records)` → 倒序事件数组;改 records.js/wxml/wxsscontentMode 增 'milestone');config 增 milestoneFirsts。全部从现有 training_records 推导,**零存储/云函数改动**。
**图标:** 节点用 *White 变体(playWhite/boltWhite/formWhite/各 badgeWhite 等);timeWhite、repeatWhite 实现时各加一行或复用现有白图标。注意组件 style isolation 陷阱——图标色烤死在 SVG,必须用 *White。
**状态:用户选择「再调整一下」,暂未批准写代码。** 下一步:与用户敲定文案/门槛/配色后实现。
+22
View File
@@ -0,0 +1,22 @@
# 2026-08-07
## 里程碑 tab 设计校正:时长维度改为「单次最长时间」
用户纠正先前理解偏差:时长里程碑要的是**单次最长时间(个人最佳)**,不是"首次突破 X 分钟"的阈值事件。
这其实与用户最初原话"什么时间单次坚持最长"一致;之前被带偏成 `first_duration` 阈值家族。
**校正后的家族(6 类):**
1. `first_train` 第一次训练 → min(record.date)
2. `badge` ×7 勋章首解锁(复用 config.trainingDayBadges)→ 第 N 个去重训练日=解锁日
3. `longest_plank` **单次坚持最久**(个人最佳)→ argmax(duration),取该记录日期+时长;**含循环**circuit 的 record.duration=workTotal 已排除休息,公平)。属「个人最佳」类,与其余「第一次」类并列。
4. `first_streak` 第一次连续打卡 N 天 → 门槛 config.milestoneFirsts.streaks=[7,14,30]
5. 模式首秀:`first_circuit``first_plan`
**config 变化:** `milestoneFirsts` 去掉 `durations`(不再有时长阈值);仅保留 `streaks:[7,14,30]`
`longest_plank` 无需阈值配置,直接取全量记录 max(duration)。
**待定细节(已向用户提出):** `longest_plank` 是「1 个节点=当前历史最长(若被刷新则更新日期)」,还是「每次刷新纪录都记一个节点(PR 进阶时间线)」?默认按前者(最贴合"单次最长时间"语义)。
## 顺带诊断:模拟器 vs 正式版连续打卡不一致
streak 是本地缓存计数器(`current_streak`),`validateStreak` 普通启动只信任旧 count、不重算;`recomputeStreak` 仅删记录/云端恢复时跑。模拟器与真机是两套隔离存储沙箱,且 streak 不随记录并集自动对齐 → 4天 vs 2天属预期差异。根治=启动即 `recomputeStreak()`。用户尚未批准改代码。
+102
View File
@@ -0,0 +1,102 @@
# 2026-08-11
## 首页顶部问候区加入用户头像与昵称
-`pages/index` 顶部问候区("准备好了吗")左侧新增圆形头像 + 昵称(昵称默认"运动达人")。
- 数据来自 `storage.getProfile()`{ nickname, avatarUrl }),在 `refresh()` 中读取,每次 onShow 刷新(settings 改完回首页即更新)。
- 头像圆形裁剪用双保险:外层 `overflow:hidden` + 内层 `image border-radius:50%`;无头像回退 `icons.peopleFill` 人形占位。
- 改动文件:index.wxmlgreeting-row 结构)、index.wxss(横向 flex 布局 + 头像样式)、index.jsdata 加 nickName/avatarUrl、refresh 读取 profile)。`node --check` 通过。
## 首页问候区样式调整(同一轮)
- 删除原来的 emoji/success 状态小图标(greeting-icon),昵称嵌入问候句前置并加逗号:`{昵称},准备好了吗?` / `{昵称},太棒了!`(完成态)。
- 昵称默认"运动达人"。删除 .greeting-nickname / .greeting-text / .greeting-icon 旧样式,标题与昵称同行同字号(40rpx 加粗)。头像仍在左、副标题在下。grep 确认无旧类名残留。
## 循环训练秒数步进改为 ±10 秒
- `pages/index/index.wxml` 循环训练弹窗:「每组时长」(hold) 与「组间休息」(rest) 的 stepper `data-delta` 由 ±5 改为 ±10;组数、每周目标(非秒数)不变。
- JS `onCircuitStep` 的 boundshold 下限5 / rest 下限0)无需改,越界自动 clamp。
## 首页 index 整体改版(参考 plank-training.html,方案 B 橙色系)
- 结构重构(纯视觉/布局,不动训练/记录/设置逻辑):全宽橙渐变 `hero`(头像+昵称+按小时时间问候+「时长」敢不敢来挑战+🔥真实连胜)→ `quest-card` 进度点地图(白卡负 margin 上浮叠 hero,圆点+连线,done/now/final 三态,末节点🏆)→ `dial` 虚线刻度环+呼吸动画(目标时长+预计千卡)→ CTA「立即挑战+时长」带脉冲 → 次级入口自由/循环。
- 取消每日语录模块(用户要求)。
- 配色:方案 Bhero 渐变用 `var(--primary)→var(--primary-light)`,随主题切换变色,保持橙色品牌。
- hero 用 `margin:-24rpx -24rpx 0` 抵消 container 内边距实现全宽;opacity-only 动画避免 transform 破坏弹窗定位。节点地图自适应:计划天数<=8 画每天节点,>8 退化为 5 里程碑节点。
- JS 新增 `greetText/streakText/questNodes/questTip/estKcal` 字段 + `_greetByHour()`/`_buildQuestMap()`;移除 `useSegments/planDays/planProgress``node --check` 通过;grep 确认旧类名(greeting-/header-card/streak-/segment-/milestone-/target-/useSegments 等)零残留。
- 注意:改版前已先 `git commit`(938a7d1)。本次改版尚未提交,待用户预览确认后再提交。
## 首页改版微调(同一轮)
- 千卡估算修正:从 4 千卡/分钟 降至保守 2.2 千卡/分钟(1分30秒≈3千卡),仅作激励参考非精确值。
- 删除「预计消耗」后「· 核心力量 +1」文案;整行放大(24→28rpx,数字 30rpx)。
- 连胜文案「🔥 已连续 N 天打卡」从 hero 底部独立行移到顶部头像+昵称行内(昵称下方),新增 `.hero-user` 纵向堆叠容器。
## 首页改版微调(第2轮)
- 千卡恢复 4 千卡/分钟(用户确认)。
- 删除「今天还没开始,现在就稳住核心」整段(含已完成态「今日已完成 N 秒」),同步删除 `.today-status`/`.done-text`/`.pending-text` 样式;CTA 按钮仍用 `todayDone` 切换「立即挑战/再战一次」文案。
- 连胜文案从「昵称下方」改为「昵称同一行后面」:`.hero-user``flex-direction: row` + `align-items:center` + `gap:14rpx``.hero-social` 上间距归零。
## 首页改版微调(第3轮)
- 计时环整体放大:`.dial` 280→320rpx;计时数字设计字号 84→96(`index.js` 数据默认值 + `_readFontScale` 的 DESIGN 常量,clamp 上限同步 96),`.dial-time` max-width 240→280rpx 防溢出。
- 删除连胜前 🔥 图标(WXML `hero-social` 改为纯文案)。
- 连胜字号与昵称对齐:`.hero-social` 26→28rpx(与 `.hero-greet` 一致)。
## 首页改版微调(第4轮)
- 进度地图(`quest-card`)与「立即挑战」CTA 按钮等宽:`.quest-card` 左右 margin 由 24rpx 改为 0(容器自身 24rpx 内边距已让两者贴合内容区边缘),保留 `margin-top:-56rpx` 上浮叠 hero。
## 训练页(timer)改版(参照用户截图,方案:var(--primary) 主题色 + 无顶部栏)
- 顶部栏按用户要求取消(不画 sound/pause 图标行)。
- `components/progress-ring` 扩展:canvas `_draw()` 新增秒刻度层(ticks 属性=秒数,cap 60;四分位加粗、其余细灰);环内新增 `subText`(副标题,如"目标 30秒");移除组件内 statusText 渲染(状态词改由 timer 页在环下方显示)。
- timer.wxmlprogress-ring 传 `ticks="{{ringTicks}}"` `subText="{{ringSubText}}"`;原 hint-section 替换为「环下方状态词 timer-status + 目标信息卡 goal-card(✅今日目标 X · 第N天 ›,静态箭头) + 运行提示 timer-tip」。
- timer.js 新增派生字段 ringTicks/ringSubText/statusText/goalLine/runningTip,在 _init/_initCircuit/onStart/onPause/onStopCancel/onTrainAgain/各 onTick/onGoalReached 中计算下发;新增 `_durationText(sec)` 语音化时长(45→"45秒",90→"1分30")。
- timer.wxss:新增 .timer-status/.goal-card/.timer-tip 样式;.timer-page 改 justify-content flex-start + .controls margin-top:auto(按钮贴底);删除旧 hint-section/hint-row/hint-text/running-pulse/completed-bounce 样式。
- 状态机(idle/running/paused/completed/circuit/free)、计时逻辑、循环 FSM、呼吸环、彩带、完成弹窗、结束确认弹窗全部保留。node --check 通过;grep 确认 hint-*/status-text/running-pulse/completed-bounce 零残留。改版尚未提交。
## 训练页姿态提示醒目化(气泡卡片)
- 用户要求训练中的姿态提示更醒目。原 `timer-tip` 仅为 26rpx 灰字无修饰。
- 改为「气泡卡片」:白底 + 主色左边框(8rpx) + 圆角 + 轻投影 + 顶部小三角指向圆环 + 左侧人物主题色图标(peopleFill) + 文字放大(26→30rpx 加粗);持续 tipPulse 呼吸动画(scale 1↔1.03)。
- 语义分级:tipType=posture(默认/主色)phase(循环阶段,主色)done(目标达成,成功色+successFill 图标)。timer.js 在 _init/_initCircuit/onTrainAgain(默认 posture+peopleFill)、single onTick(goalReached→done/successFill 否则 posture/peopleFill)、onGoalReached(done)、circuit onTick(phase/peopleFill) 设置 tipType+tipIcon。
- 图标用现成 `peopleFill`(主题色人物,白底醒目),done 用 `successFill`;不手画新增 path(规避 SVG data-URI 色烤死/渲染风险)。node --check 通过;字段引用一致。改版(含前次 timer 重构)于 commit 0464eea 提交。
## 训练页圆环放大 + 环内间距调整
- 用户反馈圆环与中间时钟太贴近。timer.wxml 中 progress-ring `size` 360→420、ringWidth 保持 22(环整体放大、环内边到时钟间隙约 23→53rpx 提升)。
- timer.wxss 呼吸光晕同步放大(breathe-ring-1/2/3520/440/380 → 620/540/460rpx),确保光晕仍罩在更大环外侧。
## 训练页呼吸光环 + 光晕增强(明显可见)
- 用户要求光晕与呼吸明显可见。新增 `.breath-aura` 呼吸光环:进度环外侧 488rpx 直径、6rpx 主色描边(0.55 alpha) + 40rpx 外辉光 + inset 28rpx 内辉光;@keyframes auraBreathe 2.8s 慢呼吸(scale 0.9↔1.06, opacity 0.5↔0.95)running 切 auraBreatheFast 1.3s。timer.wxml 在 ring-wrapper 内 progress-ring 前新增该 view(带 fast 类切换,completed 态 wx:if 隐藏)。
- 现有径向光晕透明度大幅调高:base 0.10→0.20、ring-2 0.14→0.26、ring-3 0.18→0.34,淡出半径 65%→62~68%。
- 纯 CSS transform/opacity 动画,不碰 canvas/进度环组件/状态机;z-index:0 在 progress-ring 之下、可见带在环外侧无遮挡;transform 仅作用于光环自身,不影响 fixed 弹窗(现有 breathe-ring 同机制已验证)。
- 已渲染动画预览 widget 演示可见度。未提交,待用户预览确认。
## 训练页呼吸光环柔和化 + 圆环下方整体下移
- 光环柔和化:scale 幅度 0.9↔1.06 缩至 0.96↔1.04、opacity 峰值 0.95/1.0 降到 0.85/0.9、节奏放慢(idle 2.8s→4s、running 1.3s→2.4s)。
- 圆环下方元素(状态词/目标卡/提示)整体下移:.ring-wrapper 加 margin-bottom:44rpx.controls margin-top:auto 贴底不受影响。
## progress-ring 刻度四分位对齐修复(bug)
- 现象:ticks(=训练时长,cap 60)不被 4 整除时,长刻度按 `i % round(ticks/4)===0` 判定落点,只有顶部(12点,i=0)正中,其余 3 根偏到 96°/102°/198° 等。
- 修复:长刻度改精确画在 12/3/6/9 点 4 个正方向(不依赖 tick 索引整除);短刻度在靠近四分位(角度差<一格 stepDeg=360/ticks)处跳过,避免与长刻度双线重叠。ticks 是否整除 4 都对齐正中。
- node --check 通过。未提交。
## 首页时间圆环改为大字流光时间(无圆环)
- 用户认为 index 计时环用处不大,去掉圆环,目标时长改用大字「渐变流光动感数字」显示(选中的风格)。
- index.wxml:删 `.dial` 圆环整块,改 `<text class="time-display">{{todayTargetText}}</text>` + `<text class="time-label">今日目标时长</text>`,保留「预计消耗 X 千卡」。
- index.wxss:删 `.dial/.dial::before/.dial-time/.dial-label/@keyframes ringBreathe`;新增 `.time-display`(120rpx/900/letter-spacing -2rpx/tabula-nums + 渐变 background-clip:text + timeShimmer 3s 流光, 跟随 var(--primary)/var(--primary-light) 主题色) 与 `.time-label`。含 `color: var(--primary)` 兜底。
- index.js:删 `targetTimeFontSize` 字段与 `_readFontScale()` 方法(onLoad 调用一并移除)——那是给圆环内数字防系统字号缩放溢出的专用逻辑,无圆环不需要。
- grep 确认无 `dial` 类残留(仅 hero::after 的 radial-gradient 误匹配"dial"子串、与圆环无关);node --check 通过。未提交。
## 训练页布局:时钟居中 + 按钮贴底留白
- 用户要求:把底部 3 个按钮(开始/暂停·结束/继续·结束)下移到屏幕底部(以底部 toolbar 为基础),留出空间,时钟(进度环)挪到中间区域。
- 确认 timer 不是 tabBar 页面(tab 仅 index/records/leaderboard/settings),无需额外预留 tabBar 高度;"底部 toolbar"指屏幕底部区域。
- 结构改动 timer.wxml:将 `ring-wrapper` + `timer-status` + `goal-card` + `timer-tip` 整体包进新 `<view class="middle-area">`,controls 仍在外部独立贴底。
- 样式 timer.wxss:新增 `.middle-area { flex:1; width:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:0; }`,使整组(进度环+状态/目标/提示)在剩余高度内垂直居中,与底部按钮之间自然留白;.controls 仍 `margin-top:auto` 贴底。circuit-bar 保持顶部。
- 纯布局重构,未动进度环组件/状态机/计时逻辑/动画。未提交。
## 首页布局:时钟居中 + 3 按钮贴底(修正)
- 误判:上轮把同样的"中间展示区"思路套到了训练页(timer)。用户截图与反馈(`@image#1`)是首页 index,把 3 个按钮(再战一次/自由训练/循环训练)推到屏幕底部(贴近 tab bar)、时钟(00:30)挪到中间区域。
- 撤销对 timer 的误改:timer.wxml 移除 `middle-area` 开闭标签,timer.wxss 删除 `.middle-area` 规则;grep 确认 timer 无 `middle-area` 残留。
- index 改动:index.wxml 根容器加 `index-page` 类(不动全局 `.container`,仅本页面升级为 flex 列)。index.wxss 新增 `.index-page{display:flex;flex-direction:column;}``.timer-wrap` 改为 `flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:0;text-align:center`(垂直居中"00:30 + 今日目标时长 + 预计消耗 千卡");`.action-buttons``margin-top:auto`(贴底)。hero 的负 margin 与 quest-card 的 -56rpx 上浮叠在 flex 列里仍生效。
- 效果:hero+quest 在顶 → 中间留白 + 时钟居中 → 留白 → 3 按钮贴底紧挨 tab bar 上方。
- 未提交。
## 首页时间字体改为 Saira Condensed 900base64 内联)
- 用户认为 Orbitron 不够硬朗,从 6 款硬朗字体对比中选择 Saira Condensed 900。
- **关键修复**:微信小程序 `@font-face``src:url('./local.woff2')` 本地相对路径**不可靠**(devtools 可能假性生效、真机回退系统字体)。之前 Orbitron 用本地路径加载,用户反馈"区别很大"=字体实际没加载。**治本方案**:把 woff2 转 base64 data URI 直接内联到 `@font-face``src`,不依赖任何外部文件/网络,devtools 和真机一致。
- app.wxss `@font-face``src:url('data:font/woff2;charset=utf-8;base64,<24KB>')`font-family:'Saira Condensed', font-weight:900。原 woff2 文件(17.7KB)已删除,不再随包冗余。
- index.wxss `.time-display``font-family:'Saira Condensed','Arial Narrow',sans-serif``font-weight:900``letter-spacing:2rpx`(Saira Condensed 偏窄,小间距即可)。渐变流光 + drop-shadow 阴影不变。
- node --check 通过。未提交。
+64
View File
@@ -0,0 +1,64 @@
# 2026-08-12 工作日志
## 循环训练页遮挡问题分析与 redesign 规划
- 读取 `pages/timer/timer.wxml/wxss/js`,定位循环训练态(circuit mode)布局。
- 问题根因:`.timer-page``flex column``min-height: 100vh` 但无滚动,中间圆环 420rpx + 多层呼吸环 488/620rpx + 顶部卡片 + 状态文字 + 目标卡 + 提示气泡的总高度在小屏/大字体下容易超过视口;底部 `.controls``margin-top: auto` 贴底,结果会被内容顶出可视区域或被安全区截断。
- 规划方案(未改代码):
1. 圆环从 420rpx 缩到 340rpx,呼吸环同步缩小。
2. 顶部卡片内嵌组进度条,组数/阶段/配置摘要合一,减少一层垂直空间。
3. 状态文字与圆环内副标题、目标卡与姿态提示错位排布,避免信息重复。
4. 底部控件固定高度 + `padding-bottom: calc(32rpx + env(safe-area-inset-bottom))`,保证始终在安全区上方可见。
5. 整体改为 `justify-content: space-between` 的 flex 布局,顶部-中部-底部三大块按可用空间分配。
- 已输出 SVG 线框图,待确认后再落地代码。
## 方案修正(用户反馈:timer 页是公用页面,仅循环模式有组进度条)
- 用户指出原规划把页面当"恒有顶部卡片"设计,切换模式重心会跳。
- 修订方案(未改代码):**组进度从独立顶部面板改为「圆环外圈分段弧」**——
-`progress-ring` canvas 里加 sets/currentSet/isResting 属性,track 环外侧画一圈分段弧(已完成=主色、进行中=主色+端点光点、未开始=浅灰、休息时下一段=success 绿)。
- 单组/自由模式不传 sets → 不渲染分段弧,页面结构与现状完全一致,零影响。
- 圆环内副标题循环模式显示「第 X/Y 组」,单组显示「目标 X」。
- 组数 >15 时退化为圆环下方细线进度条。
- 顶部 circuit-bar 面板(wxml 20-53 行、wxss 15-225 行)整个删除,垂直空间全部还给圆环区与底部控件,从根上解决遮挡。
- 已确认 `components/progress-ring` 为 canvas 2d 实现(_draw() 内 ctx.arc/stroke),扩分段弧同套 API,可行。
## 落地:循环训练组进度改造(用户批准后实施)
- **`components/progress-ring/progress-ring.js`**
- 新增属性 `sets/currentSet/resting/successColor`sets<=0 时零渲染,单组/自由模式不受影响)。
- 新增 `_drawSetArcs(pulse)`:track 环外侧画分段弧(arcR = radius + ringWidth/2 + 3rpx,弧宽 8rpx,段间隔 2.5°)。三态:已完成=primaryColor、进行中=primaryColor+端点光点、休息时下一段=successColor('#00B578'),未开始=trackColor。
- 呼吸动画用 `canvas.requestAnimationFrame` 循环(周期 1.6s,sin 相位),只清外圈环带(clip 环形避免碰内圈倒计时弧);paused 时冻结为静态;`detached` 时 cancel。
- `_draw()` 末尾补调 `_drawSetArcs(_setPulsePhase || 0.6)`,保证每秒全量重绘后分段弧仍在。
- **`pages/timer/timer.wxml`**:删除整个 `circuit-bar` 面板(原 18-53 行);`<progress-ring>` 新增 `sets="{{totalSets}}" currentSet="{{currentSet}}" resting="{{isResting}}"`
- **`pages/timer/timer.js`**:删除 `circuitDots/circuitShowDots/circuitTotalWork/dotsDone/dotsActive/dotsNext` 字段与 DOTS_MAX 点阵构建;onTick 由 dots* 改为 `ringSubText: 第 X/Y 组`_initCircuit 的 ringSubText 改为 `共 X 组 · 休息 Y 秒`rest=0 省略休息段),goalLine 保持 `每组 X · 共 Y 组`
- **`pages/timer/timer.wxss`**:删除全部 circuit 样式(.circuit-bar/.circuit-dots/.circuit-track/.circuit-meta/.circuit-count/.circuit-phase/.circuit-sub 等)。
- **落地时的设计决策**:组数上限 50(_int clamp),环形 360° 空间充裕,50 段也不挤线(旧 DOTS_MAX=15 是横向卡片宽度受限),故**不做数量降级**;原方案"组数>15 退化线性条"在环形下无必要。
- 验证:node --check 通过;grep 无残留代码引用(仅注释说明文字)。
## 呼吸环淡化(用户反馈"圆线条太明显")
- `timer.wxss``.breath-aura`border 6rpx/0.55 → 4rpx/0.20box-shadow 外层 40rpx/0.45 → 30rpx/0.22、内层 28rpx/0.22 → 20rpx/0.10auraBreathe 透明度 0.55↔0.85 → 0.30↔0.50fast 版 0.6↔0.9 → 0.34↔0.55。
- `.breathe-ring` 三档径向渐变强度:0.20/0.26/0.34 → 0.13/0.17/0.22,柔光更淡雅,不与倒计时环抢视觉。
## 目标卡与提示气泡轻量化(用户反馈"两个提示框不好看")
- 问题:`.goal-card` 是白底卡片+右箭头,看起来像可点按钮但无 tap;`.timer-tip` 是白底气泡+左侧绿条+上方小三角+呼吸阴影,与目标卡叠在一起视觉噪音大。
- `timer.wxml`:删除 `.goal-card` 中的右箭头 `goal-arrow`
- `timer.wxss`
- `.goal-card` 改为轻量胶囊标签:`inline-flex`、主色 8% 淡底、无边框无阴影、圆角 999rpxpadding 24→14rpx/24rpx;图标 40→32rpx;文字 28→26rpx、权重 600→500、主色。
- `.timer-tip` 改为药丸标签:去掉 `border-left`、小三角、box-shadow、tipPulse 呼吸动画;背景主色 8% 淡底(done 态 success 10%);padding 20/32→12/24rpx;圆角 999rpx;图标 40→30rpx;文字 30→26rpx、权重 600→500。
- 验证:grep 无 `goal-arrow/tipPulse/timer-tip::before/border-left: 8rpx` 残留。
## 目标卡 + 提示气泡合并为单气泡(用户反馈"字体小了、能否合并、页面三模式公用")
- 新结构 `.info-bubble`timer.wxml/wxss):一张主色 8% 淡底圆角卡,两行——
-`info-line--goal`target 图标 + goalLine26rpx/500/主色(静态摘要,常驻)
- 分隔线 `info-divider`(1rpx 主色 14% 淡线,仅提示出现时渲染)
-`info-line--tip`tipIcon + runningTip30rpx/600/`--text`(动态提示,比目标行大一号突出);`--done` 态 success 绿
- 三模式共用同一容器:单组/自由/循环都走 info-bubble;循环休息/暂停时 runningTip='' → 分隔线与提示行整体消失,气泡自动收缩只留目标行,布局不跳。
-`.goal-card/.goal-icon/.goal-text/.timer-tip/.timer-tip-icon/.timer-tip-text` 全部删除,grep 无残留。
## 状态文案并入信息气泡(用户反馈"撑住"等硬编码文案能否整合进气泡)
- 背景:环下方 `.timer-status`statusText:准备开始/撑住/休息中/已暂停)是独立一行,用户希望圆环下只留一张卡。
- `timer.wxml`:删除 `.timer-status` 行,气泡改为三行结构——上 `info-line--status`(小圆点 + statusText+ 恒显分隔线 + 中 `info-line--goal` + 提示行(条件渲染)。
- `timer.wxss``.timer-status` 样式删除,新增 `.info-line--status/.info-status-dot/.info-status-text`
- 状态行 32rpx/700,小圆点 14rpx 用 `background: currentColor` 跟随状态色(纯 CSS,规避 SVG 图标烤死问题)。
- 配色:`status--running`=主色(圆点呼吸 1.6s);休息时 `status` 字段仍是 running,靠 `isResting` 追加 `info-line--rest` 覆盖为 success 绿(呼吸 1.8s);`status--paused/idle`=text-secondary 灰,paused 圆点冻结动画。
- `.info-bubble` margin-top 24→40rpx(承接原 timer-status 间距)、padding 20→24rpx。
- 关键机制备忘:**resting 时 status 字段值仍是 'running'**,状态行转绿必须靠 `isResting` 单独加类,不能只看 status。
- 验证:grep 无 `timer-status` 代码残留(仅注释文字),js 未改动。
+1
View File
@@ -8,6 +8,7 @@
- **系统字体缩放致文字溢出固定盒**(真机重现、模拟器不重现):`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` 驱动。
- **@font-face 本地路径不可靠**:微信小程序 `@font-face``src:url('./local.woff2')` 本地相对路径在真机上不生效(devtools 可能假性生效),字体静默回退系统字体。**唯一可靠方案**:把 woff2 转 base64 data URI 内联到 `src:url('data:font/woff2;charset=utf-8;base64,...')`,不依赖外部文件/网络。`wx.loadFontFace` 需配 downloadFile 域名白名单且网络不稳,同样不推荐。
- **隐私协议在 MP 后台配置,不在 app.json**`chooseAvatar` 属隐私接口,需在小程序管理后台「设置→服务内容声明→用户隐私保护指引」声明「用户信息(头像/昵称)」。代码里(app.json)看不到隐私配置是正常的;判断隐私合规**先确认后台指引状态**,别只看代码就下"审核风险"结论。已上架运行 = 后台已配好。`__usePrivacyCheck__:true` 仅本地调试用,正式版非必须。 - **隐私协议在 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 当阻塞**。 - **App.onLaunch 的 async/await 不阻塞首屏**:微信框架是「非阻塞生命周期调度」——onLaunch 被调用但页面 onLoad/onShow 并行推进,不等 onLaunch 内 Promise resolve。所以 app.js 里 `await cloud.pullAll()` 不会卡首屏,只是 await 之后的同步逻辑延迟执行。判断启动性能**别把 onLaunch 的 await 当阻塞**。
- **代码审查避免过度判断**:给本项目做问题分析时,勿把"合理设计选择/优化建议"拔高成"问题/风险"。已被纠错的判断:①隐私协议缺失(实为后台配置,代码看不到)②onLaunch await 阻塞启动(机制理解错)③组件默认 isolated 不一致(实为合理分层)。下结论前先核实机制事实,区分"真 bug / 可优化 / 合理设计"。 - **代码审查避免过度判断**:给本项目做问题分析时,勿把"合理设计选择/优化建议"拔高成"问题/风险"。已被纠错的判断:①隐私协议缺失(实为后台配置,代码看不到)②onLaunch await 阻塞启动(机制理解错)③组件默认 isolated 不一致(实为合理分层)。下结论前先核实机制事实,区分"真 bug / 可优化 / 合理设计"。
+45
View File
@@ -0,0 +1,45 @@
# 文风 DNA(默认模板)
适用:产品功能更新介绍 / 终端用户向
来源:无历史文章,使用默认模板兜底(2026-08-04)
## 1. 语气温度
温暖、像朋友安利,不端着、不官方腔。
## 2. 人称
第二人称「你」为主;「我们」指开发团队。
## 3. 句式节奏
短句为主(15 字内),复杂处拆成 2-3 句。
## 4. 段落长度
每段 2-4 行,移动端一眼扫完,不堆大段。
## 5. 标题风格
利益点 / 数字前置,如「这次更新,给你 3 个新惊喜」。
## 6. 开头方式
直接抛痛点或好处,1 句切入,不寒暄、不写「大家好」。
## 7. 小标题
动作 / 利益导向,前加 emoji(✨🏆📈 等)增强扫读。
## 8. 列表呈现
功能点用短列表,每点按「做了什么 + 对你意味着什么」展开。
## 9. 用词
口语化,少术语;必要术语用一句话白话解释。
## 10. 结尾
行动召唤:引导更新 / 体验,留一句鼓励收尾。
## 11. emoji 使用
适度点缀小标题与关键句,不堆砌、不喧宾夺主。
## 12. 故事感
轻量:可加一句用户场景(如「坚持打卡第 7 天,想看看自己进步了吗?」)。
## 13. 证据 / 可信度
用具体功能名 + 真实使用场景,避免过度吹捧。
## 14. 节奏结构
先总(一句话更新概览)→ 分(功能块)→ 总(召唤行动)。
+10
View File
File diff suppressed because one or more lines are too long
+209 -8
View File
@@ -19,7 +19,20 @@ Component({
// 60 to avoid clutter). 0 = no ticks drawn. Quarter ticks are emphasized. // 60 to avoid clutter). 0 = no ticks drawn. Quarter ticks are emphasized.
ticks: { type: Number, value: 0 }, ticks: { type: Number, value: 0 },
// Small secondary label under the big time (e.g. "目标 30 秒"). Empty = hidden. // Small secondary label under the big time (e.g. "目标 30 秒"). Empty = hidden.
subText: { type: String, value: '' } subText: { type: String, value: '' },
// ---- Circuit (循环训练) set progress ----
// An outer band of arc segments, one per set, wrapped around the countdown
// ring. sets <= 0 (default) disables the band entirely so single/free mode
// renders exactly as before (zero extra draw cost).
sets: { type: Number, value: 0 },
// 1-based index of the set the CircuitTimer is currently on. While resting it
// still points at the just-finished set (same contract as the old dots panel):
// working -> sets before it are done, itself is active;
// resting -> itself is done, the next set is highlighted green.
currentSet: { type: Number, value: 0 },
resting: { type: Boolean, value: false },
// Highlight color for the "next set" preview during rest.
successColor: { type: String, value: '#00B578' }
}, },
data: { data: {
@@ -37,6 +50,11 @@ Component({
'remaining, duration, size, ringWidth, primaryColor, primaryLightColor, trackColor, ticks'() { 'remaining, duration, size, ringWidth, primaryColor, primaryLightColor, trackColor, ticks'() {
this._draw() this._draw()
},
// Set-progress band: re-render and (re)arm / stop the breathing loop.
'sets, currentSet, resting, status'() {
this._syncSetPulse()
} }
}, },
@@ -54,8 +72,14 @@ Component({
canvas.height = displaySize * dpr canvas.height = displaySize * dpr
this._ctx = canvas.getContext('2d') this._ctx = canvas.getContext('2d')
this._dpr = dpr this._dpr = dpr
this._canvas = canvas
this._draw() this._draw()
this._syncSetPulse()
}) })
},
detached() {
this._stopSetPulse()
} }
}, },
@@ -97,21 +121,36 @@ Component({
// ---- tick marks (inside the band) ---- // ---- tick marks (inside the band) ----
if (ticks > 0) { if (ticks > 0) {
const innerR = radius - lw / 2 - (4 * dpr) // just inside the band const innerR = radius - lw / 2 - (4 * dpr) // just inside the band
const q = Math.max(1, Math.round(ticks / 4)) // quarter emphasis step
const shortLen = 6 * dpr const shortLen = 6 * dpr
const longLen = 12 * dpr const longLen = 12 * dpr
const stepDeg = 360 / ticks
// 短刻度:均匀铺满;但跳过靠近四方位(12/3/6/9 点)的,让位给长刻度,避免双线重叠
for (let i = 0; i < ticks; i++) { for (let i = 0; i < ticks; i++) {
const ang = -Math.PI / 2 + (i / ticks) * Math.PI * 2 const deg = (i / ticks) * 360
const isLong = (i % q === 0) const nearestQuarter = Math.round(deg / 90) * 90
const len = isLong ? longLen : shortLen if (Math.abs(deg - nearestQuarter) < stepDeg) continue
const ang = -Math.PI / 2 + (deg * Math.PI) / 180
const cos = Math.cos(ang) const cos = Math.cos(ang)
const sin = Math.sin(ang) const sin = Math.sin(ang)
ctx.beginPath() ctx.beginPath()
ctx.moveTo(cx + innerR * cos, cy + innerR * sin) ctx.moveTo(cx + innerR * cos, cy + innerR * sin)
ctx.lineTo(cx + (innerR - len) * cos, cy + (innerR - len) * sin) ctx.lineTo(cx + (innerR - shortLen) * cos, cy + (innerR - shortLen) * sin)
ctx.lineWidth = (isLong ? 3 : 1.5) * dpr ctx.lineWidth = 1.5 * dpr
ctx.lineCap = 'round' ctx.lineCap = 'round'
ctx.strokeStyle = isLong ? 'rgba(0,0,0,0.28)' : 'rgba(0,0,0,0.12)' ctx.strokeStyle = 'rgba(0,0,0,0.12)'
ctx.stroke()
}
// 四分位长刻度:精确落在 12/3/6/9 点(无论 ticks 是否整除 4,都对齐正中)
for (let k = 0; k < 4; k++) {
const ang = -Math.PI / 2 + k * (Math.PI / 2)
const cos = Math.cos(ang)
const sin = Math.sin(ang)
ctx.beginPath()
ctx.moveTo(cx + innerR * cos, cy + innerR * sin)
ctx.lineTo(cx + (innerR - longLen) * cos, cy + (innerR - longLen) * sin)
ctx.lineWidth = 3 * dpr
ctx.lineCap = 'round'
ctx.strokeStyle = 'rgba(0,0,0,0.28)'
ctx.stroke() ctx.stroke()
} }
} }
@@ -167,6 +206,168 @@ Component({
ctx.fill() ctx.fill()
ctx.restore() ctx.restore()
} }
// set-progress band (circuit mode only; no-op when sets <= 0)
this._drawSetArcs(this._setPulsePhase || 0.6)
},
/**
* Map currentSet/resting onto the three segment states, mirroring the old
* dots panel's contract (see the property doc for the semantics).
*/
_setArcState() {
const { sets, currentSet, resting } = this.data
let done = 0
let active = 0
let next = 0
if (sets > 0 && currentSet > 0) {
if (resting) {
done = Math.min(currentSet, sets)
if (currentSet < sets) next = currentSet + 1
} else {
done = Math.max(0, currentSet - 1)
active = currentSet
}
}
return { done, active, next }
},
/**
* Draw (or re-draw) the outer set-progress band. `pulse` is the breathing
* phase 0..1 used for the active / next segment; pass a fixed value for a
* static render (idle / paused).
*
* Only the band itself is cleared (clipped to a ring) so the inner
* countdown arc is never touched — the rAF loop below re-renders this
* band every frame without interfering with the per-second _draw().
*/
_drawSetArcs(pulse) {
const ctx = this._ctx
if (!ctx) return
const { size, ringWidth, sets, primaryColor, trackColor, successColor, status } = this.data
if (!sets || sets <= 0) return
const dpr = this._dpr
const cx = (size / 2) * dpr
const cy = (size / 2) * dpr
// Same radius math as _draw(): the countdown arc sits at `radius`; the
// set band wraps just outside it so the two read as concentric layers.
const radius = ((size - ringWidth) / 2) * dpr - 6 * dpr
const arcR = radius + (ringWidth / 2) * dpr + 3 * dpr
const arcW = 8 * dpr
const band = arcW / 2 + 3 * dpr
const { done, active, next } = this._setArcState()
// Clear only the outer band (ring clip -> inner arc untouched).
ctx.save()
ctx.beginPath()
ctx.arc(cx, cy, arcR + band, 0, Math.PI * 2)
ctx.arc(cx, cy, arcR - band, 0, Math.PI * 2, true)
ctx.clip()
ctx.clearRect(cx - arcR - band, cy - arcR - band, (arcR + band) * 2, (arcR + band) * 2)
ctx.restore()
const p = pulse == null ? 0.6 : Math.max(0, Math.min(1, pulse))
const paused = status === 'paused'
const segAngle = (Math.PI * 2) / sets
const gap = (2.5 * Math.PI) / 180
const startAngle = -Math.PI / 2
for (let i = 1; i <= sets; i++) {
const a0 = startAngle + (i - 1) * segAngle + gap / 2
const a1 = startAngle + i * segAngle - gap / 2
let color = trackColor
if (i <= done || i === active) color = primaryColor
else if (i === next) color = successColor
const emphasized = i === active || i === next
const alpha = emphasized ? (paused ? 0.7 : 0.45 + 0.55 * p) : 1
ctx.globalAlpha = alpha
ctx.beginPath()
ctx.arc(cx, cy, arcR, a0, a1)
ctx.strokeStyle = color
ctx.lineWidth = arcW
ctx.lineCap = 'round'
ctx.stroke()
ctx.globalAlpha = 1
// Glowing endpoint dot on the emphasized segment.
if (emphasized) {
const dx = cx + arcR * Math.cos(a1)
const dy = cy + arcR * Math.sin(a1)
const dotR = (arcW / 2) * (paused ? 1 : 0.85 + 0.35 * p)
const glowColor = i === active ? primaryColor : successColor
ctx.save()
ctx.shadowColor = glowColor
ctx.shadowBlur = 8 * dpr
ctx.globalAlpha = paused ? 0.9 : 0.55 + 0.45 * p
ctx.beginPath()
ctx.arc(dx, dy, dotR, 0, Math.PI * 2)
ctx.fillStyle = glowColor
ctx.fill()
ctx.restore()
// white core for a "light bulb" feel
ctx.beginPath()
ctx.arc(dx, dy, dotR * 0.45, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255,255,255,0.85)'
ctx.fill()
}
}
},
_startSetPulse() {
if (this._setPulseRAF != null) return
const canvas = this._canvas
if (!canvas || typeof canvas.requestAnimationFrame !== 'function') {
// Older canvas implementations without rAF: render one static frame.
this._drawSetArcs(0.6)
return
}
const loop = () => {
if (!this._ctx) return
const t = Date.now() / 1000
this._setPulsePhase = 0.5 + 0.5 * Math.sin((t * Math.PI * 2) / 1.6)
this._drawSetArcs(this._setPulsePhase)
this._setPulseRAF = canvas.requestAnimationFrame(loop)
}
this._setPulseRAF = canvas.requestAnimationFrame(loop)
},
_stopSetPulse() {
if (this._setPulseRAF == null) return
const canvas = this._canvas
if (canvas && typeof canvas.cancelAnimationFrame === 'function') {
canvas.cancelAnimationFrame(this._setPulseRAF)
}
this._setPulseRAF = null
},
/**
* (Re)arm or stop the breathing loop based on the current state.
* - sets <= 0 -> nothing to draw (single/free mode)
* - idle / completed -> static render (no active/next segment)
* - paused -> static render (pausing freezes all motion)
* - working / resting -> breathe the active / next segment
*/
_syncSetPulse() {
if (!this._ctx) return
const { sets, status } = this.data
if (sets <= 0) {
this._stopSetPulse()
return
}
const state = this._setArcState()
const wantsPulse = (state.active > 0 || state.next > 0) && status !== 'paused'
if (wantsPulse) {
this._startSetPulse()
} else {
this._stopSetPulse()
this._setPulsePhase = 0.6
this._drawSetArcs(0.6)
}
} }
} }
}) })
+1 -1
View File
@@ -7,7 +7,7 @@ module.exports = {
version: 'v3.2', version: 'v3.2',
/** 最后更新日期,外显在设置页「关于」 */ /** 最后更新日期,外显在设置页「关于」 */
updatedAt: '2026-08-11', updatedAt: '2026-08-12',
/** 开发者名称 / 微信号,设置页点击可复制 */ /** 开发者名称 / 微信号,设置页点击可复制 */
developer: '三口一瓶', developer: '三口一瓶',
+1 -49
View File
@@ -19,18 +19,11 @@ Page({
// 空则回退占位(人形图标 + "运动达人"),settings 改完回首页即刷新。 // 空则回退占位(人形图标 + "运动达人"),settings 改完回首页即刷新。
nickName: '', nickName: '',
avatarUrl: '', avatarUrl: '',
// Ring number font size in rpx. WeChat auto-scales text by the user's
// font-size setting (fontSizeScaleFactor) but does NOT scale rpx box
// dimensions — so on a real device with enlarged system font the 84rpx
// number grows past the fixed-size circular ring and gets clipped. We
// cancel that scale here so the number renders at a constant visual size
// (matching the devtools/simulator) on every device. See _readFontScale().
targetTimeFontSize: 96,
streakCount: 0, streakCount: 0,
planName: '', planName: '',
planDay: 1, planDay: 1,
planTotal: 7, planTotal: 7,
// hero / 进度地图 / dial 相关展示字段(由 refresh 生成) // hero / 进度地图 / 时长展示相关字段(由 refresh 生成)
greetText: '你好', greetText: '你好',
streakText: '今天开启坚持之旅', streakText: '今天开启坚持之旅',
questNodes: [], questNodes: [],
@@ -52,50 +45,9 @@ Page({
onLoad() { onLoad() {
themeMod.applyThemeToPage(this) themeMod.applyThemeToPage(this)
this._readFontScale()
this._firstShow = true this._firstShow = true
}, },
/**
* Read the device's WeChat font-size scale and compensate the ring number
* so it never overflows the fixed circular clip on real devices.
*
* WeChat auto-applies `fontSizeScaleFactor` to ALL text (including rpx font
* sizes) but leaves rpx box dimensions untouched. A user who bumps up the
* system font therefore sees enlarged text inside a non-enlarged ring.
*
* CRITICAL asymmetry: the DevTools simulator reports the SAME
* `fontSizeScaleFactor` as a real device (the user's actual WeChat setting)
* but does NOT actually scale rendered text. So if we compensate there, the
* number ends up too small in the simulator. We therefore SKIP the
* compensation under DevTools (host.env === 'devtools') and keep the design
* size — matching what the simulator renders literally.
*
* On real devices, dividing our design size by the factor cancels the
* auto-scale, keeping the number a constant 96rpx-equivalent everywhere.
* Bounded so a bad reading can only ever make the number smaller (safe),
* never larger (overflow).
*/
_readFontScale() {
let factor = 1
try {
const info = wx.getAppBaseInfo ? wx.getAppBaseInfo() : wx.getSystemInfoSync()
const isDevtools = info.host && info.host.env === 'devtools'
if (!isDevtools) {
if (typeof info.fontSizeScaleFactor === 'number' && info.fontSizeScaleFactor > 0) {
factor = info.fontSizeScaleFactor
} else if (typeof info.fontSizeSetting === 'number' && info.fontSizeSetting > 0) {
// fontSizeScaleFactor == currentFontSize / standardFontSize(17px)
factor = info.fontSizeSetting / 17
}
}
} catch (e) {}
const DESIGN = 96
let size = Math.round(DESIGN / factor)
size = Math.max(48, Math.min(96, size))
this.setData({ targetTimeFontSize: size })
},
onShow() { onShow() {
themeMod.applyThemeToPage(this) themeMod.applyThemeToPage(this)
try { try {
+4 -6
View File
@@ -1,4 +1,4 @@
<view class="container page-enter" style="{{themeStyle}}"> <view class="container page-enter index-page" style="{{themeStyle}}">
<!-- 全宽渐变头图 hero --> <!-- 全宽渐变头图 hero -->
<view class="hero"> <view class="hero">
@@ -37,12 +37,10 @@
<text class="quest-tip">{{questTip}}</text> <text class="quest-tip">{{questTip}}</text>
</ui-card> </ui-card>
<!-- 计时环 dial --> <!-- 今日目标时长(无圆环, 大字流光时间) -->
<view class="timer-wrap"> <view class="timer-wrap">
<view class="dial"> <text class="time-display">{{todayTargetText}}</text>
<text class="dial-time" style="font-size: {{targetTimeFontSize}}rpx;">{{todayTargetText}}</text> <text class="time-label">今日目标时长</text>
<text class="dial-label">今日目标时长</text>
</view>
<view class="benefit">⚡ 预计消耗 <text class="benefit-hl">{{estKcal}}</text> 千卡</view> <view class="benefit">⚡ 预计消耗 <text class="benefit-hl">{{estKcal}}</text> 千卡</view>
</view> </view>
+34 -43
View File
@@ -3,6 +3,14 @@
底部留大内边距给下方 quest-card 负 margin 上浮叠住。文字统一白色,图标用 底部留大内边距给下方 quest-card 负 margin 上浮叠住。文字统一白色,图标用
*White 变体(若有)。hero 用 opacity-only 动画,避免 transform 把 .container *White 变体(若有)。hero 用 opacity-only 动画,避免 transform 把 .container
变成 containing block 而破坏弹窗定位(同 app.wxss pageIn 思路)。 */ 变成 containing block 而破坏弹窗定位(同 app.wxss pageIn 思路)。 */
/* index-page:仅本页面把 .container 升级为 flex 列,
时钟区 flex:1 居中,操作按钮 margin-top:auto 贴底(不动全局 container)。 */
.index-page {
display: flex;
flex-direction: column;
}
.hero { .hero {
margin: -24rpx -24rpx 0; margin: -24rpx -24rpx 0;
padding: 24rpx 24rpx 72rpx; padding: 24rpx 24rpx 72rpx;
@@ -199,57 +207,41 @@
100% { box-shadow: 0 0 0 4rpx rgba(var(--primary-rgb), 0); } 100% { box-shadow: 0 0 0 4rpx rgba(var(--primary-rgb), 0); }
} }
/* ===== 计时环 dial ===== */ /* ===== 今日目标时长(无圆环, 大字流光) =====
在 index-page 这条 flex 列里 .timer-wrap 充当"中间时钟区":flex:1 占满
hero+quest 与底部按钮之间的剩余高度,内部再垂直居中把"00:30 / 今日目标
时长 / 预计消耗 千卡"这组文字真正落到屏幕中部(原来只是默认排在卡片下方) */
.timer-wrap { .timer-wrap {
text-align: center; flex: 1;
margin: 32rpx 0 8rpx;
}
.dial {
width: 320rpx;
height: 320rpx;
border-radius: 50%;
margin: 0 auto;
position: relative;
background: radial-gradient(circle at 35% 30%, #ffffff, #f4f6f2 70%);
box-shadow: inset 0 4rpx 14rpx rgba(var(--primary-rgb), 0.12), 0 10rpx 24rpx rgba(var(--primary-rgb), 0.12);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
animation: ringBreathe 3s ease-in-out infinite;
}
/* 虚线刻度环 */
.dial::before {
content: '';
position: absolute;
inset: 14rpx;
border-radius: 50%;
border: 2rpx dashed rgba(var(--primary-rgb), 0.28);
}
.dial-time {
font-size: 84rpx;
font-weight: 800;
color: var(--primary);
font-variant-numeric: tabular-nums;
letter-spacing: -1rpx;
line-height: 1;
max-width: 280rpx;
text-align: center; text-align: center;
white-space: nowrap; min-height: 0;
overflow: hidden;
} }
.dial-label { .time-display {
font-size: 24rpx; display: block;
font-family: 'Saira Condensed', 'Arial Narrow', sans-serif;
font-size: 200rpx;
font-weight: 900;
line-height: 1.1;
letter-spacing: 6rpx;
font-variant-numeric: tabular-nums;
color: var(--primary);
text-shadow: 0 8rpx 6rpx rgba(var(--primary-rgb), 0.35);
}
.time-label {
display: block;
font-size: 26rpx;
color: var(--text-secondary); color: var(--text-secondary);
margin-top: 8rpx; margin-top: 8rpx;
} }
.benefit { .benefit {
margin-top: 20rpx; margin-top: 18rpx;
font-size: 28rpx; font-size: 28rpx;
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -260,17 +252,16 @@
font-size: 30rpx; font-size: 30rpx;
} }
@keyframes ringBreathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
/* ===== action buttons ===== */ /* ===== action buttons ===== */
/* 在 index-page flex 列里,3 个按钮(rechallenge + free + circuit)靠
margin-top:auto 推到列底、紧贴底部 toolbar 上方,与上方居中的时钟
区之间自然留白;gap 保持按钮自身间距不变 */
.action-buttons { .action-buttons {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24rpx; gap: 24rpx;
margin: 8rpx 0 8rpx; margin: 8rpx 0 8rpx;
margin-top: auto;
} }
/* 自由训练 / 循环训练 并排一行 */ /* 自由训练 / 循环训练 并排一行 */
+13 -27
View File
@@ -37,13 +37,8 @@ Page({
workTotal: 0, // effective held seconds (excludes rest) workTotal: 0, // effective held seconds (excludes rest)
isResting: false, isResting: false,
phaseTip: '', // 阶段切换提示文本(如"最后一组!"/"休息 15 秒") phaseTip: '', // 阶段切换提示文本(如"最后一组!"/"休息 15 秒")
// 顶部循环进度面板:组进度胶囊点阵 // 组进度改由 progress-ring 外圈分段弧呈现,仅需传 sets/currentSet/resting,
circuitDots: [], // [1..sets],仅用于 wx:for 渲染,组数过多时不渲染 // 页面不再维护点阵渲染数据(circuitDots/dotsDone 等已随旧面板一并删除)。
circuitShowDots: true, // 组数 > DOTS_MAX 时退化为线性进度条
circuitTotalWork: 0, // sets × holdPerSet,面板里作为"目标秒数"展示
dotsDone: 0, // 已完成组数
dotsActive: 0, // 正在进行的组序号(休息时为 0)
dotsNext: 0, // 休息时即将开始的组序号(工作时为 0)
celebrationLevel: 'normal', // normal / first / milestone / record celebrationLevel: 'normal', // normal / first / milestone / record
celebrationMessage: '', celebrationMessage: '',
confettiPieces: [], confettiPieces: [],
@@ -222,8 +217,8 @@ Page({
sets, sets,
restPerSet: rest, restPerSet: rest,
onTick: (t) => { onTick: (t) => {
// 组进度点阵三态。休息阶段 CircuitTimer 的 setIndex 仍指向"刚做完 // 组进度语义与旧点阵一致:休息时 setIndex 仍指向"刚做完的那一组",
// 的那一组",所以 resting 时 setIndex 组算已完成、下一组标为 next // 组件按 resting 自行推导已完成/进行中/下一组三态(外圈分段弧)
const resting = t.phase === 'resting' const resting = t.phase === 'resting'
this.setData({ this.setData({
currentSet: t.setIndex, currentSet: t.setIndex,
@@ -233,9 +228,6 @@ Page({
phaseElapsed: t.phaseElapsed, phaseElapsed: t.phaseElapsed,
workTotal: t.workTotal, workTotal: t.workTotal,
isResting: resting, isResting: resting,
dotsDone: resting ? t.setIndex : Math.max(0, t.setIndex - 1),
dotsActive: resting ? 0 : t.setIndex,
dotsNext: resting ? t.setIndex + 1 : 0,
// Reuse the single-hold display fields so progress-ring / completion // Reuse the single-hold display fields so progress-ring / completion
// number keep working: the ring shows the CURRENT set's countdown. // number keep working: the ring shows the CURRENT set's countdown.
remaining: t.phaseRemaining, remaining: t.phaseRemaining,
@@ -244,7 +236,9 @@ Page({
statusText: resting ? '休息中' : '撑住', statusText: resting ? '休息中' : '撑住',
runningTip: resting ? '' : this.data.phaseTip, runningTip: resting ? '' : this.data.phaseTip,
tipType: 'phase', tipType: 'phase',
tipIcon: this.data.icons.peopleFill tipIcon: this.data.icons.peopleFill,
// 环内副标题:宏观进度(第几组)交给外圈分段弧,这里只报当前组位次。
ringSubText: `${t.setIndex}/${t.sets}`
}) })
this._circuitCue(t) this._circuitCue(t)
}, },
@@ -252,13 +246,9 @@ Page({
onComplete: () => this._onCircuitComplete() onComplete: () => this._onCircuitComplete()
}) })
// 点阵超过 DOTS_MAX 个就挤成头发丝了,退化成一条线性进度条。 // 组数上限 50(见 _int 的 clamp),环形外圈 360° 空间充裕,即使 50 段也
const DOTS_MAX = 15 // 不至于挤成线(旧点阵的 DOTS_MAX=15 是受横向卡片宽度限制,环形无此问题),
const dots = [] // 因此分段弧不做数量降级,页面也无须维护点阵渲染数据。
if (sets <= DOTS_MAX) {
for (let i = 1; i <= sets; i++) dots.push(i)
}
const durText = this._durationText(hold) const durText = this._durationText(hold)
this.setData({ this.setData({
isCircuitMode: true, isCircuitMode: true,
@@ -268,12 +258,6 @@ Page({
circuitHold: hold, circuitHold: hold,
circuitRest: rest, circuitRest: rest,
circuitSessions: this._circuitCfg.sessionsPerWeek, circuitSessions: this._circuitCfg.sessionsPerWeek,
circuitDots: dots,
circuitShowDots: sets <= DOTS_MAX,
circuitTotalWork: sets * hold,
dotsDone: 0,
dotsActive: 0,
dotsNext: 0,
currentSet: 0, currentSet: 0,
totalSets: sets, totalSets: sets,
phase: 'idle', phase: 'idle',
@@ -294,7 +278,9 @@ Page({
showCompletion: false, showCompletion: false,
goalLine: `每组 ${durText} · 共 ${sets}`, goalLine: `每组 ${durText} · 共 ${sets}`,
ringTicks: Math.min(hold, 60), ringTicks: Math.min(hold, 60),
ringSubText: `每组 ${durText}`, // idle 时环内副标题承载配置摘要(组数 + 休息时长),运行时由 onTick
// 换成"第 X/Y 组"。休息时长为 0 时不展示"休息"段。
ringSubText: `${sets}${rest > 0 ? ' · 休息 ' + rest + ' 秒' : ''}`,
statusText: '准备开始', statusText: '准备开始',
runningTip: '', runningTip: '',
tipType: 'posture', tipType: 'posture',
+26 -50
View File
@@ -15,44 +15,9 @@
<text class="achievement-text">{{celebrationMessage}}</text> <text class="achievement-text">{{celebrationMessage}}</text>
</view> </view>
<!-- 循环训练进度面板:组点阵 + 第 X/Y 组 + 阶段徽章 + 累计秒数。 <!-- 呼吸引导环 + 进度条。循环训练的组进度不再占一块独立面板,
层次分工:这里管「第几组」(宏观),中间的进度环管「本组还剩几秒」(微观)。 --> 而是由 progress-ring 在圆环外圈画一圈分段弧(见组件的 sets/currentSet/resting 属性),
<view 保证单组/自由/循环三模式共用同一套页面骨架,切换零跳动。 -->
class="circuit-bar {{isResting ? 'is-rest' : ''}} {{status === 'paused' ? 'is-paused' : ''}}"
wx:if="{{isCircuitMode && status !== 'completed'}}"
>
<!-- 组进度点阵:已完成 / 进行中 / 下一组 三态 -->
<view class="circuit-dots" wx:if="{{circuitShowDots}}">
<view
wx:for="{{circuitDots}}"
wx:key="*this"
class="circuit-dot {{item <= dotsDone ? 'is-done' : ''}} {{item === dotsActive ? 'is-active' : ''}} {{item === dotsNext ? 'is-next' : ''}}"
></view>
</view>
<!-- 组数过多(>15)时退化为线性进度条,避免点阵挤成头发丝 -->
<view class="circuit-track" wx:else>
<view class="circuit-track-fill" style="width: {{dotsDone * 100 / totalSets}}%"></view>
</view>
<view class="circuit-meta">
<view class="circuit-count">
<text class="circuit-count-num">{{status === 'idle' ? totalSets : currentSet}}</text>
<text class="circuit-count-total" wx:if="{{status !== 'idle'}}">/{{totalSets}}</text>
<text class="circuit-count-unit">组</text>
</view>
<view class="circuit-phase circuit-phase--{{status === 'paused' ? 'pause' : (status === 'idle' ? 'idle' : (isResting ? 'rest' : 'work'))}}">
<view class="circuit-phase-dot"></view>
<text class="circuit-phase-text">{{status === 'paused' ? '已暂停' : (status === 'idle' ? '准备开始' : (isResting ? '休息中' : '撑住'))}}</text>
</view>
</view>
<text class="circuit-sub">
<block wx:if="{{status === 'idle'}}">每组 {{circuitHold}} 秒{{circuitRest > 0 ? ' · 休息 ' + circuitRest + ' 秒' : ''}}</block>
<block wx:else>已撑 {{workTotal}} 秒 · 目标 {{circuitTotalWork}} 秒</block>
</text>
</view>
<!-- 呼吸引导环 + 进度条 -->
<view class="ring-wrapper"> <view class="ring-wrapper">
<view class="breathe-ring breathe-ring-1 {{status === 'running' ? 'fast' : ''}}" <view class="breathe-ring breathe-ring-1 {{status === 'running' ? 'fast' : ''}}"
wx:if="{{status !== 'completed'}}"></view> wx:if="{{status !== 'completed'}}"></view>
@@ -60,13 +25,16 @@
wx:if="{{status !== 'completed'}}"></view> wx:if="{{status !== 'completed'}}"></view>
<view class="breathe-ring breathe-ring-3 {{status === 'running' ? 'fast' : ''}}" <view class="breathe-ring breathe-ring-3 {{status === 'running' ? 'fast' : ''}}"
wx:if="{{status !== 'completed'}}"></view> wx:if="{{status !== 'completed'}}"></view>
<!-- 呼吸光环:进度环外侧一圈可见的描边辉光,随呼吸收放(idle 慢/running 快) -->
<view class="breath-aura {{status === 'running' ? 'fast' : ''}}"
wx:if="{{status !== 'completed'}}"></view>
<progress-ring <progress-ring
wx:if="{{status !== 'completed'}}" wx:if="{{status !== 'completed'}}"
duration="{{duration}}" duration="{{duration}}"
remaining="{{isCompleted ? 0 : remaining}}" remaining="{{isCompleted ? 0 : remaining}}"
elapsed="{{elapsed}}" elapsed="{{elapsed}}"
size="{{360}}" size="{{420}}"
ringWidth="{{22}}" ringWidth="{{22}}"
status="{{status}}" status="{{status}}"
primaryColor="{{theme.primary}}" primaryColor="{{theme.primary}}"
@@ -74,6 +42,9 @@
trackColor="{{isCircuitMode ? '#EEEEEE' : (elapsed >= duration ? '#00B578' : '#EEEEEE')}}" trackColor="{{isCircuitMode ? '#EEEEEE' : (elapsed >= duration ? '#00B578' : '#EEEEEE')}}"
ticks="{{ringTicks}}" ticks="{{ringTicks}}"
subText="{{ringSubText}}" subText="{{ringSubText}}"
sets="{{totalSets}}"
currentSet="{{currentSet}}"
resting="{{isResting}}"
></progress-ring> ></progress-ring>
<!-- 完成庆祝粒子 --> <!-- 完成庆祝粒子 -->
@@ -87,18 +58,23 @@
</view> </view>
</view> </view>
<!-- 状态文案(环下方) + 目标信息卡 + 运行提示 --> <!-- 信息气泡:状态 + 目标摘要 + 运行提示 三行合一(三模式公用)。
<view class="timer-status status--{{status}}" wx:if="{{status !== 'completed'}}">{{statusText}}</view> 环下方原 statusText(撑住/休息中/已暂停)并入气泡顶部,圆环下只留一张卡。 -->
<view class="info-bubble" wx:if="{{status !== 'completed'}}">
<view class="goal-card" wx:if="{{status !== 'completed'}}"> <view class="info-line info-line--status info-line--{{status}} {{isResting ? 'info-line--rest' : ''}}">
<image class="goal-icon" src="{{icons.targetFill}}" mode="aspectFit"></image> <view class="info-status-dot"></view>
<text class="goal-text">{{goalLine}}</text> <text class="info-status-text">{{statusText}}</text>
<text class="goal-arrow"></text> </view>
<view class="info-divider"></view>
<view class="info-line info-line--goal">
<image class="info-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
<text class="info-text">{{goalLine}}</text>
</view>
<view class="info-divider" wx:if="{{runningTip}}"></view>
<view class="info-line info-line--tip info-line--{{tipType}}" wx:if="{{runningTip}}">
<image class="info-icon" src="{{tipIcon}}" mode="aspectFit"></image>
<text class="info-text">{{runningTip}}</text>
</view> </view>
<view class="timer-tip timer-tip--{{tipType}}" wx:if="{{runningTip}}">
<image class="timer-tip-icon" src="{{tipIcon}}" mode="aspectFit"></image>
<text class="timer-tip-text">{{runningTip}}</text>
</view> </view>
<!-- 控制按钮 --> <!-- 控制按钮 -->
+118 -309
View File
@@ -9,227 +9,14 @@
box-sizing: border-box; box-sizing: border-box;
} }
/* ---- 循环训练进度面板 ----
注意:整块不设固定 height,靠 padding 撑开。真机大字体档下文字会被微信
放大,固定高度 + overflow 会把数字裁掉(项目里踩过这个坑)。 */
.circuit-bar {
/* 抬一层:呼吸光晕(.breathe-ring, z-index:0)在 DOM 里排在本面板之后,
不抬层会把橙色晕染盖到卡片上。 */
position: relative;
z-index: 1;
width: 620rpx;
max-width: 86%;
box-sizing: border-box;
padding: 26rpx 32rpx 22rpx;
margin-bottom: 12rpx;
background: var(--card-bg);
border: 1rpx solid rgba(var(--primary-rgb), 0.14);
border-radius: 28rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.05);
transition: border-color 0.35s ease, box-shadow 0.35s ease;
animation: circuitBarIn 0.45s cubic-bezier(0.34, 1.4, 0.64, 1) both;
}
/* 休息态整块降温:边框转成 success 绿,和"撑住"的主色态形成对比 */
.circuit-bar.is-rest {
border-color: rgba(var(--success-rgb), 0.32);
box-shadow: 0 6rpx 20rpx rgba(var(--success-rgb), 0.10);
}
@keyframes circuitBarIn {
0% { opacity: 0; transform: translateY(-16rpx); }
100% { opacity: 1; transform: translateY(0); }
}
/* --- 组进度点阵 ---
整行铺满面板内宽,每段按组数严格等分(分段进度条式)。 */
.circuit-dots {
display: flex;
align-items: center;
width: 100%;
/* 锁住行高:当前组比其它段高 6rpx,不锁行高会在 idle→running 时整块跳一下 */
min-height: 18rpx;
gap: 8rpx;
margin-bottom: 22rpx;
}
/* flex:1 1 0 + min-width:0 → 每段等分剩余宽度,与内容无关。
不设 max-width:一旦封顶,组数少时点阵就只占左边一截(此前的问题)。
当前组不再靠"加宽"强调(那会破坏等分),改为增高+发光,宽度恒等分。 */
.circuit-dot {
flex: 1 1 0;
min-width: 0;
height: 12rpx;
border-radius: 6rpx;
background: var(--bg-soft);
transition: height 0.3s ease, background 0.3s ease, box-shadow 0.3s ease;
}
.circuit-dot.is-done {
background: var(--primary);
opacity: 0.85;
}
/* 当前组:增高 + 渐变 + 发光呼吸,一眼锁定进度位置(宽度仍等分) */
.circuit-dot.is-active {
height: 18rpx;
border-radius: 9rpx;
opacity: 1;
background: linear-gradient(90deg, var(--primary), var(--primary-light));
box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.55);
animation: circuitDotPulse 1.5s ease-in-out infinite;
}
/* 休息时高亮"下一组",给用户一个即将开始的预告焦点 */
.circuit-dot.is-next {
height: 18rpx;
border-radius: 9rpx;
background: rgba(var(--success-rgb), 0.45);
animation: circuitDotPulse 1.8s ease-in-out infinite;
}
@keyframes circuitDotPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
/* 暂停时冻结所有呼吸动画,静止本身就是"已暂停"的信号 */
.circuit-bar.is-paused .circuit-dot {
animation: none;
}
/* --- 组数过多时的线性降级条 --- */
.circuit-track {
height: 12rpx;
border-radius: 6rpx;
background: var(--bg-soft);
overflow: hidden;
margin-bottom: 22rpx;
}
.circuit-track-fill {
height: 100%;
border-radius: 6rpx;
background: linear-gradient(90deg, var(--primary), var(--primary-light));
transition: width 0.4s ease;
}
/* --- 主信息行:大数字 + 阶段徽章 --- */
.circuit-meta {
display: flex;
align-items: center;
justify-content: space-between;
}
.circuit-count {
display: flex;
align-items: baseline;
}
.circuit-count-num {
font-size: 56rpx;
font-weight: 800;
color: var(--text);
line-height: 1;
font-variant-numeric: tabular-nums;
letter-spacing: -1rpx;
}
.circuit-count-total {
font-size: 32rpx;
font-weight: 600;
color: var(--text-secondary);
line-height: 1;
margin-left: 2rpx;
}
.circuit-count-unit {
font-size: 26rpx;
color: var(--text-secondary);
margin-left: 8rpx;
}
/* --- 阶段徽章 --- */
.circuit-phase {
display: flex;
align-items: center;
gap: 10rpx;
padding: 12rpx 24rpx;
border-radius: 30rpx;
transition: background 0.3s ease;
}
/* 状态指示灯用 CSS 画而非 icon:项目里的 SVG 图标颜色烤死在 data URI 里,
CSS 改不动;这里需要跟随状态换色,纯 CSS 圆点是唯一干净的做法。 */
.circuit-phase-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
flex-shrink: 0;
}
.circuit-phase-text {
font-size: 26rpx;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
/* 工作态:主色渐变实心 + 白灯白字 */
.circuit-phase--work {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
box-shadow: 0 4rpx 14rpx rgba(var(--primary-rgb), 0.32);
}
.circuit-phase--work .circuit-phase-dot {
background: #FFFFFF;
animation: phaseDotBlink 1.2s ease-in-out infinite;
}
.circuit-phase--work .circuit-phase-text { color: #FFFFFF; }
/* 休息态:绿色淡底,视觉降温 */
.circuit-phase--rest {
background: rgba(var(--success-rgb), 0.14);
}
.circuit-phase--rest .circuit-phase-dot {
background: var(--success);
animation: phaseDotBlink 1.8s ease-in-out infinite;
}
.circuit-phase--rest .circuit-phase-text { color: var(--success); }
/* 待机态 / 暂停态:中性灰,不抢视觉 */
.circuit-phase--idle,
.circuit-phase--pause {
background: var(--bg-soft);
}
.circuit-phase--idle .circuit-phase-dot,
.circuit-phase--pause .circuit-phase-dot {
background: var(--text-secondary);
}
.circuit-phase--idle .circuit-phase-text,
.circuit-phase--pause .circuit-phase-text {
color: var(--text-secondary);
}
@keyframes phaseDotBlink {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.8); }
}
/* --- 副信息行 --- */
.circuit-sub {
display: block;
margin-top: 14rpx;
font-size: 24rpx;
color: var(--text-secondary);
line-height: 1.3;
}
/* ---- ring wrapper ---- */ /* ---- ring wrapper ---- */
.ring-wrapper { .ring-wrapper {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
/* 圆环与下方(状态词/目标卡/提示)整组拉开间距,按钮靠 margin-top:auto 贴底不受影响 */
margin-bottom: 44rpx;
} }
/* breathing guide rings — soft radial glows instead of hard outline circles, /* breathing guide rings — soft radial glows instead of hard outline circles,
@@ -237,21 +24,53 @@
.breathe-ring { .breathe-ring {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle, rgba(var(--primary-rgb), 0.10) 0%, rgba(var(--primary-rgb), 0) 65%); background: radial-gradient(circle, rgba(var(--primary-rgb), 0.13) 0%, rgba(var(--primary-rgb), 0) 68%);
animation: breathePulse 3.5s ease-in-out infinite; animation: breathePulse 3.5s ease-in-out infinite;
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
.breathe-ring-1 { width: 520rpx; height: 520rpx; animation-delay: 0s; } .breathe-ring-1 { width: 620rpx; height: 620rpx; animation-delay: 0s; }
.breathe-ring-2 { width: 440rpx; height: 440rpx; animation-delay: 0.5s; background: radial-gradient(circle, rgba(var(--primary-rgb), 0.14) 0%, rgba(var(--primary-rgb), 0) 65%); } .breathe-ring-2 { width: 540rpx; height: 540rpx; animation-delay: 0.5s; background: radial-gradient(circle, rgba(var(--primary-rgb), 0.17) 0%, rgba(var(--primary-rgb), 0) 66%); }
.breathe-ring-3 { width: 380rpx; height: 380rpx; animation-delay: 1s; background: radial-gradient(circle, rgba(var(--primary-rgb), 0.18) 0%, rgba(var(--primary-rgb), 0) 65%); } .breathe-ring-3 { width: 460rpx; height: 460rpx; animation-delay: 1s; background: radial-gradient(circle, rgba(var(--primary-rgb), 0.22) 0%, rgba(var(--primary-rgb), 0) 63%); }
.breathe-ring.fast { animation: breathePulseFast 1.4s ease-in-out infinite; } .breathe-ring.fast { animation: breathePulseFast 1.4s ease-in-out infinite; }
.breathe-ring.fast.breathe-ring-1 { animation-delay: 0s; } .breathe-ring.fast.breathe-ring-1 { animation-delay: 0s; }
.breathe-ring.fast.breathe-ring-2 { animation-delay: 0.25s; } .breathe-ring.fast.breathe-ring-2 { animation-delay: 0.25s; }
.breathe-ring.fast.breathe-ring-3 { animation-delay: 0.5s; } .breathe-ring.fast.breathe-ring-3 { animation-delay: 0.5s; }
/* ---- 呼吸光环:进度环外侧一圈淡化的描边辉光 ---- */
.breath-aura {
position: absolute;
/* 直径略大于进度环(420),落在刻度/进度弧外侧,不遮挡中间时钟 */
width: 488rpx;
height: 488rpx;
border-radius: 50%;
/* 细线 + 低透明度:只是隐约的呼吸边界,不跟倒计时环抢视觉 */
border: 4rpx solid rgba(var(--primary-rgb), 0.20);
box-shadow:
0 0 30rpx rgba(var(--primary-rgb), 0.22),
inset 0 0 20rpx rgba(var(--primary-rgb), 0.10);
animation: auraBreathe 4s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
.breath-aura.fast {
animation: auraBreatheFast 2.4s ease-in-out infinite;
}
/* 柔和呼吸:幅度小(scale 0.96↔1.04)、透明度压低(0.3↔0.5),留出呼吸感但始终很淡 */
@keyframes auraBreathe {
0%, 100% { transform: scale(0.96); opacity: 0.30; }
50% { transform: scale(1.04); opacity: 0.50; }
}
@keyframes auraBreatheFast {
0%, 100% { transform: scale(0.97); opacity: 0.34; }
50% { transform: scale(1.05); opacity: 0.55; }
}
/* celebration burst */ /* celebration burst */
.celebrate-burst { .celebrate-burst {
position: absolute; position: absolute;
@@ -281,115 +100,105 @@
100% { opacity: 0; transform: translate(0, -80rpx) scale(0.4); } 100% { opacity: 0; transform: translate(0, -80rpx) scale(0.4); }
} }
/* ---- 状态文案(环下方) ---- */ /* ---- 信息气泡·状态行(原环下方 statusText 并入气泡顶部) ----
.timer-status { 状态色:running=主色 / 休息中=绿 / idle、paused=中性灰。
margin-top: 40rpx; resting 时 status 字段仍是 running,额外用 .info-line--rest 覆盖为绿。
font-size: 36rpx; 小圆点用 currentColor 跟随状态色,纯 CSS 画(项目 SVG 图标色烤死改不动)。 */
font-weight: 700; .info-line--status {
color: var(--text); gap: 12rpx;
line-height: 1.2;
text-align: center;
} }
.timer-status.status--running { color: var(--primary); } .info-status-dot {
.timer-status.status--paused { color: var(--text-secondary); } width: 14rpx;
height: 14rpx;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
/* ---- 目标信息卡 ---- */ .info-status-text {
.goal-card { font-size: 32rpx;
font-weight: 700;
line-height: 1.3;
color: var(--text);
}
.info-line--status.status--running { color: var(--primary); }
.info-line--status.status--running .info-status-dot {
animation: statusDotBlink 1.6s ease-in-out infinite;
}
.info-line--status.info-line--rest { color: var(--success); }
.info-line--status.info-line--rest .info-status-dot {
animation: statusDotBlink 1.8s ease-in-out infinite;
}
.info-line--status.status--paused,
.info-line--status.status--idle { color: var(--text-secondary); }
/* 暂停时冻结呼吸,静止本身就是"已暂停"的信号 */
.info-line--status.status--paused .info-status-dot { animation: none; }
@keyframes statusDotBlink {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.75); }
}
/* ---- 信息气泡:状态 + 目标摘要 + 运行提示 合并为一张卡(三模式公用) ----
三行结构:上=状态(最醒目,状态色) / 中=目标常驻(小字次级) / 下=动态提示。
休息/暂停/待机时提示行为空,分隔线和提示行整体消失,气泡自动收缩。 */
.info-bubble {
display: flex; display: flex;
align-items: center; flex-direction: column;
box-sizing: border-box;
width: 620rpx; width: 620rpx;
max-width: 86%; max-width: 86%;
box-sizing: border-box; /* 承接原 timer-status 的 40rpx 间距,圆环与卡片保持呼吸感 */
margin-top: 28rpx; margin-top: 40rpx;
padding: 24rpx 28rpx; padding: 24rpx 28rpx;
background: var(--card-bg); background: rgba(var(--primary-rgb), 0.08);
border: 1rpx solid rgba(var(--primary-rgb), 0.12); border-radius: 26rpx;
border-radius: 24rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.05);
} }
.goal-icon { .info-line {
width: 40rpx; display: flex;
height: 40rpx; align-items: center;
}
.info-icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0; flex-shrink: 0;
margin-right: 16rpx; margin-right: 12rpx;
} }
.goal-text { .info-text {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font-size: 28rpx;
font-weight: 600;
color: var(--text);
line-height: 1.3;
}
.goal-arrow {
font-size: 40rpx;
color: var(--text-secondary);
line-height: 1;
margin-left: 8rpx;
}
/* ---- 运行提示(气泡卡片:姿态 / 阶段 / 目标达成) ---- */
.timer-tip {
position: relative;
display: inline-flex;
align-items: center;
gap: 14rpx;
margin-top: 18rpx;
max-width: 100%;
padding: 20rpx 32rpx;
background: #fff;
border-left: 8rpx solid var(--primary);
border-radius: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(var(--primary-rgb), 0.14);
animation: tipPulse 2.4s ease-in-out infinite;
}
/* 小三角指向圆环 */
.timer-tip::before {
content: '';
position: absolute;
top: -16rpx;
left: 50%;
transform: translateX(-50%);
border-left: 16rpx solid transparent;
border-right: 16rpx solid transparent;
border-bottom: 16rpx solid #fff;
}
.timer-tip--done {
border-left-color: var(--success);
}
.timer-tip-icon {
width: 40rpx;
height: 40rpx;
flex-shrink: 0;
}
.timer-tip-text {
font-size: 30rpx;
font-weight: 600;
color: var(--text);
line-height: 1.4; line-height: 1.4;
} }
.timer-tip--done .timer-tip-text { /* 目标行:静态摘要,主色小字 */
color: var(--success); .info-line--goal .info-text {
font-size: 26rpx;
font-weight: 500;
color: var(--primary);
} }
@keyframes tipPulse { /* 分隔线:提示行出现时才渲染 */
0%, 100% { .info-divider {
transform: scale(1); height: 1rpx;
box-shadow: 0 8rpx 24rpx rgba(var(--primary-rgb), 0.14); background: rgba(var(--primary-rgb), 0.14);
} margin: 14rpx 0;
50% { }
transform: scale(1.03);
box-shadow: 0 12rpx 32rpx rgba(var(--primary-rgb), 0.22); /* 提示行:动态信息,比目标行大一号更突出 */
} .info-line--tip .info-text {
font-size: 30rpx;
font-weight: 600;
color: var(--text);
}
.info-line--done .info-text {
color: var(--success);
} }
/* ---- controls ---- */ /* ---- controls ---- */
+3 -2
View File
@@ -23,7 +23,7 @@
"minified": true, "minified": true,
"autoAudits": false, "autoAudits": false,
"newFeature": false, "newFeature": false,
"uglifyFileName": false, "uglifyFileName": true,
"uploadWithSourceMap": false, "uploadWithSourceMap": false,
"useIsolateContext": true, "useIsolateContext": true,
"nodeModules": false, "nodeModules": false,
@@ -49,7 +49,8 @@
"disableUseStrict": false, "disableUseStrict": false,
"useCompilerPlugins": false, "useCompilerPlugins": false,
"swc": false, "swc": false,
"disableSWC": true "disableSWC": true,
"ignoreUploadUnusedFiles": true
}, },
"compileType": "miniprogram", "compileType": "miniprogram",
"lazyCodeLoading": "requiredComponents", "lazyCodeLoading": "requiredComponents",
+1 -1
View File
@@ -3,7 +3,7 @@
"projectname": "wx_pbzc", "projectname": "wx_pbzc",
"condition": {}, "condition": {},
"setting": { "setting": {
"urlCheck": true, "urlCheck": false,
"coverView": true, "coverView": true,
"lazyloadPlaceholderEnable": false, "lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false, "skylineRenderEnable": false,