From 0464eeaba6b274927f684b19ad797ac61a36a121 Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Tue, 11 Aug 2026 15:38:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(timer):=20=E8=AE=AD=E7=BB=83=E9=A1=B5?= =?UTF-8?q?=E6=94=B9=E7=89=88=20hero=20=E9=A3=8E=E6=A0=BC=20+=20=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E7=8E=AF=E5=88=BB=E5=BA=A6=20+=20=E9=86=92=E7=9B=AE?= =?UTF-8?q?=E5=A7=BF=E6=80=81=E6=8F=90=E7=A4=BA=E6=B0=94=E6=B3=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - progress-ring: canvas 新增秒刻度层(四分位加粗), 环内加 subText, 移除组件内 statusText - timer.wxml: 环下方状态词 + 目标信息卡(今日目标·第N天›静态箭头) + 运行提示; 进度环传 ticks/subText - timer.js: 派生 ringTicks/ringSubText/statusText/goalLine/runningTip/tipType/tipIcon, 各生命周期计算下发 - timer.wxss: 按钮贴底(flex-start+margin-top:auto), 新增 .timer-status/.goal-card/.timer-tip 气泡卡片(主色左边框+小三角+tipPulse 呼吸), 文字放大加粗 - 姿态提示分语义: posture/phase(主色) / done(成功色), 图标用 peopleFill/successFill - 状态机/计时/循环FSM/呼吸环/彩带/弹窗/语音震动常亮全部保留 - 另含 index 进度地图右上角奖杯图标删除(quest-trophy 样式清理) --- components/progress-ring/progress-ring.js | 40 ++++-- components/progress-ring/progress-ring.wxml | 2 +- components/progress-ring/progress-ring.wxss | 8 +- pages/index/index.wxml | 1 - pages/index/index.wxss | 4 - pages/timer/timer.js | 67 ++++++++-- pages/timer/timer.wxml | 40 +++--- pages/timer/timer.wxss | 129 +++++++++++++++----- 8 files changed, 209 insertions(+), 82 deletions(-) diff --git a/components/progress-ring/progress-ring.js b/components/progress-ring/progress-ring.js index 87c0906..dba2dea 100644 --- a/components/progress-ring/progress-ring.js +++ b/components/progress-ring/progress-ring.js @@ -14,26 +14,28 @@ Component({ trackColor: { type: String, value: '#EEEEEE' }, // Total elapsed seconds (since start). Shown in the ring center once the // countdown hits 0 (overtime mode) - replaces the old +Xs badge. - elapsed: { type: Number, value: 0 } + elapsed: { type: Number, value: 0 }, + // Tick count. Caller passes the duration (one tick per second, capped at + // 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: '' } }, data: { - displayTime: '00:00', - statusText: '准备开始' + displayTime: '00:00' }, observers: { 'remaining, status, duration, elapsed'() { - const texts = { idle: '准备开始', running: '坚持住', paused: '已暂停', completed: '完成!' } // 倒计时阶段显示剩余;达标后 remaining 归零,改显总训练时长(overtime 模式) const shown = this.data.remaining > 0 ? this.data.remaining : (this.data.elapsed || 0) this.setData({ - displayTime: util.formatTime(shown), - statusText: texts[this.data.status] || '' + displayTime: util.formatTime(shown) }) }, - 'remaining, duration, size, ringWidth, primaryColor, primaryLightColor, trackColor'() { + 'remaining, duration, size, ringWidth, primaryColor, primaryLightColor, trackColor, ticks'() { this._draw() } }, @@ -70,7 +72,7 @@ Component({ const ctx = this._ctx if (!ctx) return - const { size, ringWidth, duration, remaining, primaryColor, primaryLightColor, trackColor } = this.data + const { size, ringWidth, duration, remaining, primaryColor, primaryLightColor, trackColor, ticks } = this.data const dpr = this._dpr const w = size * dpr @@ -92,6 +94,28 @@ Component({ ctx.lineCap = 'round' ctx.stroke() + // ---- 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 + 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 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.lineCap = 'round' + ctx.strokeStyle = isLong ? 'rgba(0,0,0,0.28)' : 'rgba(0,0,0,0.12)' + ctx.stroke() + } + } + // progress arc (clockwise from 12 o'clock) const ratio = duration > 0 ? Math.max(0, Math.min(1, remaining / duration)) : 0 if (ratio > 0.001) { diff --git a/components/progress-ring/progress-ring.wxml b/components/progress-ring/progress-ring.wxml index 4d9d036..839ad5d 100644 --- a/components/progress-ring/progress-ring.wxml +++ b/components/progress-ring/progress-ring.wxml @@ -2,6 +2,6 @@ {{displayTime}} - {{statusText}} + {{subText}} diff --git a/components/progress-ring/progress-ring.wxss b/components/progress-ring/progress-ring.wxss index ca66fba..b1c25ba 100644 --- a/components/progress-ring/progress-ring.wxss +++ b/components/progress-ring/progress-ring.wxss @@ -21,7 +21,7 @@ } .time-text { - font-size: 88rpx; + font-size: 96rpx; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; @@ -29,9 +29,9 @@ line-height: 1; } -.status-text { - font-size: 28rpx; +.sub-text { + font-size: 26rpx; color: var(--text-secondary); - margin-top: 10rpx; + margin-top: 14rpx; line-height: 1; } diff --git a/pages/index/index.wxml b/pages/index/index.wxml index f182e85..07a3733 100644 --- a/pages/index/index.wxml +++ b/pages/index/index.wxml @@ -27,7 +27,6 @@ {{planName}}第 {{planDay}} / {{planTotal}} 天 - 🏆 diff --git a/pages/index/index.wxss b/pages/index/index.wxss index d656731..ef03e94 100644 --- a/pages/index/index.wxss +++ b/pages/index/index.wxss @@ -137,10 +137,6 @@ margin-left: 12rpx; } -.quest-trophy { - font-size: 40rpx; -} - .quest-map { display: flex; align-items: center; diff --git a/pages/timer/timer.js b/pages/timer/timer.js index aa5f6de..728dcb1 100644 --- a/pages/timer/timer.js +++ b/pages/timer/timer.js @@ -59,6 +59,14 @@ Page({ // 完成弹窗科学提示(config.scienceTips 按本次时长选段) completionTipValue: '', completionTipRisk: '', + // --- 计时页改版派生字段(状态文案 / 目标信息卡 / 环刻度 / 环内副标题) --- + ringTicks: 0, + ringSubText: '', + statusText: '准备开始', + goalLine: '', + runningTip: '', + tipType: 'posture', + tipIcon: '', icons: iconsMod.build() }, @@ -120,6 +128,7 @@ Page({ target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays), customPlans) } + const durText = this._durationText(target) this.setData({ duration: target, remaining: target, @@ -127,7 +136,14 @@ Page({ planDay: isFreeMode ? 0 : planDay, isFreeMode, goalReached: false, - elapsed: 0 + elapsed: 0, + goalLine: isFreeMode ? `自由训练 · 目标 ${durText}` : `今日目标 ${durText} · 第 ${planDay} 天`, + ringTicks: Math.min(target, 60), + ringSubText: `目标 ${durText}`, + statusText: '准备开始', + runningTip: '', + tipType: 'posture', + tipIcon: this.data.icons.peopleFill }) if (this._timer) { @@ -149,7 +165,10 @@ Page({ onTick: (tick) => { this.setData({ remaining: tick.remaining > 0 ? tick.remaining : 0, - elapsed: tick.elapsed + elapsed: tick.elapsed, + runningTip: this.data.goalReached ? '目标达成,继续挑战!' : this.data.hintTip, + tipType: this.data.goalReached ? 'done' : 'posture', + tipIcon: this.data.goalReached ? this.data.icons.successFill : this.data.icons.peopleFill }) this._updateHintTip(tick) this._remind(tick) @@ -161,7 +180,7 @@ Page({ if (s.voiceGuide !== false) { voice.play('goal') } - this.setData({ goalReached: true }) + this.setData({ goalReached: true, runningTip: '目标达成,继续挑战!', tipType: 'done', tipIcon: this.data.icons.successFill }) } }) }, @@ -221,7 +240,11 @@ Page({ // number keep working: the ring shows the CURRENT set's countdown. remaining: t.phaseRemaining, elapsed: t.workTotal, - duration: t.phase === 'working' ? hold : rest + duration: t.phase === 'working' ? hold : rest, + statusText: resting ? '休息中' : '撑住', + runningTip: resting ? '' : this.data.phaseTip, + tipType: 'phase', + tipIcon: this.data.icons.peopleFill }) this._circuitCue(t) }, @@ -236,6 +259,7 @@ Page({ for (let i = 1; i <= sets; i++) dots.push(i) } + const durText = this._durationText(hold) this.setData({ isCircuitMode: true, isFreeMode: false, @@ -267,7 +291,14 @@ Page({ isRunning: false, isPaused: false, isCompleted: false, - showCompletion: false + showCompletion: false, + goalLine: `每组 ${durText} · 共 ${sets} 组`, + ringTicks: Math.min(hold, 60), + ringSubText: `每组 ${durText}`, + statusText: '准备开始', + runningTip: '', + tipType: 'posture', + tipIcon: this.data.icons.peopleFill }) }, @@ -277,6 +308,18 @@ Page({ return Math.max(min, Math.min(max, n)) }, + /** + * 把秒数转成口语化时长文本:45 -> "45秒", 90 -> "1分30", 120 -> "2分钟"。 + * 用于目标信息卡与环内副标题,比 MM:SS 更贴近自然语言。 + */ + _durationText(sec) { + const s = Math.floor(sec || 0) + if (s < 60) return s + '秒' + const m = Math.floor(s / 60) + const rem = s % 60 + return rem === 0 ? m + '分钟' : m + '分' + rem + }, + /** * Fire a haptic buzz, tracked so pause/stop/unload can cancel pending ones. * Mirrors the vibrate helper inside _remind(). @@ -624,7 +667,7 @@ Page({ // Re-assert: if the user backgrounded the app while paused, WeChat has // already cleared the flag for us. this._keepScreenOn(true) - this.setData({ isRunning: true, isPaused: false, status: 'running' }) + this.setData({ isRunning: true, isPaused: false, status: 'running', statusText: '撑住', runningTip: '' }) return } if (this.data.isRunning) return @@ -640,7 +683,7 @@ Page({ this.setData({ remaining: this.data.duration }) } this._timer.start(this.data.duration) - this.setData({ isRunning: true, status: 'running' }) + this.setData({ isRunning: true, status: 'running', statusText: '撑住', runningTip: '' }) const s = storage.getSettings() if (s.voiceGuide !== false) voice.play('start') }, @@ -650,7 +693,7 @@ Page({ this._timer.pause() voice.stop() this._clearVibrateTimers() - this.setData({ isPaused: true, status: 'paused' }) + this.setData({ isPaused: true, status: 'paused', statusText: '已暂停', runningTip: '' }) }, onStop() { @@ -673,7 +716,7 @@ Page({ // 弹窗前若正在计时(被 onStop 暂停),取消后恢复 if (!this._stopWasPaused && this.data.isRunning) { this._timer.resume() - this.setData({ isRunning: true, isPaused: false, status: 'running' }) + this.setData({ isRunning: true, isPaused: false, status: 'running', statusText: '撑住', runningTip: '' }) } }, @@ -855,7 +898,11 @@ Page({ isPaused: false, remaining: this.data.duration, elapsed: 0, - goalReached: false + goalReached: false, + statusText: '准备开始', + runningTip: '', + tipType: 'posture', + tipIcon: this.data.icons.peopleFill }) }, diff --git a/pages/timer/timer.wxml b/pages/timer/timer.wxml index da08624..687033b 100644 --- a/pages/timer/timer.wxml +++ b/pages/timer/timer.wxml @@ -72,6 +72,8 @@ primaryColor="{{theme.primary}}" primaryLightColor="{{theme.primaryLight}}" trackColor="{{isCircuitMode ? '#EEEEEE' : (elapsed >= duration ? '#00B578' : '#EEEEEE')}}" + ticks="{{ringTicks}}" + subText="{{ringSubText}}" > @@ -85,32 +87,18 @@ - - - - - - 循环训练 · 共需撑 {{circuitTotalWork}} 秒 - 自由训练 · 目标 {{duration}} 秒 - 今日目标 {{duration}} 秒 · 第 {{planDay}} 天 - - - - - 目标达成,继续挑战! - - - - {{isCircuitMode ? phaseTip : hintTip}} - - - - 已暂停 - - - - 目标完成! 继续坚持! - + + {{statusText}} + + + + {{goalLine}} + + + + + + {{runningTip}} diff --git a/pages/timer/timer.wxss b/pages/timer/timer.wxss index f08d245..1e4b132 100644 --- a/pages/timer/timer.wxss +++ b/pages/timer/timer.wxss @@ -2,9 +2,9 @@ display: flex; flex-direction: column; align-items: center; - justify-content: space-between; + justify-content: flex-start; padding-top: 80rpx; - padding-bottom: calc(170rpx + env(safe-area-inset-bottom)); + padding-bottom: calc(48rpx + env(safe-area-inset-bottom)); min-height: 100vh; box-sizing: border-box; } @@ -281,47 +281,120 @@ 100% { opacity: 0; transform: translate(0, -80rpx) scale(0.4); } } -/* ---- hint section ---- */ -.hint-section { - margin-top: 36rpx; +/* ---- 状态文案(环下方) ---- */ +.timer-status { + margin-top: 40rpx; + font-size: 36rpx; + font-weight: 700; + color: var(--text); + line-height: 1.2; text-align: center; } -.hint-row { - display: inline-flex; +.timer-status.status--running { color: var(--primary); } +.timer-status.status--paused { color: var(--text-secondary); } + +/* ---- 目标信息卡 ---- */ +.goal-card { + display: flex; align-items: center; - justify-content: center; - gap: 10rpx; + width: 620rpx; + max-width: 86%; + box-sizing: border-box; + margin-top: 28rpx; + 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); } -.hint-icon { - width: 36rpx; - height: 36rpx; +.goal-icon { + width: 40rpx; + height: 40rpx; + flex-shrink: 0; + margin-right: 16rpx; } -.running-pulse { - animation: float 1.5s ease-in-out infinite; -} - -.completed-bounce { - animation: bounceSoft 0.6s ease infinite; -} - -.hint-text { - font-size: 34rpx; - color: var(--text-secondary); +.goal-text { + flex: 1; + min-width: 0; + font-size: 28rpx; + font-weight: 600; + color: var(--text); line-height: 1.3; } -.hint-text.running { color: var(--primary); font-weight: 600; } -.hint-text.paused { color: var(--primary-light); } -.hint-text.completed { color: var(--success); font-weight: 600; } +.goal-arrow { + font-size: 40rpx; + color: var(--text-secondary); + line-height: 1; + margin-left: 8rpx; +} -.completed-hint { animation: popIn 0.5s ease; } +/* ---- 运行提示(气泡卡片:姿态 / 阶段 / 目标达成) ---- */ +.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); +} + +@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); + } +} /* ---- controls ---- */ .controls { - margin-top: 60rpx; + margin-top: auto; width: 100%; display: flex; justify-content: center;