feat: 训练完成全屏弹窗 (大数字+countUp+超时+3按钮)

替换原来不显眼的 wx.showToast:
- 全屏遮罩 + 大圆角卡片,scale-in 弹性入场
- 大数字 180rpx countUp 从 0 滚到实际秒数(1.2s)
- 超时标签 +Xs 秒超时(OvertimePop 弹性出现,延迟 1.1s 等数字到末尾)
- 连续打卡显示 🔥
- 3 按钮:再来一次(留在当前页重置) / 查看记录(跳记录页) / 回到首页
- 装饰 emoji 圆形 1.6s 循环脉冲
- onComplete 触发时自动保存,不需要再手动点结束
This commit is contained in:
2026-07-08 14:19:40 +08:00
parent f052327276
commit 503f01f6e9
3 changed files with 323 additions and 17 deletions
+100 -17
View File
@@ -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
})
}
})
+38
View File
@@ -113,4 +113,42 @@
></ui-btn>
</view>
</view>
<!-- 完成训练全屏弹窗 -->
<view class="completion-mask" wx:if="{{showCompletion}}">
<view class="completion-card">
<view class="completion-emoji-wrap">
<text class="completion-emoji">{{completionLevel === 'first' ? '🎉' : completionLevel === 'record' ? '🏆' : completionLevel === 'milestone' ? '🔥' : '✨'}}</text>
</view>
<text class="completion-title">{{completionMessage}}</text>
<view class="completion-number-wrap">
<text class="completion-number">{{completionElapsed}}</text>
<text class="completion-unit">秒</text>
</view>
<text class="completion-label">本次训练</text>
<view class="completion-overtime" wx:if="{{completionOvertime > 0}}">
<text class="overtime-plus">+{{completionOvertime}}</text>
<text class="overtime-label">秒超时</text>
</view>
<view class="completion-streak" wx:if="{{completionStreak > 0}}">
<text class="streak-flame">🔥</text>
<text class="streak-text">连续打卡 {{completionStreak}} 天</text>
</view>
<view class="completion-actions">
<view class="completion-btn completion-btn--primary" bindtap="onTrainAgain">
<text>再来一次</text>
</view>
<view class="completion-btn" bindtap="onViewRecords">
<text>查看记录</text>
</view>
</view>
<view class="completion-btn completion-btn--text" bindtap="onCloseCompletion">
<text>回到首页</text>
</view>
</view>
</view>
</view>
+185
View File
@@ -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;
}