feat(timer): 训练时长低于阈值不记录并明确提示

- config.js 新增 minRecordSeconds: 20 阈值配置,改配置即可调整门槛
- timer.js: finishTraining/_onCircuitComplete 低时长不播完成语音;
  _saveAndShowCompletion 兜底判断,任何入口都不会把短时训练落库;
  新增 _showNotRecorded: 不保存记录、不更新连胜,复用完成弹窗展示实际秒数
- timer.wxml/wxss: 完成弹窗新增"未计入记录"提示行(门槛说明),
  未记录时隐藏"查看记录/分享"按钮,只留"再来一次+回到首页"
- 修复状态残留: 未记录后点"再来一次",下次正常完成不会误显示"未计入记录"
This commit is contained in:
2026-08-12 10:20:40 +08:00
parent 178a51d261
commit f1ad3eeb95
5 changed files with 152 additions and 3 deletions
+27
View File
@@ -62,3 +62,30 @@
- `.info-bubble` margin-top 24→40rpx(承接原 timer-status 间距)、padding 20→24rpx。 - `.info-bubble` margin-top 24→40rpx(承接原 timer-status 间距)、padding 20→24rpx。
- 关键机制备忘:**resting 时 status 字段值仍是 'running'**,状态行转绿必须靠 `isResting` 单独加类,不能只看 status。 - 关键机制备忘:**resting 时 status 字段值仍是 'running'**,状态行转绿必须靠 `isResting` 单独加类,不能只看 status。
- 验证:grep 无 `timer-status` 代码残留(仅注释文字),js 未改动。 - 验证:grep 无 `timer-status` 代码残留(仅注释文字),js 未改动。
## 进度弧"0 点对齐"问题:两轮修复被否定,已全部回滚(待用户澄清)
- 用户反馈(两次截图):训练时粗进度弧起点与 0 点/顶部起点差一点没对齐,运动过程中更明显(循环与单组模式都出现过)。
- 尝试过两轮修复(progress-ring.js `_drawSetArcs` 半径/清除区微调),用户判定"完全理解错了",**要求撤销**。
- 已回滚:`git diff` 确认 progress-ring.js 与 HEAD`05c4763` 分段弧初始实现:glowPad=6、arcR=radius+lw/2+3、arcW=8、清除区 clip 内缘 arcR-band)完全一致,`node --check` 通过。
- 教训:对齐类问题不要凭数值推理直接改半径,先让用户明确"0 点"的参照物(刻度环/呼吸环/分段弧?)与出现模式,再定位。
- **最终定位与修复(用户澄清"所有运动中,进度弧零点与表盘 0 点没对齐")**:
- 根因确凿:`_drawSetArcs` 每段起点 `+gap/2`(1.25°),**外圈分段弧第一段从 12 点偏右 1.25° 开始**(半径 ~207dpr 处 ≈ 4.5dpr ≈ 1.5px),而内圈进度弧/12 点长刻度都从精确 -π/2 开始 → 循环模式外圈与内圈 0 点肉眼错开。
- 修复:`a0 = startAngle + (i-1)*segAngle`(第一段精确 -π/2 起),`a1 = a0 + segAngle - gap`(段尾留 gap)。段间空隙均匀(2.5°×3),收尾空隙落在 12 点左侧,环带以 12 点为起点。node 验算:段起点 270°/30°/150°,空隙 2.5° 均匀,首段 12 点 ✓。
- 单组/自由模式无分段弧,canvas 内刻度/进度弧同基准(-π/2),数学对齐;若用户仍反馈错位需再查(可能涉及 canvas 布局/参照物)。
- 注意:`gap/2` 对称偏移是"段内居中空隙"的常见写法,环形进度环带与刻度基准对齐时必须从 -π/2 起算。
- **用户叫停("算了,这个问题先不修复了")**:零点对齐问题整体搁置。该轮 progress-ring.js 的角度修复(仅影响循环模式、覆盖不了单组模式现象)已 `git checkout` 回退,与 HEAD 一致。等用户想继续时再排查,方向可能是 canvas 布局/参照物而非半径。
## 新功能:训练时长低于阈值不记录(config.minRecordSeconds,默认 20 秒)
- 需求:训练有效时长 < 20 秒(可配置)不写入记录,并给用户明确提示;阈值收进 config.js。
- **`config.js`**:新增 `minRecordSeconds: 20`(带注释:不写记录、不更新连胜;循环按有效撑持秒数=各组时长之和判断)。
- **`pages/timer/timer.js`**
- `finishTraining()``elapsed >= minSec` 才播完成语音(低于阈值不庆祝)。
- `_onCircuitComplete()`:循环自然完成路径同样按 `workTotal >= minSec` 播语音。
- `_saveAndShowCompletion(elapsed)` 开头加**阈值兜底**`elapsed < minSec``_showNotRecorded(elapsed, minSec)` 直接 return。手动结束与循环自然完成两条路径都汇聚到这里,任何入口都不会把短时训练落库。
- 新增 `_showNotRecorded(elapsed, minSec)`:复用完成弹窗(避免系统原生 modal 在 WebView stacking context 下 hit-test 不稳),不保存记录/连胜、无 confetti/streak/科学提示,大数字照常 countUp 到 elapsed;置 `completionNotRecorded: true``minRecordSeconds: minSec`、标题"未达最低记录时长"。
- data 新增初始字段 `completionNotRecorded: false``minRecordSeconds: 20`
- **状态残留坑(已修)**`onTrainAgain`(再来一次)与 `_saveAndShowCompletion` 正常分支都必须显式 `completionNotRecorded: false`,否则上轮未记录后重练,下次正常完成弹窗会误显示"未计入记录"且隐藏分享/记录按钮。
- **`timer.wxml`**:完成弹窗新增 `.completion-not-recorded` 提示块(infoFill 图标 + "本次训练未计入记录" + 副行"撑满 X 秒以上才会记录并计入连续打卡");未记录态 `wx:if="{{!completionNotRecorded}}"` 隐藏"查看记录/分享"(没有成绩可看/可晒),仅留"再来一次 + 回到首页"。
- **`timer.wxss`**:新增 `.completion-not-recorded` 主色淡底信息块(与 completion-tip 同风格,tipIn 延迟 0.5s 入场)。
- 保留逻辑:`elapsed < 3` 秒仍走 toast「训练时间太短」+ navigateBack(几乎未开始,不弹完成弹窗)。
- 验证:node --check 通过;grep 确认 minRecordSeconds/completionNotRecorded/_showNotRecorded 三处字段在 config/js/wxml 引用一致。
+9
View File
@@ -67,6 +67,15 @@ module.exports = {
*/ */
caloriesPerSecond: 0.068, caloriesPerSecond: 0.068,
/**
* 最短记录时长(秒)。本次训练有效时长低于该值时:
* - 不写入训练记录、不更新连续打卡天数(避免"撑几秒也算一天"的无效记录)
* - 完成弹窗会明确提示"本次未计入记录",并隐藏查看记录/分享入口
* 循环训练按"有效撑持秒数"(各组时长之和,不含休息)判断。
* 改这里即可调整门槛,无需动逻辑代码。
*/
minRecordSeconds: 15,
/** /**
* 完成弹窗科学提示(按本次训练时长分段)。 * 完成弹窗科学提示(按本次训练时长分段)。
* 完成时按实际撑的秒数命中第一段 maxSeconds 大于它的配置, * 完成时按实际撑的秒数命中第一段 maxSeconds 大于它的配置,
+64 -2
View File
@@ -40,6 +40,10 @@ Page({
// 组进度改由 progress-ring 外圈分段弧呈现,仅需传 sets/currentSet/resting, // 组进度改由 progress-ring 外圈分段弧呈现,仅需传 sets/currentSet/resting,
// 页面不再维护点阵渲染数据(circuitDots/dotsDone 等已随旧面板一并删除)。 // 页面不再维护点阵渲染数据(circuitDots/dotsDone 等已随旧面板一并删除)。
celebrationLevel: 'normal', // normal / first / milestone / record celebrationLevel: 'normal', // normal / first / milestone / record
// 训练时长低于 config.minRecordSeconds 时置 true:完成弹窗提示"未计入记录",
// 隐藏查看记录/分享入口;弹窗内展示门槛值给用户明确反馈
completionNotRecorded: false,
minRecordSeconds: 20,
celebrationMessage: '', celebrationMessage: '',
confettiPieces: [], confettiPieces: [],
// Completion modal (full-screen summary after training ends) // Completion modal (full-screen summary after training ends)
@@ -435,7 +439,9 @@ Page({
_onCircuitComplete() { _onCircuitComplete() {
this._clearVibrateTimers() this._clearVibrateTimers()
const s = storage.getSettings() const s = storage.getSettings()
if (s.voiceGuide !== false) voice.play('complete') // 有效撑持秒数未达最低记录时长时不播完成语音(弹窗走"未计入记录"分支)
const minSec = config.minRecordSeconds || 20
if (this.data.workTotal >= minSec && s.voiceGuide !== false) voice.play('complete')
this._saveAndShowCompletion(this.data.workTotal) this._saveAndShowCompletion(this.data.workTotal)
}, },
@@ -725,8 +731,10 @@ Page({
return return
} }
// Completion voice (replaces the old auto-onComplete cue). No vibration. // Completion voice (replaces the old auto-onComplete cue). No vibration.
// 未达最低记录时长时不播完成语音——弹窗会走"未计入记录"分支,不庆祝。
const s = storage.getSettings() const s = storage.getSettings()
if (s.voiceGuide !== false) { const minSec = config.minRecordSeconds || 20
if (elapsed >= minSec && s.voiceGuide !== false) {
voice.play('complete') voice.play('complete')
} }
this._saveAndShowCompletion(elapsed) this._saveAndShowCompletion(elapsed)
@@ -759,6 +767,15 @@ Page({
// Re-entry guard: protects the finishTraining path so the save never // Re-entry guard: protects the finishTraining path so the save never
// runs twice even if the user double-taps end. // runs twice even if the user double-taps end.
if (this._saving) return if (this._saving) return
// 未达 config.minRecordSeconds:不写入记录/连胜,弹"未计入记录"提示。
// 兜底判断——手动结束(finishTraining)与循环自然完成(_onCircuitComplete)
// 两条路径最终都汇聚到这里,保证任何入口都不会把短时训练落库。
const minSec = config.minRecordSeconds || 20
if (elapsed < minSec) {
this._showNotRecorded(elapsed, minSec)
return
}
this._saving = true this._saving = true
const { level, message } = this._detectCelebrationLevel(elapsed) const { level, message } = this._detectCelebrationLevel(elapsed)
@@ -797,6 +814,9 @@ Page({
// Background effects keep playing behind the modal // Background effects keep playing behind the modal
status: 'completed', status: 'completed',
isCompleted: true, isCompleted: true,
// 重置未记录标记(上一轮若走了 _showNotRecorded,这里必须回 false,
// 否则下次正常完成的弹窗会误显示"未计入记录"且隐藏分享/记录按钮)
completionNotRecorded: false,
celebrationLevel: level, celebrationLevel: level,
celebrationMessage: message, celebrationMessage: message,
confettiPieces, confettiPieces,
@@ -827,6 +847,46 @@ Page({
}) })
}, },
/**
* 训练时长低于 config.minRecordSeconds:不保存记录、不更新连胜,
* 复用完成弹窗(避免系统原生 modal 在 WebView stacking context 下
* hit-test 不稳),弹窗内明确提示"本次未计入记录"。
* 仍显示本次实际撑持秒数(大数字 countUp),但不放 confetti/连胜/科学提示,
* 并隐藏"查看记录/分享"入口(没有记录可看)。
*/
_showNotRecorded(elapsed, minSec) {
if (this._saving) return
this._saving = true
this.setData({
status: 'completed',
isCompleted: true,
completionNotRecorded: true,
minRecordSeconds: minSec,
showCompletion: true,
completionElapsed: 0,
completionActualElapsed: elapsed,
completionTarget: this.data.duration,
completionOvertime: 0,
completionStreak: 0,
completionLevel: 'normal',
completionMessage: '未达最低记录时长',
completionTipValue: '',
completionTipRisk: '',
confettiPieces: []
})
// Animate the big number from 0 to elapsed (same as the normal modal)
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() { onCloseCompletion() {
if (this._completionCountUp) { if (this._completionCountUp) {
this._completionCountUp.cancel() this._completionCountUp.cancel()
@@ -882,6 +942,8 @@ Page({
status: 'idle', status: 'idle',
isRunning: false, isRunning: false,
isPaused: false, isPaused: false,
// 复位未记录标记:若上一轮走了 _showNotRecorded,重练前必须回 false
completionNotRecorded: false,
remaining: this.data.duration, remaining: this.data.duration,
elapsed: 0, elapsed: 0,
goalReached: false, goalReached: false,
+12 -1
View File
@@ -172,6 +172,15 @@
<text class="streak-text">连续打卡 {{completionStreak}} 天</text> <text class="streak-text">连续打卡 {{completionStreak}} 天</text>
</view> </view>
<!-- 未达最低记录时长:明确提示本次未计入记录,并说明门槛 -->
<view class="completion-not-recorded" wx:if="{{completionNotRecorded}}">
<view class="completion-not-recorded-main">
<image class="completion-not-recorded-icon" src="{{icons.infoFill}}" mode="aspectFit"></image>
<text class="completion-not-recorded-title">本次训练未计入记录</text>
</view>
<text class="completion-not-recorded-sub">撑满 {{minRecordSeconds}} 秒以上才会记录并计入连续打卡</text>
</view>
<!-- 科学提示:价值行 + 弱化风险行(config.scienceTips 按时长选段) --> <!-- 科学提示:价值行 + 弱化风险行(config.scienceTips 按时长选段) -->
<view class="completion-tip" wx:if="{{completionTipValue}}"> <view class="completion-tip" wx:if="{{completionTipValue}}">
<text class="completion-tip-value">{{completionTipValue}}</text> <text class="completion-tip-value">{{completionTipValue}}</text>
@@ -185,13 +194,15 @@
<view class="completion-btn completion-btn--primary" bindtap="onTrainAgain"> <view class="completion-btn completion-btn--primary" bindtap="onTrainAgain">
<text>再来一次</text> <text>再来一次</text>
</view> </view>
<view class="completion-btn" bindtap="onViewRecords"> <!-- 未记录时没有成绩可看/可晒,隐藏查看记录与分享入口 -->
<view class="completion-btn" wx:if="{{!completionNotRecorded}}" bindtap="onViewRecords">
<text>查看记录</text> <text>查看记录</text>
</view> </view>
<!-- openType="share" 会触发 Page.onShareAppMessage(已实现), <!-- openType="share" 会触发 Page.onShareAppMessage(已实现),
没有这个属性 + onShareAppMessage 时分享按钮就是灰色的。 没有这个属性 + onShareAppMessage 时分享按钮就是灰色的。
ui-btn 透传 openType 到内部原生 button。 --> ui-btn 透传 openType 到内部原生 button。 -->
<ui-btn <ui-btn
wx:if="{{!completionNotRecorded}}"
variant="ghost" variant="ghost"
size="md" size="md"
customStyle="flex:1; height:88rpx;" customStyle="flex:1; height:88rpx;"
+40
View File
@@ -425,6 +425,46 @@
height: 32rpx; height: 32rpx;
} }
/* 未达最低记录时长提示(完成弹窗内):主色淡底信息块,与科学提示同风格 */
.completion-not-recorded {
display: flex;
flex-direction: column;
gap: 10rpx;
width: 100%;
box-sizing: border-box;
background: rgba(var(--primary-rgb), 0.08);
border-radius: 20rpx;
padding: 20rpx 24rpx;
margin-bottom: 28rpx;
animation: tipIn 0.4s ease 0.5s both;
}
.completion-not-recorded-main {
display: flex;
align-items: center;
gap: 10rpx;
}
.completion-not-recorded-icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
}
.completion-not-recorded-title {
flex: 1;
font-size: 26rpx;
font-weight: 600;
line-height: 1.4;
color: var(--primary);
}
.completion-not-recorded-sub {
font-size: 24rpx;
line-height: 1.5;
color: var(--text-secondary);
}
.streak-text { .streak-text {
font-weight: 500; font-weight: 500;
} }