From 05c4763e498b2aaad1235f70d4810a3d529c4273 Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Wed, 12 Aug 2026 09:39:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(timer):=20=E5=BE=AA=E7=8E=AF=E8=AE=AD?= =?UTF-8?q?=E7=BB=83=E7=BB=84=E8=BF=9B=E5=BA=A6=E6=94=B9=E4=B8=BA=E5=9C=86?= =?UTF-8?q?=E7=8E=AF=E5=88=86=E6=AE=B5=E5=BC=A7=20+=20=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E6=B0=94=E6=B3=A1=E4=B8=89=E8=A1=8C=E5=90=88=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - progress-ring 新增 sets/currentSet/resting/successColor 属性, 圆环外圈绘制分段弧(已完成=主色/当前=呼吸光点/休息下一组=绿),单组模式零渲染 - timer 删除顶部 circuit-bar 进度面板,布局骨架三模式统一 - 呼吸环淡化(描边 0.55→0.20,柔光渐变强度减半) - 目标卡与提示气泡合并为 info-bubble,状态文案(撑住/休息中/已暂停)并入气泡顶部 --- .workbuddy/clean-console.js | 45 +++ .workbuddy/memory/2026-08-06.md | 20 + .workbuddy/memory/2026-08-07.md | 22 ++ .workbuddy/memory/2026-08-11.md | 102 ++++++ .workbuddy/memory/2026-08-12.md | 64 ++++ .workbuddy/memory/MEMORY.md | 1 + .workbuddy/style-dna-default.md | 45 +++ app.wxss | 10 + components/progress-ring/progress-ring.js | 217 ++++++++++- config.js | 2 +- pages/index/index.js | 50 +-- pages/index/index.wxml | 10 +- pages/index/index.wxss | 77 ++-- pages/timer/timer.js | 40 +- pages/timer/timer.wxml | 78 ++-- pages/timer/timer.wxss | 427 ++++++---------------- project.config.json | 5 +- project.private.config.json | 2 +- 18 files changed, 720 insertions(+), 497 deletions(-) create mode 100644 .workbuddy/clean-console.js create mode 100644 .workbuddy/memory/2026-08-06.md create mode 100644 .workbuddy/memory/2026-08-07.md create mode 100644 .workbuddy/memory/2026-08-11.md create mode 100644 .workbuddy/memory/2026-08-12.md create mode 100644 .workbuddy/style-dna-default.md diff --git a/.workbuddy/clean-console.js b/.workbuddy/clean-console.js new file mode 100644 index 0000000..f11df61 --- /dev/null +++ b/.workbuddy/clean-console.js @@ -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) diff --git a/.workbuddy/memory/2026-08-06.md b/.workbuddy/memory/2026-08-06.md new file mode 100644 index 0000000..9a844fd --- /dev/null +++ b/.workbuddy/memory/2026-08-06.md @@ -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/wxss(contentMode 增 'milestone');config 增 milestoneFirsts。全部从现有 training_records 推导,**零存储/云函数改动**。 +**图标:** 节点用 *White 变体(playWhite/boltWhite/formWhite/各 badgeWhite 等);timeWhite、repeatWhite 实现时各加一行或复用现有白图标。注意组件 style isolation 陷阱——图标色烤死在 SVG,必须用 *White。 +**状态:用户选择「再调整一下」,暂未批准写代码。** 下一步:与用户敲定文案/门槛/配色后实现。 diff --git a/.workbuddy/memory/2026-08-07.md b/.workbuddy/memory/2026-08-07.md new file mode 100644 index 0000000..252e095 --- /dev/null +++ b/.workbuddy/memory/2026-08-07.md @@ -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()`。用户尚未批准改代码。 diff --git a/.workbuddy/memory/2026-08-11.md b/.workbuddy/memory/2026-08-11.md new file mode 100644 index 0000000..4df42f6 --- /dev/null +++ b/.workbuddy/memory/2026-08-11.md @@ -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.wxml(greeting-row 结构)、index.wxss(横向 flex 布局 + 头像样式)、index.js(data 加 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` 的 bounds(hold 下限5 / rest 下限0)无需改,越界自动 clamp。 + +## 首页 index 整体改版(参考 plank-training.html,方案 B 橙色系) +- 结构重构(纯视觉/布局,不动训练/记录/设置逻辑):全宽橙渐变 `hero`(头像+昵称+按小时时间问候+「时长」敢不敢来挑战+🔥真实连胜)→ `quest-card` 进度点地图(白卡负 margin 上浮叠 hero,圆点+连线,done/now/final 三态,末节点🏆)→ `dial` 虚线刻度环+呼吸动画(目标时长+预计千卡)→ CTA「立即挑战+时长」带脉冲 → 次级入口自由/循环。 +- 取消每日语录模块(用户要求)。 +- 配色:方案 B,hero 渐变用 `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.wxml:progress-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/3:520/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` 圆环整块,改 `{{todayTargetText}}` + `今日目标时长`,保留「预计消耗 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` 整体包进新 ``,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 900(base64 内联) +- 用户认为 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 通过。未提交。 diff --git a/.workbuddy/memory/2026-08-12.md b/.workbuddy/memory/2026-08-12.md new file mode 100644 index 0000000..1a806c0 --- /dev/null +++ b/.workbuddy/memory/2026-08-12.md @@ -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 行);`` 新增 `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.20;box-shadow 外层 40rpx/0.45 → 30rpx/0.22、内层 28rpx/0.22 → 20rpx/0.10;auraBreathe 透明度 0.55↔0.85 → 0.30↔0.50,fast 版 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% 淡底、无边框无阴影、圆角 999rpx;padding 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 图标 + goalLine,26rpx/500/主色(静态摘要,常驻) + - 分隔线 `info-divider`(1rpx 主色 14% 淡线,仅提示出现时渲染) + - 下 `info-line--tip`:tipIcon + runningTip,30rpx/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 未改动。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index d453c10..2cd1f29 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -8,6 +8,7 @@ - **系统字体缩放致文字溢出固定盒**(真机重现、模拟器不重现):`getAppBaseInfo().host.env==='devtools'` 时跳过补偿,真机/PC 才把字号 `÷factor` 并 clamp。凡文字进固定圆/方盒且靠 `overflow:hidden` 裁切,都要考虑。 - **云函数写库必须 `{ data: {...} }` 包裹**(wx-server-sdk 2.6.3):`set/update/add` 漏包裹报 `parameter.data should be object`。读操作 `get()` 无此限制。 - **自定义组件宿主默认 inline**:页面给 `` 等标签加 `border`/`transform`/`box-shadow` 会碎裂或静默失效(仅 `ui-card` 已修 `:host{display:block}`)。描边优先用 `box-shadow:0 0 0 Nrpx` 并补 `border-radius`,别用 `border`。跨组件边界的 `nth-child` 选择器一律失效,交错延迟由页面传 `index` 驱动。 +- **@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` 仅本地调试用,正式版非必须。 - **App.onLaunch 的 async/await 不阻塞首屏**:微信框架是「非阻塞生命周期调度」——onLaunch 被调用但页面 onLoad/onShow 并行推进,不等 onLaunch 内 Promise resolve。所以 app.js 里 `await cloud.pullAll()` 不会卡首屏,只是 await 之后的同步逻辑延迟执行。判断启动性能**别把 onLaunch 的 await 当阻塞**。 - **代码审查避免过度判断**:给本项目做问题分析时,勿把"合理设计选择/优化建议"拔高成"问题/风险"。已被纠错的判断:①隐私协议缺失(实为后台配置,代码看不到)②onLaunch await 阻塞启动(机制理解错)③组件默认 isolated 不一致(实为合理分层)。下结论前先核实机制事实,区分"真 bug / 可优化 / 合理设计"。 diff --git a/.workbuddy/style-dna-default.md b/.workbuddy/style-dna-default.md new file mode 100644 index 0000000..7d5b8c9 --- /dev/null +++ b/.workbuddy/style-dna-default.md @@ -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. 节奏结构 +先总(一句话更新概览)→ 分(功能块)→ 总(召唤行动)。 diff --git a/app.wxss b/app.wxss index d6e394f..8ebd9b4 100644 --- a/app.wxss +++ b/app.wxss @@ -1,3 +1,13 @@ +/* 自定义字体:Saira Condensed 900(base64 内联,确保微信小程序 @font-face 可靠加载)。 + 原文件 static/fonts/saira-condensed-900.woff2(17.7KB→base64 24KB)。 */ +@font-face { + font-family: 'Saira Condensed'; + src: url('data:font/woff2;charset=utf-8;base64,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') format('woff2'); + font-weight: 900; + font-style: normal; + font-display: swap; +} + page { --text: #333333; --text-secondary: #999999; diff --git a/components/progress-ring/progress-ring.js b/components/progress-ring/progress-ring.js index dba2dea..a3ca42a 100644 --- a/components/progress-ring/progress-ring.js +++ b/components/progress-ring/progress-ring.js @@ -19,7 +19,20 @@ Component({ // 60 to avoid clutter). 0 = no ticks drawn. Quarter ticks are emphasized. ticks: { type: Number, value: 0 }, // 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: { @@ -37,6 +50,11 @@ Component({ 'remaining, duration, size, ringWidth, primaryColor, primaryLightColor, trackColor, ticks'() { 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 this._ctx = canvas.getContext('2d') this._dpr = dpr + this._canvas = canvas this._draw() + this._syncSetPulse() }) + }, + + detached() { + this._stopSetPulse() } }, @@ -97,21 +121,36 @@ Component({ // ---- tick marks (inside the band) ---- if (ticks > 0) { 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 longLen = 12 * dpr + const stepDeg = 360 / ticks + // 短刻度:均匀铺满;但跳过靠近四方位(12/3/6/9 点)的,让位给长刻度,避免双线重叠 for (let i = 0; i < ticks; i++) { - const ang = -Math.PI / 2 + (i / ticks) * Math.PI * 2 - const isLong = (i % q === 0) - const len = isLong ? longLen : shortLen + const deg = (i / ticks) * 360 + const nearestQuarter = Math.round(deg / 90) * 90 + if (Math.abs(deg - nearestQuarter) < stepDeg) continue + const ang = -Math.PI / 2 + (deg * Math.PI) / 180 const cos = Math.cos(ang) const sin = Math.sin(ang) ctx.beginPath() ctx.moveTo(cx + innerR * cos, cy + innerR * sin) - ctx.lineTo(cx + (innerR - len) * cos, cy + (innerR - len) * sin) - ctx.lineWidth = (isLong ? 3 : 1.5) * dpr + ctx.lineTo(cx + (innerR - shortLen) * cos, cy + (innerR - shortLen) * sin) + ctx.lineWidth = 1.5 * dpr 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() } } @@ -167,6 +206,168 @@ Component({ ctx.fill() 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) + } } } }) diff --git a/config.js b/config.js index 7f603e6..f970bf6 100644 --- a/config.js +++ b/config.js @@ -7,7 +7,7 @@ module.exports = { version: 'v3.2', /** 最后更新日期,外显在设置页「关于」 */ - updatedAt: '2026-08-11', + updatedAt: '2026-08-12', /** 开发者名称 / 微信号,设置页点击可复制 */ developer: '三口一瓶', diff --git a/pages/index/index.js b/pages/index/index.js index bbc1d75..d871efd 100644 --- a/pages/index/index.js +++ b/pages/index/index.js @@ -19,18 +19,11 @@ Page({ // 空则回退占位(人形图标 + "运动达人"),settings 改完回首页即刷新。 nickName: '', 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, planName: '', planDay: 1, planTotal: 7, - // hero / 进度地图 / dial 相关展示字段(由 refresh 生成) + // hero / 进度地图 / 时长展示相关字段(由 refresh 生成) greetText: '你好', streakText: '今天开启坚持之旅', questNodes: [], @@ -52,50 +45,9 @@ Page({ onLoad() { themeMod.applyThemeToPage(this) - this._readFontScale() 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() { themeMod.applyThemeToPage(this) try { diff --git a/pages/index/index.wxml b/pages/index/index.wxml index 07a3733..dd31f30 100644 --- a/pages/index/index.wxml +++ b/pages/index/index.wxml @@ -1,4 +1,4 @@ - + @@ -37,12 +37,10 @@ {{questTip}} - + - - {{todayTargetText}} - 今日目标时长 - + {{todayTargetText}} + 今日目标时长 ⚡ 预计消耗 {{estKcal}} 千卡 diff --git a/pages/index/index.wxss b/pages/index/index.wxss index ef03e94..8175ce3 100644 --- a/pages/index/index.wxss +++ b/pages/index/index.wxss @@ -3,6 +3,14 @@ 底部留大内边距给下方 quest-card 负 margin 上浮叠住。文字统一白色,图标用 *White 变体(若有)。hero 用 opacity-only 动画,避免 transform 把 .container 变成 containing block 而破坏弹窗定位(同 app.wxss pageIn 思路)。 */ + +/* index-page:仅本页面把 .container 升级为 flex 列, + 时钟区 flex:1 居中,操作按钮 margin-top:auto 贴底(不动全局 container)。 */ +.index-page { + display: flex; + flex-direction: column; +} + .hero { margin: -24rpx -24rpx 0; padding: 24rpx 24rpx 72rpx; @@ -199,57 +207,41 @@ 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 { - text-align: center; - 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); + flex: 1; display: flex; flex-direction: column; align-items: 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; - white-space: nowrap; - overflow: hidden; + min-height: 0; } -.dial-label { - font-size: 24rpx; +.time-display { + 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); margin-top: 8rpx; } .benefit { - margin-top: 20rpx; + margin-top: 18rpx; font-size: 28rpx; color: var(--text-secondary); } @@ -260,17 +252,16 @@ font-size: 30rpx; } -@keyframes ringBreathe { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.02); } -} - /* ===== action buttons ===== */ +/* 在 index-page flex 列里,3 个按钮(rechallenge + free + circuit)靠 + margin-top:auto 推到列底、紧贴底部 toolbar 上方,与上方居中的时钟 + 区之间自然留白;gap 保持按钮自身间距不变 */ .action-buttons { display: flex; flex-direction: column; gap: 24rpx; margin: 8rpx 0 8rpx; + margin-top: auto; } /* 自由训练 / 循环训练 并排一行 */ diff --git a/pages/timer/timer.js b/pages/timer/timer.js index 728dcb1..e8b2632 100644 --- a/pages/timer/timer.js +++ b/pages/timer/timer.js @@ -37,13 +37,8 @@ Page({ workTotal: 0, // effective held seconds (excludes rest) isResting: false, phaseTip: '', // 阶段切换提示文本(如"最后一组!"/"休息 15 秒") - // 顶部循环进度面板:组进度胶囊点阵 - circuitDots: [], // [1..sets],仅用于 wx:for 渲染,组数过多时不渲染 - circuitShowDots: true, // 组数 > DOTS_MAX 时退化为线性进度条 - circuitTotalWork: 0, // sets × holdPerSet,面板里作为"目标秒数"展示 - dotsDone: 0, // 已完成组数 - dotsActive: 0, // 正在进行的组序号(休息时为 0) - dotsNext: 0, // 休息时即将开始的组序号(工作时为 0) + // 组进度改由 progress-ring 外圈分段弧呈现,仅需传 sets/currentSet/resting, + // 页面不再维护点阵渲染数据(circuitDots/dotsDone 等已随旧面板一并删除)。 celebrationLevel: 'normal', // normal / first / milestone / record celebrationMessage: '', confettiPieces: [], @@ -222,8 +217,8 @@ Page({ sets, restPerSet: rest, onTick: (t) => { - // 组进度点阵三态。休息阶段 CircuitTimer 的 setIndex 仍指向"刚做完 - // 的那一组",所以 resting 时 setIndex 组算已完成、下一组标为 next。 + // 组进度语义与旧点阵一致:休息时 setIndex 仍指向"刚做完的那一组", + // 组件按 resting 自行推导已完成/进行中/下一组三态(外圈分段弧)。 const resting = t.phase === 'resting' this.setData({ currentSet: t.setIndex, @@ -233,9 +228,6 @@ Page({ phaseElapsed: t.phaseElapsed, workTotal: t.workTotal, 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 // number keep working: the ring shows the CURRENT set's countdown. remaining: t.phaseRemaining, @@ -244,7 +236,9 @@ Page({ statusText: resting ? '休息中' : '撑住', runningTip: resting ? '' : this.data.phaseTip, tipType: 'phase', - tipIcon: this.data.icons.peopleFill + tipIcon: this.data.icons.peopleFill, + // 环内副标题:宏观进度(第几组)交给外圈分段弧,这里只报当前组位次。 + ringSubText: `第 ${t.setIndex}/${t.sets} 组` }) this._circuitCue(t) }, @@ -252,13 +246,9 @@ Page({ onComplete: () => this._onCircuitComplete() }) - // 点阵超过 DOTS_MAX 个就挤成头发丝了,退化成一条线性进度条。 - const DOTS_MAX = 15 - const dots = [] - if (sets <= DOTS_MAX) { - for (let i = 1; i <= sets; i++) dots.push(i) - } - + // 组数上限 50(见 _int 的 clamp),环形外圈 360° 空间充裕,即使 50 段也 + // 不至于挤成线(旧点阵的 DOTS_MAX=15 是受横向卡片宽度限制,环形无此问题), + // 因此分段弧不做数量降级,页面也无须维护点阵渲染数据。 const durText = this._durationText(hold) this.setData({ isCircuitMode: true, @@ -268,12 +258,6 @@ Page({ circuitHold: hold, circuitRest: rest, circuitSessions: this._circuitCfg.sessionsPerWeek, - circuitDots: dots, - circuitShowDots: sets <= DOTS_MAX, - circuitTotalWork: sets * hold, - dotsDone: 0, - dotsActive: 0, - dotsNext: 0, currentSet: 0, totalSets: sets, phase: 'idle', @@ -294,7 +278,9 @@ Page({ showCompletion: false, goalLine: `每组 ${durText} · 共 ${sets} 组`, ringTicks: Math.min(hold, 60), - ringSubText: `每组 ${durText}`, + // idle 时环内副标题承载配置摘要(组数 + 休息时长),运行时由 onTick + // 换成"第 X/Y 组"。休息时长为 0 时不展示"休息"段。 + ringSubText: `共 ${sets} 组${rest > 0 ? ' · 休息 ' + rest + ' 秒' : ''}`, statusText: '准备开始', runningTip: '', tipType: 'posture', diff --git a/pages/timer/timer.wxml b/pages/timer/timer.wxml index 687033b..d63bfa0 100644 --- a/pages/timer/timer.wxml +++ b/pages/timer/timer.wxml @@ -15,44 +15,9 @@ {{celebrationMessage}} - - - - - - - - - - - - - - {{status === 'idle' ? totalSets : currentSet}} - /{{totalSets}} - - - - - {{status === 'paused' ? '已暂停' : (status === 'idle' ? '准备开始' : (isResting ? '休息中' : '撑住'))}} - - - - - 每组 {{circuitHold}} 秒{{circuitRest > 0 ? ' · 休息 ' + circuitRest + ' 秒' : ''}} - 已撑 {{workTotal}} 秒 · 目标 {{circuitTotalWork}} 秒 - - - - + @@ -60,13 +25,16 @@ wx:if="{{status !== 'completed'}}"> + + @@ -87,18 +58,23 @@ - - {{statusText}} - - - - {{goalLine}} - - - - - - {{runningTip}} + + + + + {{statusText}} + + + + + {{goalLine}} + + + + + {{runningTip}} + diff --git a/pages/timer/timer.wxss b/pages/timer/timer.wxss index 1e4b132..cf68937 100644 --- a/pages/timer/timer.wxss +++ b/pages/timer/timer.wxss @@ -9,227 +9,14 @@ 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 { position: relative; display: flex; align-items: center; justify-content: center; + /* 圆环与下方(状态词/目标卡/提示)整组拉开间距,按钮靠 margin-top:auto 贴底不受影响 */ + margin-bottom: 44rpx; } /* breathing guide rings — soft radial glows instead of hard outline circles, @@ -237,21 +24,53 @@ .breathe-ring { position: absolute; 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; pointer-events: none; z-index: 0; } -.breathe-ring-1 { width: 520rpx; height: 520rpx; 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-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-1 { width: 620rpx; height: 620rpx; animation-delay: 0s; } +.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: 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.breathe-ring-1 { animation-delay: 0s; } .breathe-ring.fast.breathe-ring-2 { animation-delay: 0.25s; } .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 */ .celebrate-burst { position: absolute; @@ -281,115 +100,105 @@ 100% { opacity: 0; transform: translate(0, -80rpx) scale(0.4); } } -/* ---- 状态文案(环下方) ---- */ -.timer-status { - margin-top: 40rpx; - font-size: 36rpx; - font-weight: 700; - color: var(--text); - line-height: 1.2; - text-align: center; +/* ---- 信息气泡·状态行(原环下方 statusText 并入气泡顶部) ---- + 状态色:running=主色 / 休息中=绿 / idle、paused=中性灰。 + resting 时 status 字段仍是 running,额外用 .info-line--rest 覆盖为绿。 + 小圆点用 currentColor 跟随状态色,纯 CSS 画(项目 SVG 图标色烤死改不动)。 */ +.info-line--status { + gap: 12rpx; } -.timer-status.status--running { color: var(--primary); } -.timer-status.status--paused { color: var(--text-secondary); } +.info-status-dot { + width: 14rpx; + height: 14rpx; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} -/* ---- 目标信息卡 ---- */ -.goal-card { +.info-status-text { + 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; - align-items: center; + flex-direction: column; + box-sizing: border-box; width: 620rpx; max-width: 86%; - box-sizing: border-box; - margin-top: 28rpx; + /* 承接原 timer-status 的 40rpx 间距,圆环与卡片保持呼吸感 */ + margin-top: 40rpx; padding: 24rpx 28rpx; - background: var(--card-bg); - border: 1rpx solid rgba(var(--primary-rgb), 0.12); - border-radius: 24rpx; - box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.05); + background: rgba(var(--primary-rgb), 0.08); + border-radius: 26rpx; } -.goal-icon { - width: 40rpx; - height: 40rpx; +.info-line { + display: flex; + align-items: center; +} + +.info-icon { + width: 32rpx; + height: 32rpx; flex-shrink: 0; - margin-right: 16rpx; + margin-right: 12rpx; } -.goal-text { +.info-text { flex: 1; 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; } -.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% { - transform: scale(1); - box-shadow: 0 8rpx 24rpx rgba(var(--primary-rgb), 0.14); - } - 50% { - transform: scale(1.03); - box-shadow: 0 12rpx 32rpx rgba(var(--primary-rgb), 0.22); - } +/* 分隔线:提示行出现时才渲染 */ +.info-divider { + height: 1rpx; + background: rgba(var(--primary-rgb), 0.14); + margin: 14rpx 0; +} + +/* 提示行:动态信息,比目标行大一号更突出 */ +.info-line--tip .info-text { + font-size: 30rpx; + font-weight: 600; + color: var(--text); +} + +.info-line--done .info-text { + color: var(--success); } /* ---- controls ---- */ diff --git a/project.config.json b/project.config.json index 897e630..162a843 100644 --- a/project.config.json +++ b/project.config.json @@ -23,7 +23,7 @@ "minified": true, "autoAudits": false, "newFeature": false, - "uglifyFileName": false, + "uglifyFileName": true, "uploadWithSourceMap": false, "useIsolateContext": true, "nodeModules": false, @@ -49,7 +49,8 @@ "disableUseStrict": false, "useCompilerPlugins": false, "swc": false, - "disableSWC": true + "disableSWC": true, + "ignoreUploadUnusedFiles": true }, "compileType": "miniprogram", "lazyCodeLoading": "requiredComponents", diff --git a/project.private.config.json b/project.private.config.json index d1dc72f..451702e 100644 --- a/project.private.config.json +++ b/project.private.config.json @@ -3,7 +3,7 @@ "projectname": "wx_pbzc", "condition": {}, "setting": { - "urlCheck": true, + "urlCheck": false, "coverView": true, "lazyloadPlaceholderEnable": false, "skylineRenderEnable": false,