From 503f01f6e97e5f1c8a351e56b036e1be8273d222 Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Wed, 8 Jul 2026 14:19:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=AD=E7=BB=83=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=85=A8=E5=B1=8F=E5=BC=B9=E7=AA=97=20(=E5=A4=A7=E6=95=B0?= =?UTF-8?q?=E5=AD=97+countUp+=E8=B6=85=E6=97=B6+3=E6=8C=89=E9=92=AE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 替换原来不显眼的 wx.showToast: - 全屏遮罩 + 大圆角卡片,scale-in 弹性入场 - 大数字 180rpx countUp 从 0 滚到实际秒数(1.2s) - 超时标签 +Xs 秒超时(OvertimePop 弹性出现,延迟 1.1s 等数字到末尾) - 连续打卡显示 🔥 - 3 按钮:再来一次(留在当前页重置) / 查看记录(跳记录页) / 回到首页 - 装饰 emoji 圆形 1.6s 循环脉冲 - onComplete 触发时自动保存,不需要再手动点结束 --- pages/timer/timer.js | 117 ++++++++++++++++++++++---- pages/timer/timer.wxml | 38 +++++++++ pages/timer/timer.wxss | 185 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 323 insertions(+), 17 deletions(-) diff --git a/pages/timer/timer.js b/pages/timer/timer.js index 269bd50..a5843e2 100644 --- a/pages/timer/timer.js +++ b/pages/timer/timer.js @@ -23,6 +23,14 @@ Page({ celebrationLevel: 'normal', // normal / first / milestone / record celebrationMessage: '', confettiPieces: [], + // Completion modal (full-screen summary after training ends) + showCompletion: false, + completionElapsed: 0, + completionTarget: 0, + completionOvertime: 0, + completionStreak: 0, + completionLevel: 'normal', + completionMessage: '', icons: iconsMod.build() }, @@ -96,24 +104,15 @@ Page({ }, onComplete: () => { const elapsed = this._timer.elapsed - const { level, message } = this._detectCelebrationLevel(elapsed) - const confettiPieces = this._buildConfettiPieces(level) - this.setData({ - status: 'completed', - isCompleted: true, - celebrationLevel: level, - celebrationMessage: message, - confettiPieces - }) const s = storage.getSettings() if (s.vibrate) { try { wx.vibrateLong() } catch (e) {} } - // 4th voice prompt: completion if (s.voiceGuide !== false) { voice.play('complete') } - wx.showToast({ title: '目标完成! 太棒了!', icon: 'success', duration: 2000 }) + // Auto-save + show big completion modal + this._saveAndShowCompletion(elapsed) } }) }, @@ -271,8 +270,9 @@ Page({ }, finishTraining() { - // Guard against double-tap: stop button is still visible during the - // 1.5s navigateBack delay, so a second tap would create a duplicate. + // Guard against double-tap: completion modal shows immediately, so + // a second tap on the underlying 结束 button is harmless — but we + // still need to guard the save itself. if (this._saving) return this._saving = true @@ -283,6 +283,20 @@ Page({ setTimeout(() => { wx.navigateBack() }, 1500) return } + this._saveAndShowCompletion(elapsed) + }, + + /** + * Persist the just-finished training and pop the big completion modal. + * Called from both onComplete (natural finish) and finishTraining + * (user ended early). Detection of "first / record / milestone" uses + * pre-save stats so the level is decided before the data changes. + */ + _saveAndShowCompletion(elapsed) { + const { level, message } = this._detectCelebrationLevel(elapsed) + const confettiPieces = this._buildConfettiPieces(level) + + // Save record + streak const today = storage.getToday() storage.saveRecord({ date: today, @@ -292,11 +306,80 @@ Page({ }) storage.updateStreak(today) - // Flag the just-completed training so the index page can briefly - // highlight the "今日已完成" badge when the user lands back there. + // Flag for the index page's "训练完成" highlight try { wx.setStorageSync('_last_train_at', Date.now()) } catch (e) {} - wx.showToast({ title: `已记录 ${elapsed}秒`, icon: 'none', duration: 1500 }) - setTimeout(() => { wx.navigateBack() }, 1500) + const finalStreak = storage.getStreak().count + + this.setData({ + // Background effects keep playing behind the modal + status: 'completed', + isCompleted: true, + celebrationLevel: level, + celebrationMessage: message, + confettiPieces, + // Modal state + showCompletion: true, + completionElapsed: 0, + completionTarget: this.data.duration, + completionOvertime: Math.max(0, elapsed - this.data.duration), + completionStreak: finalStreak, + completionLevel: level, + completionMessage: message + }) + + // Animate the big number from 0 to elapsed + if (this._completionCountUp) this._completionCountUp.cancel() + this._completionCountUp = countUp({ + from: 0, + to: elapsed, + duration: 1200, + onUpdate: (v) => this.setData({ completionElapsed: v }), + onComplete: () => { this._completionCountUp = null } + }) + }, + + onCloseCompletion() { + if (this._completionCountUp) { + this._completionCountUp.cancel() + this._completionCountUp = null + } + this.setData({ showCompletion: false }) + setTimeout(() => { wx.navigateBack() }, 280) + }, + + onViewRecords() { + if (this._completionCountUp) { + this._completionCountUp.cancel() + this._completionCountUp = null + } + this.setData({ showCompletion: false }) + setTimeout(() => { + wx.navigateBack({ + success: () => { + setTimeout(() => wx.switchTab({ url: '/pages/records/records' }), 100) + } + }) + }, 280) + }, + + onTrainAgain() { + // Reset the timer to idle so the user can start a new session right + // here (no need to navigate back and re-tap 开始). + if (this._completionCountUp) { + this._completionCountUp.cancel() + this._completionCountUp = null + } + this.setData({ + showCompletion: false, + isCompleted: false, + status: 'idle', + isRunning: false, + isPaused: false, + remaining: this.data.duration, + overtime: 0, + // Re-init countUp so the big number rolls again on next start + _countUpTask: null + }) } }) diff --git a/pages/timer/timer.wxml b/pages/timer/timer.wxml index bf40106..3dbc304 100644 --- a/pages/timer/timer.wxml +++ b/pages/timer/timer.wxml @@ -113,4 +113,42 @@ > + + + + + + {{completionLevel === 'first' ? '🎉' : completionLevel === 'record' ? '🏆' : completionLevel === 'milestone' ? '🔥' : '✨'}} + + {{completionMessage}} + + + {{completionElapsed}} + + + 本次训练 + + + +{{completionOvertime}} + 秒超时 + + + + 🔥 + 连续打卡 {{completionStreak}} 天 + + + + + 再来一次 + + + 查看记录 + + + + 回到首页 + + + diff --git a/pages/timer/timer.wxss b/pages/timer/timer.wxss index a7b1cff..fe8f103 100644 --- a/pages/timer/timer.wxss +++ b/pages/timer/timer.wxss @@ -208,3 +208,188 @@ 0% { opacity: 1; transform: translateX(-50%) scale(1); } 100% { opacity: 0; transform: translateX(-50%) scale(0.8); } } + +/* ---- completion modal (full-screen summary after training ends) ---- */ +.completion-mask { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 40rpx; + box-sizing: border-box; + animation: completionMaskIn 0.3s ease; +} + +@keyframes completionMaskIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.completion-card { + width: 100%; + max-width: 640rpx; + background: var(--card-bg); + border-radius: 32rpx; + padding: 56rpx 40rpx 40rpx; + display: flex; + flex-direction: column; + align-items: center; + box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.25); + animation: completionCardIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes completionCardIn { + 0% { transform: scale(0.6) translateY(40rpx); opacity: 0; } + 100% { transform: scale(1) translateY(0); opacity: 1; } +} + +.completion-emoji-wrap { + width: 140rpx; + height: 140rpx; + border-radius: 50%; + background: linear-gradient(135deg, var(--primary), var(--primary-light)); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 24rpx; + box-shadow: 0 8rpx 24rpx rgba(var(--primary-rgb), 0.4); + animation: completionEmojiPulse 1.6s ease-in-out infinite; +} + +@keyframes completionEmojiPulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.08); } +} + +.completion-emoji { + font-size: 80rpx; + line-height: 1; +} + +.completion-title { + font-size: 32rpx; + font-weight: 600; + color: var(--text); + margin-bottom: 32rpx; + text-align: center; +} + +.completion-number-wrap { + display: flex; + align-items: baseline; + justify-content: center; + margin-bottom: 8rpx; +} + +.completion-number { + font-size: 180rpx; + font-weight: 800; + color: var(--primary); + line-height: 1; + font-variant-numeric: tabular-nums; + letter-spacing: -4rpx; + text-shadow: 0 4rpx 12rpx rgba(var(--primary-rgb), 0.2); +} + +.completion-unit { + font-size: 36rpx; + font-weight: 600; + color: var(--text-secondary); + margin-left: 8rpx; +} + +.completion-label { + font-size: 26rpx; + color: var(--text-secondary); + margin-bottom: 24rpx; +} + +.completion-overtime { + display: flex; + align-items: baseline; + gap: 6rpx; + background: rgba(var(--primary-rgb), 0.12); + padding: 12rpx 24rpx; + border-radius: 32rpx; + margin-bottom: 16rpx; + animation: overtimePop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 1.1s both; +} + +@keyframes overtimePop { + 0% { transform: scale(0); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } +} + +.overtime-plus { + font-size: 32rpx; + font-weight: 700; + color: var(--primary); + line-height: 1; +} + +.overtime-label { + font-size: 24rpx; + color: var(--primary); + font-weight: 500; +} + +.completion-streak { + display: flex; + align-items: center; + gap: 8rpx; + margin-bottom: 36rpx; + font-size: 26rpx; + color: var(--text); +} + +.streak-flame { + font-size: 32rpx; +} + +.streak-text { + font-weight: 500; +} + +.completion-actions { + display: flex; + gap: 20rpx; + width: 100%; + margin-bottom: 12rpx; +} + +.completion-btn { + flex: 1; + height: 88rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 44rpx; + font-size: 30rpx; + font-weight: 600; + transition: transform 0.15s ease, opacity 0.15s ease, background 0.2s ease; + background: var(--bg-soft); + color: var(--text); +} + +.completion-btn:active { + transform: scale(0.96); + opacity: 0.85; +} + +.completion-btn--primary { + background: linear-gradient(135deg, var(--primary), var(--primary-light)); + color: #FFFFFF; + box-shadow: 0 8rpx 20rpx rgba(var(--primary-rgb), 0.3); +} + +.completion-btn--text { + background: transparent; + color: var(--text-secondary); + font-weight: 500; + height: 64rpx; + font-size: 26rpx; + margin-top: 4rpx; +}