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
|
||||
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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user