feat: 循环训练模式 + 语音补全 / 屏幕常亮 / 卡片渲染修复
循环训练(circuit)
- 新增 utils/circuitTimer.js 状态机: 工作->休息->...->完成, stop() 返回有效撑总秒(不含休息)
- 首页新增「循环训练·分组练习」入口 + stepper 配置弹窗
- 配置本地持久化 circuit_config(每组时长/组数/休息/每周目标), 下次自动预填
- 记录以 planId:'circuit' + mode:'circuit' 落库, duration 语义不变 -> 排行榜/统计/每日计划进度零改动
- 记录页展示「循环 N组xMs·休Rs」
计时器顶部循环进度面板
- 组进度点阵按组数等分铺满(flex:1 1 0, 去掉 max-width 封顶), 当前组以增高而非加宽强调
- 大号「X/Y 组」+ 四态阶段徽章(撑住/休息中/准备开始/已暂停) + 累计秒数副行
- 状态灯用纯 CSS 圆点(SVG 图标颜色烤死在 data URI 里, CSS 改不动)
- 超过 15 组自动降级为线性进度条; 面板不设固定高度, 规避真机大字体档裁字
修复: 循环训练全程无语音
- 根因: circuit 的 onTick 走 _circuitCue(只有震动), 从未调用 _remind
- 接入整场进度播报, 基准为 sets x holdPerSet(而非每组), 避免 4 组播 4 次「已完成一半」
- 新增 restStart/nextSet/lastSet 三条不含数字的词条, 客户端与 tts 云函数词表同步
- 修复必然撞车的时序: 偶数组时 halfway 与休息切换同一 tick, 加 2.6s 优先窗口让位
- 防吵闸门: 每组 <10s 不播过场语音, 休息 <5s 不播; 预载按模式取词条
训练期间屏幕常亮
- wx.setKeepScreenOn: onStart 开 / onUnload 关 / onShow 在 isRunning 时重新武装
- 切后台再回来该标志会被系统清掉, 故必须重新武装, 否则后半程丢语音与震动
- 低基础库与 Android 省电模式静默降级, 不弹 toast
修复: 首页打卡卡片左侧两个竖条
- 根因: ui-card 宿主节点无 display 声明为 inline, 页面侧加的 border 被打断成两个零宽行盒碎片
- ui-card 加 :host{display:block}, margin-bottom 从内部 view 上提到宿主
- 高亮由 border 改为 box-shadow spread 描边(不占布局, 无跳动) + 补 border-radius
- 连带修复失效的交错入场: nth-child 跨组件边界恒匹配 1, 改为 index 属性驱动内联 animation-delay, 14 处调用点补序号
注意: tts 云函数需重新部署, 否则新增的 3 条词条返回 Unknown prompt key(静默降级不报错)
This commit is contained in:
+344
-6
@@ -1,4 +1,5 @@
|
||||
const Timer = require('../../utils/timer')
|
||||
const CircuitTimer = require('../../utils/circuitTimer')
|
||||
const storage = require('../../utils/storage')
|
||||
const planMod = require('../../utils/plan')
|
||||
const themeMod = require('../../utils/theme')
|
||||
@@ -22,6 +23,27 @@ Page({
|
||||
todayTarget: 0,
|
||||
planDay: 1,
|
||||
isFreeMode: false,
|
||||
// --- Circuit (循环训练) mode fields ---
|
||||
isCircuitMode: false,
|
||||
circuitSets: 4,
|
||||
circuitHold: 30,
|
||||
circuitRest: 15,
|
||||
circuitSessions: 3,
|
||||
currentSet: 0,
|
||||
totalSets: 0,
|
||||
phase: 'idle', // idle | working | resting | done
|
||||
phaseRemaining: 0,
|
||||
phaseElapsed: 0,
|
||||
workTotal: 0, // effective held seconds (excludes rest)
|
||||
isResting: false,
|
||||
phaseTip: '', // 阶段切换提示文本(如"最后一组!"/"休息 15 秒")
|
||||
// 顶部循环进度面板:组进度胶囊点阵
|
||||
circuitDots: [], // [1..sets],仅用于 wx:for 渲染,组数过多时不渲染
|
||||
circuitShowDots: true, // 组数 > DOTS_MAX 时退化为线性进度条
|
||||
circuitTotalWork: 0, // sets × holdPerSet,面板里作为"目标秒数"展示
|
||||
dotsDone: 0, // 已完成组数
|
||||
dotsActive: 0, // 正在进行的组序号(休息时为 0)
|
||||
dotsNext: 0, // 休息时即将开始的组序号(工作时为 0)
|
||||
celebrationLevel: 'normal', // normal / first / milestone / record
|
||||
celebrationMessage: '',
|
||||
confettiPieces: [],
|
||||
@@ -40,9 +62,13 @@ Page({
|
||||
onLoad(options) {
|
||||
themeMod.applyThemeToPage(this)
|
||||
this._init(options)
|
||||
// 预载语音到本地缓存,训练开始时即可即时播放(仅语音引导开启时)
|
||||
// 预载语音到本地缓存,训练开始时即可即时播放(仅语音引导开启时)。
|
||||
// 按模式取词条:循环模式要 restStart/nextSet/lastSet,单组模式要 goal,
|
||||
// 各自不载对方的,省掉无谓的云函数调用与下载。
|
||||
const s = storage.getSettings()
|
||||
if (s.voiceGuide !== false) voice.preload()
|
||||
if (s.voiceGuide !== false) {
|
||||
voice.preload(this.data.isCircuitMode ? voice.CIRCUIT_KEYS : voice.HOLD_KEYS)
|
||||
}
|
||||
// countUp: 数字从 0 滚到目标秒数
|
||||
const target = this.data.duration
|
||||
if (this._countUpTask) this._countUpTask.cancel()
|
||||
@@ -59,9 +85,21 @@ Page({
|
||||
themeMod.applyThemeToPage(this)
|
||||
const theme = themeMod.getCurrentTheme()
|
||||
this.setData({ theme, icons: iconsMod.build(theme) })
|
||||
// Coming back from the background (WeChat chat, phone call, home screen)
|
||||
// clears the app-wide keep-awake flag, so re-arm it if a session is still
|
||||
// in flight — otherwise the second half of the training silently loses
|
||||
// every voice/haptic cue again.
|
||||
if (this.data.isRunning) this._keepScreenOn(true)
|
||||
},
|
||||
|
||||
_init(options) {
|
||||
this._initOptions = options || {}
|
||||
// Circuit (循环训练) mode: build the work/rest FSM and bail out of the
|
||||
// single-hold init below. All controls / completion modal / save path are shared.
|
||||
if (this._initOptions.circuit) {
|
||||
this._initCircuit(this._initOptions)
|
||||
return
|
||||
}
|
||||
let target
|
||||
let planDay = 1
|
||||
let isFreeMode = false
|
||||
@@ -125,6 +163,250 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize a circuit (循环) training session. Reads the persisted config
|
||||
* (utils/storage.getCircuitConfig) but lets URL params override so the home
|
||||
* page can jump straight in with the last-saved settings.
|
||||
*/
|
||||
_initCircuit(options) {
|
||||
if (this._timer) {
|
||||
this._timer.stop()
|
||||
this._timer = null
|
||||
}
|
||||
// Reset per-session state (mirrors the single-hold path)
|
||||
this._halfwaySignaled = false
|
||||
this._last30Signaled = false
|
||||
this._last10Signaled = false
|
||||
this._lastMinuteAt = 0
|
||||
this._lastCountdown = 0
|
||||
this._saving = false
|
||||
this._lastTipIndex = -1
|
||||
this._voiceBusyUntil = 0
|
||||
this._clearVibrateTimers()
|
||||
|
||||
const cfg = storage.getCircuitConfig()
|
||||
const hold = this._int(options.hold, cfg.holdPerSet, 5, 600)
|
||||
const sets = this._int(options.sets, cfg.sets, 1, 50)
|
||||
const rest = this._int(options.rest, cfg.restPerSet, 0, 600)
|
||||
this._circuitCfg = {
|
||||
holdPerSet: hold,
|
||||
sets,
|
||||
restPerSet: rest,
|
||||
sessionsPerWeek: cfg.sessionsPerWeek
|
||||
}
|
||||
|
||||
this._timer = new CircuitTimer({
|
||||
holdPerSet: hold,
|
||||
sets,
|
||||
restPerSet: rest,
|
||||
onTick: (t) => {
|
||||
// 组进度点阵三态。休息阶段 CircuitTimer 的 setIndex 仍指向"刚做完
|
||||
// 的那一组",所以 resting 时 setIndex 组算已完成、下一组标为 next。
|
||||
const resting = t.phase === 'resting'
|
||||
this.setData({
|
||||
currentSet: t.setIndex,
|
||||
totalSets: t.sets,
|
||||
phase: t.phase,
|
||||
phaseRemaining: t.phaseRemaining,
|
||||
phaseElapsed: t.phaseElapsed,
|
||||
workTotal: t.workTotal,
|
||||
isResting: resting,
|
||||
dotsDone: resting ? t.setIndex : Math.max(0, t.setIndex - 1),
|
||||
dotsActive: resting ? 0 : t.setIndex,
|
||||
dotsNext: resting ? t.setIndex + 1 : 0,
|
||||
// Reuse the single-hold display fields so progress-ring / completion
|
||||
// number keep working: the ring shows the CURRENT set's countdown.
|
||||
remaining: t.phaseRemaining,
|
||||
elapsed: t.workTotal,
|
||||
duration: t.phase === 'working' ? hold : rest
|
||||
})
|
||||
this._circuitCue(t)
|
||||
},
|
||||
onPhaseChange: (p) => this._circuitPhase(p),
|
||||
onComplete: () => this._onCircuitComplete()
|
||||
})
|
||||
|
||||
// 点阵超过 DOTS_MAX 个就挤成头发丝了,退化成一条线性进度条。
|
||||
const DOTS_MAX = 15
|
||||
const dots = []
|
||||
if (sets <= DOTS_MAX) {
|
||||
for (let i = 1; i <= sets; i++) dots.push(i)
|
||||
}
|
||||
|
||||
this.setData({
|
||||
isCircuitMode: true,
|
||||
isFreeMode: false,
|
||||
planDay: 0,
|
||||
circuitSets: sets,
|
||||
circuitHold: hold,
|
||||
circuitRest: rest,
|
||||
circuitSessions: this._circuitCfg.sessionsPerWeek,
|
||||
circuitDots: dots,
|
||||
circuitShowDots: sets <= DOTS_MAX,
|
||||
circuitTotalWork: sets * hold,
|
||||
dotsDone: 0,
|
||||
dotsActive: 0,
|
||||
dotsNext: 0,
|
||||
currentSet: 0,
|
||||
totalSets: sets,
|
||||
phase: 'idle',
|
||||
phaseRemaining: hold,
|
||||
phaseElapsed: 0,
|
||||
workTotal: 0,
|
||||
isResting: false,
|
||||
phaseTip: '',
|
||||
duration: hold,
|
||||
remaining: hold,
|
||||
todayTarget: hold,
|
||||
goalReached: false,
|
||||
elapsed: 0,
|
||||
status: 'idle',
|
||||
isRunning: false,
|
||||
isPaused: false,
|
||||
isCompleted: false,
|
||||
showCompletion: false
|
||||
})
|
||||
},
|
||||
|
||||
_int(v, fallback, min, max) {
|
||||
const n = parseInt(v)
|
||||
if (!Number.isFinite(n)) return fallback
|
||||
return Math.max(min, Math.min(max, n))
|
||||
},
|
||||
|
||||
/**
|
||||
* Fire a haptic buzz, tracked so pause/stop/unload can cancel pending ones.
|
||||
* Mirrors the vibrate helper inside _remind().
|
||||
*/
|
||||
_vibrateOnce(n, ms) {
|
||||
const s = storage.getSettings()
|
||||
if (s.vibrate === false) return
|
||||
const intensity = s.vibrateIntensity || 'heavy'
|
||||
const doVibrate = () => {
|
||||
const fail = (err) => console.warn('[vibrate] failed:', err.errMsg || err)
|
||||
if (intensity === 'light') wx.vibrateShort({ type: 'light', fail })
|
||||
else if (intensity === 'medium') wx.vibrateShort({ type: 'medium', fail })
|
||||
else wx.vibrateLong({ fail })
|
||||
}
|
||||
try {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const id = setTimeout(() => {
|
||||
doVibrate()
|
||||
const idx = this._vibrateTimers.indexOf(id)
|
||||
if (idx > -1) this._vibrateTimers.splice(idx, 1)
|
||||
}, i * (ms + 30))
|
||||
this._vibrateTimers.push(id)
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
/**
|
||||
* Per-tick circuit cues. Mirrors _remind() for the single-hold mode, but the
|
||||
* yardstick is the WHOLE session's effective held time (sets × holdPerSet),
|
||||
* NOT the current set — otherwise a 4-set circuit would announce "已完成一半啦"
|
||||
* four times (once per set), which is both noisy and semantically wrong.
|
||||
*
|
||||
* Only evaluated while `working`: workTotal is frozen during rest, so the
|
||||
* thresholds can never re-fire mid-break.
|
||||
*/
|
||||
_circuitCue(t) {
|
||||
if (t.phase !== 'working') return
|
||||
|
||||
const total = this._circuitCfg.sets * this._circuitCfg.holdPerSet
|
||||
const done = t.workTotal
|
||||
const left = total - done
|
||||
const s = storage.getSettings()
|
||||
const voiceOn = s.voiceGuide !== false
|
||||
|
||||
// Same duration gates as _remind(), so short circuits (e.g. 3 sets × 5s)
|
||||
// don't fire "最后30秒" on the very first tick. `<=` rather than `===`
|
||||
// survives a skipped second when the app returns from background.
|
||||
if (total >= 10 && !this._halfwaySignaled && done >= Math.floor(total / 2)) {
|
||||
this._halfwaySignaled = true
|
||||
if (voiceOn) this._playProgressVoice('halfway')
|
||||
this._vibrateOnce(2, 150)
|
||||
}
|
||||
if (total > 60 && left <= 30 && !this._last30Signaled) {
|
||||
this._last30Signaled = true
|
||||
if (voiceOn) this._playProgressVoice('last30')
|
||||
}
|
||||
if (total > 20 && left <= 10 && !this._last10Signaled) {
|
||||
this._last10Signaled = true
|
||||
if (voiceOn) this._playProgressVoice('last10')
|
||||
}
|
||||
|
||||
// Countdown buzz over the last 5s of the FINAL set (the real finish line).
|
||||
if (t.setIndex >= this._circuitCfg.sets && t.phaseRemaining <= 5 && t.phaseRemaining > 0 && t.phaseRemaining !== this._lastCountdown) {
|
||||
this._lastCountdown = t.phaseRemaining
|
||||
this._vibrateOnce(1, 100)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Play a whole-session progress prompt (halfway / last30 / last10) and claim
|
||||
* a short priority window afterwards.
|
||||
*
|
||||
* Why: the halfway mark is `sets × hold / 2`, which for an EVEN set count
|
||||
* lands exactly on the final second of set sets/2 — i.e. the same tick as the
|
||||
* rest transition. voice.play() drops any in-flight clip when a new one
|
||||
* starts, so without this the phase line would cut "已完成一半啦" off after
|
||||
* one syllable. Progress prompts win; the phase line is the expendable one.
|
||||
*/
|
||||
_playProgressVoice(key) {
|
||||
this._voiceBusyUntil = Date.now() + 2600 // ~ one spoken line
|
||||
voice.play(key)
|
||||
},
|
||||
|
||||
/**
|
||||
* Fired once on each work/rest transition: haptic + on-screen phaseTip +
|
||||
* a voice line (restStart / nextSet / lastSet — number-free, so 3 fixed
|
||||
* clips cover any set count).
|
||||
*
|
||||
* `chatty` gate: a spoken line takes ~2s, so on short sets (<10s each) the
|
||||
* prompts would overlap into a stream of chatter and step on the training
|
||||
* rhythm. Below that we keep only the "last set" cue (the one moment worth
|
||||
* interrupting for) and let haptics + text carry the rest.
|
||||
*/
|
||||
_circuitPhase(p) {
|
||||
const s = storage.getSettings()
|
||||
const chatty = this._circuitCfg.holdPerSet >= 10
|
||||
// Yield the mic if a progress prompt is still speaking (see _playProgressVoice).
|
||||
const voiceOn = s.voiceGuide !== false && Date.now() >= (this._voiceBusyUntil || 0)
|
||||
|
||||
if (p.phase === 'working') {
|
||||
if (p.setIndex === 1) {
|
||||
// No voice here: onStart already plays 'start' at the same instant.
|
||||
this.setData({ phaseTip: '开始第一组' })
|
||||
} else if (p.isLastSet) {
|
||||
this.setData({ phaseTip: '最后一组,冲刺!' })
|
||||
if (voiceOn) voice.play('lastSet')
|
||||
this._vibrateOnce(2, 150)
|
||||
} else {
|
||||
this.setData({ phaseTip: '第 ' + p.setIndex + ' 组' })
|
||||
if (voiceOn && chatty) voice.play('nextSet')
|
||||
this._vibrateOnce(1, 150)
|
||||
}
|
||||
} else if (p.phase === 'resting') {
|
||||
this.setData({ phaseTip: '休息 ' + this._circuitCfg.restPerSet + ' 秒' })
|
||||
// Skip on very short breaks — the clip would still be talking when the
|
||||
// next set starts, colliding with nextSet/lastSet.
|
||||
if (voiceOn && chatty && this._circuitCfg.restPerSet >= 5) voice.play('restStart')
|
||||
this._vibrateOnce(1, 100)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* All sets completed naturally. workTotal already equals sets*holdPerSet.
|
||||
* Reuse the shared completion/save path so celebration + streak + record
|
||||
* all behave identically to a normal session.
|
||||
*/
|
||||
_onCircuitComplete() {
|
||||
this._clearVibrateTimers()
|
||||
const s = storage.getSettings()
|
||||
if (s.voiceGuide !== false) voice.play('complete')
|
||||
this._saveAndShowCompletion(this.data.workTotal)
|
||||
},
|
||||
|
||||
/**
|
||||
* Rotate the posture cue in the hint area every config.tipInterval seconds.
|
||||
* Only runs while still under the target (after goalReached the hint
|
||||
@@ -233,6 +515,31 @@ Page({
|
||||
this._vibrateTimers = []
|
||||
},
|
||||
|
||||
/**
|
||||
* Keep the screen awake while training.
|
||||
*
|
||||
* Why this matters here: during a plank the user's hands are on the floor —
|
||||
* nobody taps the screen, so the system's idle timer (usually 60s) fires
|
||||
* mid-session. Once the screen locks, the mini program is backgrounded and
|
||||
* JS timers get throttled/suspended: the elapsed count still recovers
|
||||
* (Timer/CircuitTimer reconcile against Date.now on resume), but every
|
||||
* real-time cue in between — halfway / last30 / last10 voice, set-change
|
||||
* and rest prompts, haptics — is simply lost.
|
||||
*
|
||||
* Scope caveat: wx.setKeepScreenOn is APP-wide, not page-scoped, and only
|
||||
* resets when the user exits the mini program. So onUnload MUST turn it
|
||||
* back off, otherwise the screen stays lit while they browse records or
|
||||
* the leaderboard afterwards.
|
||||
*
|
||||
* fail is swallowed on purpose: some Android ROMs ignore this under battery
|
||||
* saver, and a "failed to keep screen on" toast would only confuse the user
|
||||
* — the training itself is unaffected. Supported since base library 1.4.0.
|
||||
*/
|
||||
_keepScreenOn(on) {
|
||||
if (typeof wx.setKeepScreenOn !== 'function') return
|
||||
wx.setKeepScreenOn({ keepScreenOn: !!on, fail() {} })
|
||||
},
|
||||
|
||||
/**
|
||||
* Detect which celebration level to show. Called from _saveAndShowCompletion
|
||||
* (runs in finishTraining, BEFORE saveRecord), so stats reflect pre-save state.
|
||||
@@ -290,6 +597,9 @@ Page({
|
||||
onUnload() {
|
||||
voice.stop()
|
||||
voice.destroy()
|
||||
// Mandatory: the flag is app-wide, so leaving it on would keep the screen
|
||||
// lit across every other page until the user quits the mini program.
|
||||
this._keepScreenOn(false)
|
||||
this._clearVibrateTimers()
|
||||
if (this._timer) {
|
||||
this._timer.stop()
|
||||
@@ -308,10 +618,18 @@ Page({
|
||||
onStart() {
|
||||
if (this.data.isPaused) {
|
||||
this._timer.resume()
|
||||
// Re-assert: if the user backgrounded the app while paused, WeChat has
|
||||
// already cleared the flag for us.
|
||||
this._keepScreenOn(true)
|
||||
this.setData({ isRunning: true, isPaused: false, status: 'running' })
|
||||
return
|
||||
}
|
||||
if (this.data.isRunning) return
|
||||
// Screen stays awake from here until onUnload. Deliberately NOT turned
|
||||
// off on pause (users pause to adjust posture — a screen blackout mid-set
|
||||
// is worse than a few seconds of extra backlight) nor on the completion
|
||||
// modal (they're reading their result).
|
||||
this._keepScreenOn(true)
|
||||
// 取消可能还在滚的 countUp,确保 remaining = duration
|
||||
if (this._countUpTask) {
|
||||
this._countUpTask.cancel()
|
||||
@@ -400,12 +718,23 @@ Page({
|
||||
|
||||
// Save record + streak
|
||||
const today = storage.getToday()
|
||||
storage.saveRecord({
|
||||
const record = {
|
||||
date: today,
|
||||
duration: elapsed,
|
||||
planId: storage.getSettings().planId,
|
||||
// Circuit records use a special planId so getPlanDay() never counts
|
||||
// them toward the daily-plan progress; leaderboard/stats aggregate by
|
||||
// `duration`, so a circuit's effective held seconds count normally.
|
||||
planId: this.data.isCircuitMode ? 'circuit' : storage.getSettings().planId,
|
||||
day: this.data.planDay
|
||||
})
|
||||
}
|
||||
if (this.data.isCircuitMode && this._circuitCfg) {
|
||||
record.mode = 'circuit'
|
||||
record.sets = this._circuitCfg.sets
|
||||
record.holdPerSet = this._circuitCfg.holdPerSet
|
||||
record.restPerSet = this._circuitCfg.restPerSet
|
||||
record.sessionsPerWeek = this._circuitCfg.sessionsPerWeek
|
||||
}
|
||||
storage.saveRecord(record)
|
||||
storage.updateStreak(today)
|
||||
|
||||
// Flag for the index page's "训练完成" highlight
|
||||
@@ -427,7 +756,9 @@ Page({
|
||||
completionElapsed: 0,
|
||||
completionActualElapsed: elapsed,
|
||||
completionTarget: this.data.duration,
|
||||
completionOvertime: Math.max(0, elapsed - this.data.duration),
|
||||
// Circuit mode has no "overtime" concept (each set is a fixed target)
|
||||
// and `duration` here is just the last set's length, so hide it.
|
||||
completionOvertime: this.data.isCircuitMode ? 0 : Math.max(0, elapsed - this.data.duration),
|
||||
completionStreak: finalStreak,
|
||||
completionLevel: level,
|
||||
completionMessage: message
|
||||
@@ -475,6 +806,13 @@ Page({
|
||||
this._completionCountUp.cancel()
|
||||
this._completionCountUp = null
|
||||
}
|
||||
// Circuit mode: rebuild the FSM (start() only resets from idle, and a
|
||||
// finished/paused timer is no longer idle, so a fresh init is the clean
|
||||
// reset; _initCircuit also closes the completion modal).
|
||||
if (this.data.isCircuitMode) {
|
||||
this._initCircuit(this._initOptions)
|
||||
return
|
||||
}
|
||||
// Reset per-session state so the next run fires reminders/saves
|
||||
// correctly (previously these stayed set, muting cues on the 2nd run).
|
||||
this._saving = false
|
||||
|
||||
+41
-3
@@ -15,6 +15,43 @@
|
||||
<text class="achievement-text">{{celebrationMessage}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 循环训练进度面板:组点阵 + 第 X/Y 组 + 阶段徽章 + 累计秒数。
|
||||
层次分工:这里管「第几组」(宏观),中间的进度环管「本组还剩几秒」(微观)。 -->
|
||||
<view
|
||||
class="circuit-bar {{isResting ? 'is-rest' : ''}} {{status === 'paused' ? 'is-paused' : ''}}"
|
||||
wx:if="{{isCircuitMode && status !== 'completed'}}"
|
||||
>
|
||||
<!-- 组进度点阵:已完成 / 进行中 / 下一组 三态 -->
|
||||
<view class="circuit-dots" wx:if="{{circuitShowDots}}">
|
||||
<view
|
||||
wx:for="{{circuitDots}}"
|
||||
wx:key="*this"
|
||||
class="circuit-dot {{item <= dotsDone ? 'is-done' : ''}} {{item === dotsActive ? 'is-active' : ''}} {{item === dotsNext ? 'is-next' : ''}}"
|
||||
></view>
|
||||
</view>
|
||||
<!-- 组数过多(>15)时退化为线性进度条,避免点阵挤成头发丝 -->
|
||||
<view class="circuit-track" wx:else>
|
||||
<view class="circuit-track-fill" style="width: {{dotsDone * 100 / totalSets}}%"></view>
|
||||
</view>
|
||||
|
||||
<view class="circuit-meta">
|
||||
<view class="circuit-count">
|
||||
<text class="circuit-count-num">{{status === 'idle' ? totalSets : currentSet}}</text>
|
||||
<text class="circuit-count-total" wx:if="{{status !== 'idle'}}">/{{totalSets}}</text>
|
||||
<text class="circuit-count-unit">组</text>
|
||||
</view>
|
||||
<view class="circuit-phase circuit-phase--{{status === 'paused' ? 'pause' : (status === 'idle' ? 'idle' : (isResting ? 'rest' : 'work'))}}">
|
||||
<view class="circuit-phase-dot"></view>
|
||||
<text class="circuit-phase-text">{{status === 'paused' ? '已暂停' : (status === 'idle' ? '准备开始' : (isResting ? '休息中' : '撑住'))}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="circuit-sub">
|
||||
<block wx:if="{{status === 'idle'}}">每组 {{circuitHold}} 秒{{circuitRest > 0 ? ' · 休息 ' + circuitRest + ' 秒' : ''}}</block>
|
||||
<block wx:else>已撑 {{workTotal}} 秒 · 目标 {{circuitTotalWork}} 秒</block>
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 呼吸引导环 + 进度条 -->
|
||||
<view class="ring-wrapper">
|
||||
<view class="breathe-ring breathe-ring-1 {{status === 'running' ? 'fast' : ''}}"
|
||||
@@ -34,7 +71,7 @@
|
||||
status="{{status}}"
|
||||
primaryColor="{{theme.primary}}"
|
||||
primaryLightColor="{{theme.primaryLight}}"
|
||||
trackColor="{{elapsed >= duration ? '#00B578' : (isDark ? '#3A3A40' : '#EEEEEE')}}"
|
||||
trackColor="{{isCircuitMode ? '#EEEEEE' : (elapsed >= duration ? '#00B578' : '#EEEEEE')}}"
|
||||
></progress-ring>
|
||||
|
||||
<!-- 完成庆祝粒子 -->
|
||||
@@ -53,7 +90,8 @@
|
||||
<view class="hint-row" wx:if="{{status === 'idle'}}">
|
||||
<image class="hint-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
|
||||
<text class="hint-text">
|
||||
<block wx:if="{{isFreeMode}}">自由训练 · 目标 {{duration}} 秒</block>
|
||||
<block wx:if="{{isCircuitMode}}">循环训练 · 共需撑 {{circuitTotalWork}} 秒</block>
|
||||
<block wx:elif="{{isFreeMode}}">自由训练 · 目标 {{duration}} 秒</block>
|
||||
<block wx:else>今日目标 {{duration}} 秒 · 第 {{planDay}} 天</block>
|
||||
</text>
|
||||
</view>
|
||||
@@ -63,7 +101,7 @@
|
||||
</view>
|
||||
<view class="hint-row" wx:elif="{{status === 'running'}}">
|
||||
<image class="hint-icon running-pulse" src="{{icons.likeFill}}" mode="aspectFit"></image>
|
||||
<text class="hint-text running">{{hintTip}}</text>
|
||||
<text class="hint-text running">{{isCircuitMode ? phaseTip : hintTip}}</text>
|
||||
</view>
|
||||
<view class="hint-row" wx:elif="{{status === 'paused'}}">
|
||||
<image class="hint-icon" src="{{icons.notificationForbidFill}}" mode="aspectFit"></image>
|
||||
|
||||
@@ -9,6 +9,221 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---- 循环训练进度面板 ----
|
||||
注意:整块不设固定 height,靠 padding 撑开。真机大字体档下文字会被微信
|
||||
放大,固定高度 + overflow 会把数字裁掉(项目里踩过这个坑)。 */
|
||||
.circuit-bar {
|
||||
/* 抬一层:呼吸光晕(.breathe-ring, z-index:0)在 DOM 里排在本面板之后,
|
||||
不抬层会把橙色晕染盖到卡片上。 */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 620rpx;
|
||||
max-width: 86%;
|
||||
box-sizing: border-box;
|
||||
padding: 26rpx 32rpx 22rpx;
|
||||
margin-bottom: 12rpx;
|
||||
background: var(--card-bg);
|
||||
border: 1rpx solid rgba(var(--primary-rgb), 0.14);
|
||||
border-radius: 28rpx;
|
||||
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.05);
|
||||
transition: border-color 0.35s ease, box-shadow 0.35s ease;
|
||||
animation: circuitBarIn 0.45s cubic-bezier(0.34, 1.4, 0.64, 1) both;
|
||||
}
|
||||
|
||||
/* 休息态整块降温:边框转成 success 绿,和"撑住"的主色态形成对比 */
|
||||
.circuit-bar.is-rest {
|
||||
border-color: rgba(var(--success-rgb), 0.32);
|
||||
box-shadow: 0 6rpx 20rpx rgba(var(--success-rgb), 0.10);
|
||||
}
|
||||
|
||||
@keyframes circuitBarIn {
|
||||
0% { opacity: 0; transform: translateY(-16rpx); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* --- 组进度点阵 ---
|
||||
整行铺满面板内宽,每段按组数严格等分(分段进度条式)。 */
|
||||
.circuit-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
/* 锁住行高:当前组比其它段高 6rpx,不锁行高会在 idle→running 时整块跳一下 */
|
||||
min-height: 18rpx;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
/* flex:1 1 0 + min-width:0 → 每段等分剩余宽度,与内容无关。
|
||||
不设 max-width:一旦封顶,组数少时点阵就只占左边一截(此前的问题)。
|
||||
当前组不再靠"加宽"强调(那会破坏等分),改为增高+发光,宽度恒等分。 */
|
||||
.circuit-dot {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 12rpx;
|
||||
border-radius: 6rpx;
|
||||
background: var(--bg-soft);
|
||||
transition: height 0.3s ease, background 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.circuit-dot.is-done {
|
||||
background: var(--primary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 当前组:增高 + 渐变 + 发光呼吸,一眼锁定进度位置(宽度仍等分) */
|
||||
.circuit-dot.is-active {
|
||||
height: 18rpx;
|
||||
border-radius: 9rpx;
|
||||
opacity: 1;
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
||||
box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.55);
|
||||
animation: circuitDotPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 休息时高亮"下一组",给用户一个即将开始的预告焦点 */
|
||||
.circuit-dot.is-next {
|
||||
height: 18rpx;
|
||||
border-radius: 9rpx;
|
||||
background: rgba(var(--success-rgb), 0.45);
|
||||
animation: circuitDotPulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes circuitDotPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
|
||||
/* 暂停时冻结所有呼吸动画,静止本身就是"已暂停"的信号 */
|
||||
.circuit-bar.is-paused .circuit-dot {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* --- 组数过多时的线性降级条 --- */
|
||||
.circuit-track {
|
||||
height: 12rpx;
|
||||
border-radius: 6rpx;
|
||||
background: var(--bg-soft);
|
||||
overflow: hidden;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.circuit-track-fill {
|
||||
height: 100%;
|
||||
border-radius: 6rpx;
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* --- 主信息行:大数字 + 阶段徽章 --- */
|
||||
.circuit-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.circuit-count {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.circuit-count-num {
|
||||
font-size: 56rpx;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -1rpx;
|
||||
}
|
||||
|
||||
.circuit-count-total {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1;
|
||||
margin-left: 2rpx;
|
||||
}
|
||||
|
||||
.circuit-count-unit {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
/* --- 阶段徽章 --- */
|
||||
.circuit-phase {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 30rpx;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
/* 状态指示灯用 CSS 画而非 icon:项目里的 SVG 图标颜色烤死在 data URI 里,
|
||||
CSS 改不动;这里需要跟随状态换色,纯 CSS 圆点是唯一干净的做法。 */
|
||||
.circuit-phase-dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.circuit-phase-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 工作态:主色渐变实心 + 白灯白字 */
|
||||
.circuit-phase--work {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||||
box-shadow: 0 4rpx 14rpx rgba(var(--primary-rgb), 0.32);
|
||||
}
|
||||
.circuit-phase--work .circuit-phase-dot {
|
||||
background: #FFFFFF;
|
||||
animation: phaseDotBlink 1.2s ease-in-out infinite;
|
||||
}
|
||||
.circuit-phase--work .circuit-phase-text { color: #FFFFFF; }
|
||||
|
||||
/* 休息态:绿色淡底,视觉降温 */
|
||||
.circuit-phase--rest {
|
||||
background: rgba(var(--success-rgb), 0.14);
|
||||
}
|
||||
.circuit-phase--rest .circuit-phase-dot {
|
||||
background: var(--success);
|
||||
animation: phaseDotBlink 1.8s ease-in-out infinite;
|
||||
}
|
||||
.circuit-phase--rest .circuit-phase-text { color: var(--success); }
|
||||
|
||||
/* 待机态 / 暂停态:中性灰,不抢视觉 */
|
||||
.circuit-phase--idle,
|
||||
.circuit-phase--pause {
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
.circuit-phase--idle .circuit-phase-dot,
|
||||
.circuit-phase--pause .circuit-phase-dot {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
.circuit-phase--idle .circuit-phase-text,
|
||||
.circuit-phase--pause .circuit-phase-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@keyframes phaseDotBlink {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
/* --- 副信息行 --- */
|
||||
.circuit-sub {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 24rpx;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ---- ring wrapper ---- */
|
||||
.ring-wrapper {
|
||||
position: relative;
|
||||
|
||||
Reference in New Issue
Block a user