之前误将训练模块加在 uniapp 项目,实际目标项目是 TUICallKit-Vue3 (甄养堂主小程序壳),整体迁入: - hooks/useMetronome.ts 节拍器(音频实例池+预热,无冷启动延迟) - hooks/useTrainingSession.ts 训练 Session 状态机 - hooks/useVoiceCoach.ts 语音教练队列 - hooks/useTrainingBgm.ts 训练/休息 BGM 切换+渐变 - pages/training/index.vue 练一练主页(器械跟练) - pages/training/metronome.vue 独立节拍器(老人友好版, 中央圆即开始/暂停按钮,纯 CSS 几何 icon,大字大按钮) - pages/training/components/exercise-anim.vue CSS 动作动画 - pages/training/exercises.ts 5 个器械动作预设 - static/training/audio/*.mp3 3 种 click 音色(MIT 协议) - scripts/generate-voice.mjs MiMo TTS 批量生成语音脚本 - pages.json 注册 pages/training/index 与 pages/training/metronome - 「我的」页加 DevTrainingEntry 悬浮入口,生产环境自动隐藏 Co-authored-by: Cursor <cursoragent@cursor.com>
134 lines
3.5 KiB
TypeScript
134 lines
3.5 KiB
TypeScript
import { ref, computed, onUnmounted } from 'vue'
|
|
|
|
export type SessionPhase = 'idle' | 'training' | 'resting' | 'done'
|
|
|
|
export interface TrainingSessionConfig {
|
|
sets: number
|
|
reps: number
|
|
restSec: number
|
|
beatsPerRep?: number
|
|
onRepComplete?: (currentRep: number, totalReps: number) => void
|
|
onSetComplete?: (currentSet: number, totalSets: number) => void
|
|
onEnterRest?: (restSec: number) => void
|
|
onExitRest?: () => void
|
|
onDone?: () => void
|
|
onCountdownTick?: (remainingSec: number) => void
|
|
}
|
|
|
|
export function useTrainingSession() {
|
|
const phase = ref<SessionPhase>('idle')
|
|
const currentSet = ref<number>(0)
|
|
const currentRep = ref<number>(0)
|
|
const restRemainingSec = ref<number>(0)
|
|
const beatCounter = ref<number>(0)
|
|
|
|
let config: TrainingSessionConfig | null = null
|
|
let restTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
const totalReps = computed(() => config?.reps ?? 0)
|
|
const totalSets = computed(() => config?.sets ?? 0)
|
|
const beatsPerRep = computed(() => config?.beatsPerRep ?? 2)
|
|
|
|
const isTraining = computed(() => phase.value === 'training')
|
|
const isResting = computed(() => phase.value === 'resting')
|
|
const isDone = computed(() => phase.value === 'done')
|
|
|
|
const start = (cfg: TrainingSessionConfig) => {
|
|
config = cfg
|
|
phase.value = 'training'
|
|
currentSet.value = 1
|
|
currentRep.value = 0
|
|
beatCounter.value = 0
|
|
restRemainingSec.value = 0
|
|
}
|
|
|
|
const onBeat = () => {
|
|
if (phase.value !== 'training' || !config) return
|
|
|
|
beatCounter.value++
|
|
|
|
if (beatCounter.value % beatsPerRep.value !== 0) return
|
|
|
|
currentRep.value++
|
|
config.onRepComplete?.(currentRep.value, totalReps.value)
|
|
|
|
if (currentRep.value >= totalReps.value) {
|
|
config.onSetComplete?.(currentSet.value, totalSets.value)
|
|
|
|
if (currentSet.value >= totalSets.value) {
|
|
phase.value = 'done'
|
|
config.onDone?.()
|
|
return
|
|
}
|
|
|
|
enterRest()
|
|
}
|
|
}
|
|
|
|
const enterRest = () => {
|
|
if (!config) return
|
|
phase.value = 'resting'
|
|
restRemainingSec.value = config.restSec
|
|
config.onEnterRest?.(config.restSec)
|
|
|
|
restTimer = setInterval(() => {
|
|
restRemainingSec.value--
|
|
config?.onCountdownTick?.(restRemainingSec.value)
|
|
if (restRemainingSec.value <= 0) {
|
|
exitRest()
|
|
}
|
|
}, 1000)
|
|
}
|
|
|
|
const exitRest = () => {
|
|
if (restTimer) {
|
|
clearInterval(restTimer)
|
|
restTimer = null
|
|
}
|
|
if (!config) return
|
|
|
|
currentSet.value++
|
|
currentRep.value = 0
|
|
beatCounter.value = 0
|
|
phase.value = 'training'
|
|
config.onExitRest?.()
|
|
}
|
|
|
|
const skipRest = () => {
|
|
if (phase.value !== 'resting') return
|
|
exitRest()
|
|
}
|
|
|
|
const stop = () => {
|
|
if (restTimer) {
|
|
clearInterval(restTimer)
|
|
restTimer = null
|
|
}
|
|
phase.value = 'idle'
|
|
currentSet.value = 0
|
|
currentRep.value = 0
|
|
beatCounter.value = 0
|
|
restRemainingSec.value = 0
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
if (restTimer) clearInterval(restTimer)
|
|
})
|
|
|
|
return {
|
|
phase,
|
|
currentSet,
|
|
currentRep,
|
|
restRemainingSec,
|
|
totalReps,
|
|
totalSets,
|
|
isTraining,
|
|
isResting,
|
|
isDone,
|
|
start,
|
|
stop,
|
|
skipRest,
|
|
onBeat,
|
|
}
|
|
}
|