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:
+100
-17
@@ -23,6 +23,14 @@ Page({
|
|||||||
celebrationLevel: 'normal', // normal / first / milestone / record
|
celebrationLevel: 'normal', // normal / first / milestone / record
|
||||||
celebrationMessage: '',
|
celebrationMessage: '',
|
||||||
confettiPieces: [],
|
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()
|
icons: iconsMod.build()
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -96,24 +104,15 @@ Page({
|
|||||||
},
|
},
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
const elapsed = this._timer.elapsed
|
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()
|
const s = storage.getSettings()
|
||||||
if (s.vibrate) {
|
if (s.vibrate) {
|
||||||
try { wx.vibrateLong() } catch (e) {}
|
try { wx.vibrateLong() } catch (e) {}
|
||||||
}
|
}
|
||||||
// 4th voice prompt: completion
|
|
||||||
if (s.voiceGuide !== false) {
|
if (s.voiceGuide !== false) {
|
||||||
voice.play('complete')
|
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() {
|
finishTraining() {
|
||||||
// Guard against double-tap: stop button is still visible during the
|
// Guard against double-tap: completion modal shows immediately, so
|
||||||
// 1.5s navigateBack delay, so a second tap would create a duplicate.
|
// a second tap on the underlying 结束 button is harmless — but we
|
||||||
|
// still need to guard the save itself.
|
||||||
if (this._saving) return
|
if (this._saving) return
|
||||||
this._saving = true
|
this._saving = true
|
||||||
|
|
||||||
@@ -283,6 +283,20 @@ Page({
|
|||||||
setTimeout(() => { wx.navigateBack() }, 1500)
|
setTimeout(() => { wx.navigateBack() }, 1500)
|
||||||
return
|
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()
|
const today = storage.getToday()
|
||||||
storage.saveRecord({
|
storage.saveRecord({
|
||||||
date: today,
|
date: today,
|
||||||
@@ -292,11 +306,80 @@ Page({
|
|||||||
})
|
})
|
||||||
storage.updateStreak(today)
|
storage.updateStreak(today)
|
||||||
|
|
||||||
// Flag the just-completed training so the index page can briefly
|
// Flag for the index page's "训练完成" highlight
|
||||||
// highlight the "今日已完成" badge when the user lands back there.
|
|
||||||
try { wx.setStorageSync('_last_train_at', Date.now()) } catch (e) {}
|
try { wx.setStorageSync('_last_train_at', Date.now()) } catch (e) {}
|
||||||
|
|
||||||
wx.showToast({ title: `已记录 ${elapsed}秒`, icon: 'none', duration: 1500 })
|
const finalStreak = storage.getStreak().count
|
||||||
setTimeout(() => { wx.navigateBack() }, 1500)
|
|
||||||
|
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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -113,4 +113,42 @@
|
|||||||
></ui-btn>
|
></ui-btn>
|
||||||
</view>
|
</view>
|
||||||
</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>
|
</view>
|
||||||
|
|||||||
@@ -208,3 +208,188 @@
|
|||||||
0% { opacity: 1; transform: translateX(-50%) scale(1); }
|
0% { opacity: 1; transform: translateX(-50%) scale(1); }
|
||||||
100% { opacity: 0; transform: translateX(-50%) scale(0.8); }
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user