From 2beace89f432dc24bbe60a7f0481dd3935f98372 Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Mon, 25 May 2026 11:28:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(TUICallKit-Vue3):=20=E8=BF=81=E5=85=A5?= =?UTF-8?q?=E8=8A=82=E6=8B=8D=E5=99=A8=E4=B8=8E=E7=BB=83=E4=B8=80=E7=BB=83?= =?UTF-8?q?=E8=AE=AD=E7=BB=83=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前误将训练模块加在 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 --- .../components/dev-training-entry/index.vue | 180 ++++++ TUICallKit-Vue3/hooks/useMetronome.ts | 205 ++++++ TUICallKit-Vue3/hooks/useTrainingBgm.ts | 115 ++++ TUICallKit-Vue3/hooks/useTrainingSession.ts | 133 ++++ TUICallKit-Vue3/hooks/useVoiceCoach.ts | 87 +++ TUICallKit-Vue3/pages.json | 14 + .../training/components/exercise-anim.vue | 259 ++++++++ TUICallKit-Vue3/pages/training/exercises.ts | 116 ++++ TUICallKit-Vue3/pages/training/index.vue | 610 ++++++++++++++++++ TUICallKit-Vue3/pages/training/metronome.vue | 381 +++++++++++ TUICallKit-Vue3/pages/user/user.vue | 5 + TUICallKit-Vue3/scripts/generate-voice.mjs | 213 ++++++ TUICallKit-Vue3/static/training/README.md | 105 +++ .../static/training/audio/click-soft.mp3 | Bin 0 -> 4280 bytes .../static/training/audio/click-wood.mp3 | Bin 0 -> 4697 bytes .../static/training/audio/click.mp3 | Bin 0 -> 3444 bytes uniapp/src/pages.json | 14 - uniapp/src/pages/index/index.vue | 3 - uniapp/src/pages/user/user.vue | 2 - 19 files changed, 2423 insertions(+), 19 deletions(-) create mode 100644 TUICallKit-Vue3/components/dev-training-entry/index.vue create mode 100644 TUICallKit-Vue3/hooks/useMetronome.ts create mode 100644 TUICallKit-Vue3/hooks/useTrainingBgm.ts create mode 100644 TUICallKit-Vue3/hooks/useTrainingSession.ts create mode 100644 TUICallKit-Vue3/hooks/useVoiceCoach.ts create mode 100644 TUICallKit-Vue3/pages/training/components/exercise-anim.vue create mode 100644 TUICallKit-Vue3/pages/training/exercises.ts create mode 100644 TUICallKit-Vue3/pages/training/index.vue create mode 100644 TUICallKit-Vue3/pages/training/metronome.vue create mode 100644 TUICallKit-Vue3/scripts/generate-voice.mjs create mode 100644 TUICallKit-Vue3/static/training/README.md create mode 100644 TUICallKit-Vue3/static/training/audio/click-soft.mp3 create mode 100644 TUICallKit-Vue3/static/training/audio/click-wood.mp3 create mode 100644 TUICallKit-Vue3/static/training/audio/click.mp3 diff --git a/TUICallKit-Vue3/components/dev-training-entry/index.vue b/TUICallKit-Vue3/components/dev-training-entry/index.vue new file mode 100644 index 00000000..b60f5c34 --- /dev/null +++ b/TUICallKit-Vue3/components/dev-training-entry/index.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/TUICallKit-Vue3/hooks/useMetronome.ts b/TUICallKit-Vue3/hooks/useMetronome.ts new file mode 100644 index 00000000..8bbaa905 --- /dev/null +++ b/TUICallKit-Vue3/hooks/useMetronome.ts @@ -0,0 +1,205 @@ +import { ref, computed, onUnmounted } from 'vue' + +export interface MetronomeOptions { + initialBpm?: number + bpmMin?: number + bpmMax?: number + clickSrc?: string + accentSrc?: string + accentEvery?: number + poolSize?: number + onBeat?: (beatIndex: number, isAccent: boolean) => void +} + +/** + * 音频实例池:每个 click 用一个独立 InnerAudioContext,循环复用。 + * 解决两个问题: + * 1. seek+play 模式在某些设备上首拍冷启动延迟(100-300ms) + * 2. BPM 偏快时上一拍音频还没播完,下一拍 play 会被阻塞或丢失 + */ +class AudioPool { + private list: UniApp.InnerAudioContext[] = [] + private cursor = 0 + private warmedUp = false + + constructor( + private src: string, + private size: number, + ) {} + + private create() { + for (let i = 0; i < this.size; i++) { + const ctx = uni.createInnerAudioContext() + ctx.src = this.src + ctx.obeyMuteSwitch = false + ctx.autoplay = false + this.list.push(ctx) + } + } + + /** + * 预热:静音播放一次每个实例,让音频解码完成 + * 这样后续 play() 时就是热启动,没有冷启动延迟 + */ + warmUp() { + if (this.warmedUp) return + if (this.list.length === 0) this.create() + + this.list.forEach((ctx) => { + const originalVolume = ctx.volume + ctx.volume = 0 + try { + ctx.play() + setTimeout(() => { + try { + ctx.stop() + ctx.volume = originalVolume === 0 ? 1 : originalVolume + } catch (_) {} + }, 80) + } catch (_) {} + }) + this.warmedUp = true + } + + play() { + if (this.list.length === 0) { + this.create() + this.warmUp() + } + const ctx = this.list[this.cursor] + this.cursor = (this.cursor + 1) % this.list.length + try { + ctx.seek(0) + ctx.play() + } catch (_) { + ctx.play() + } + } + + destroy() { + this.list.forEach((ctx) => { + try { + ctx.destroy?.() + } catch (_) {} + }) + this.list = [] + this.warmedUp = false + } +} + +export function useMetronome(options: MetronomeOptions = {}) { + const { + initialBpm = 80, + bpmMin = 40, + bpmMax = 240, + clickSrc = '/static/training/audio/click.mp3', + accentSrc, + poolSize = 4, + onBeat, + } = options + + const bpm = ref(initialBpm) + const isPlaying = ref(false) + const beatIndex = ref(0) + const isAccent = ref(false) + const accentEveryRef = ref(options.accentEvery ?? 4) + + const intervalMs = computed(() => 60000 / bpm.value) + + let clickPool: AudioPool | null = null + let accentPool: AudioPool | null = null + let timer: ReturnType | null = null + let nextTickAt = 0 + + const ensureAudio = () => { + if (!clickPool) { + clickPool = new AudioPool(clickSrc, poolSize) + clickPool.warmUp() + } + if (accentSrc && !accentPool) { + accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2))) + accentPool.warmUp() + } + } + + const playSound = (accent: boolean) => { + if (accent && accentPool) { + accentPool.play() + } else if (clickPool) { + clickPool.play() + } + } + + const tick = () => { + if (!isPlaying.value) return + + const every = Math.max(1, accentEveryRef.value) + const accent = beatIndex.value % every === 0 + isAccent.value = accent + + playSound(accent) + onBeat?.(beatIndex.value, accent) + beatIndex.value++ + + nextTickAt += intervalMs.value + const drift = Date.now() - nextTickAt + const nextDelay = Math.max(0, intervalMs.value - drift) + + timer = setTimeout(tick, nextDelay) + } + + const start = () => { + if (isPlaying.value) return + ensureAudio() + isPlaying.value = true + beatIndex.value = 0 + nextTickAt = Date.now() + tick() + } + + const stop = () => { + isPlaying.value = false + if (timer) { + clearTimeout(timer) + timer = null + } + } + + const setBpm = (val: number) => { + bpm.value = Math.max(bpmMin, Math.min(bpmMax, Math.round(val))) + } + + const setAccentEvery = (n: number) => { + accentEveryRef.value = Math.max(1, Math.min(16, Math.round(n))) + beatIndex.value = 0 + } + + /** + * 主动预加载(推荐在页面 onShow 时调用,让用户进页面就完成预热) + */ + const preload = () => { + ensureAudio() + } + + onUnmounted(() => { + stop() + clickPool?.destroy() + accentPool?.destroy() + clickPool = null + accentPool = null + }) + + return { + bpm, + isPlaying, + beatIndex, + isAccent, + intervalMs, + accentEvery: accentEveryRef, + start, + stop, + setBpm, + setAccentEvery, + preload, + } +} diff --git a/TUICallKit-Vue3/hooks/useTrainingBgm.ts b/TUICallKit-Vue3/hooks/useTrainingBgm.ts new file mode 100644 index 00000000..13dfc585 --- /dev/null +++ b/TUICallKit-Vue3/hooks/useTrainingBgm.ts @@ -0,0 +1,115 @@ +import { ref, onUnmounted } from 'vue' + +const BGM_BASE = '/static/training/bgm' + +export type BgmMode = 'none' | 'training' | 'resting' + +export interface UseTrainingBgmOptions { + trainingSrc?: string + restingSrc?: string + volume?: number + fadeMs?: number +} + +export function useTrainingBgm(options: UseTrainingBgmOptions = {}) { + const { + trainingSrc = `${BGM_BASE}/train-light.mp3`, + restingSrc = `${BGM_BASE}/rest-meditation.mp3`, + volume = 0.6, + fadeMs = 600, + } = options + + const mode = ref('none') + const enabled = ref(true) + + let trainCtx: UniApp.InnerAudioContext | null = null + let restCtx: UniApp.InnerAudioContext | null = null + let fadeTimers: ReturnType[] = [] + + const ensureCtx = () => { + if (!trainCtx) { + trainCtx = uni.createInnerAudioContext() + trainCtx.src = trainingSrc + trainCtx.loop = true + trainCtx.obeyMuteSwitch = false + trainCtx.volume = 0 + } + if (!restCtx) { + restCtx = uni.createInnerAudioContext() + restCtx.src = restingSrc + restCtx.loop = true + restCtx.obeyMuteSwitch = false + restCtx.volume = 0 + } + } + + const fade = ( + ctx: UniApp.InnerAudioContext, + from: number, + to: number, + duration = fadeMs, + ) => { + const steps = 16 + const stepMs = Math.max(16, duration / steps) + let i = 0 + const t = setInterval(() => { + i++ + const v = from + (to - from) * (i / steps) + ctx.volume = Math.max(0, Math.min(1, v)) + if (i >= steps) { + clearInterval(t) + fadeTimers = fadeTimers.filter((x) => x !== t) + } + }, stepMs) + fadeTimers.push(t) + } + + const switchTo = (target: BgmMode) => { + if (!enabled.value) return + if (mode.value === target) return + ensureCtx() + + if (target === 'training' && trainCtx && restCtx) { + fade(restCtx, restCtx.volume, 0) + setTimeout(() => restCtx?.pause(), fadeMs) + trainCtx.play() + fade(trainCtx, 0, volume) + } else if (target === 'resting' && trainCtx && restCtx) { + fade(trainCtx, trainCtx.volume, 0) + setTimeout(() => trainCtx?.pause(), fadeMs) + restCtx.play() + fade(restCtx, 0, volume) + } else if (target === 'none') { + if (trainCtx) { + fade(trainCtx, trainCtx.volume, 0) + setTimeout(() => trainCtx?.pause(), fadeMs) + } + if (restCtx) { + fade(restCtx, restCtx.volume, 0) + setTimeout(() => restCtx?.pause(), fadeMs) + } + } + mode.value = target + } + + const setEnabled = (v: boolean) => { + enabled.value = v + if (!v) switchTo('none') + } + + onUnmounted(() => { + fadeTimers.forEach((t) => clearInterval(t)) + fadeTimers = [] + trainCtx?.destroy?.() + restCtx?.destroy?.() + trainCtx = null + restCtx = null + }) + + return { + mode, + enabled, + switchTo, + setEnabled, + } +} diff --git a/TUICallKit-Vue3/hooks/useTrainingSession.ts b/TUICallKit-Vue3/hooks/useTrainingSession.ts new file mode 100644 index 00000000..f33499b8 --- /dev/null +++ b/TUICallKit-Vue3/hooks/useTrainingSession.ts @@ -0,0 +1,133 @@ +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('idle') + const currentSet = ref(0) + const currentRep = ref(0) + const restRemainingSec = ref(0) + const beatCounter = ref(0) + + let config: TrainingSessionConfig | null = null + let restTimer: ReturnType | 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, + } +} diff --git a/TUICallKit-Vue3/hooks/useVoiceCoach.ts b/TUICallKit-Vue3/hooks/useVoiceCoach.ts new file mode 100644 index 00000000..c17c26d9 --- /dev/null +++ b/TUICallKit-Vue3/hooks/useVoiceCoach.ts @@ -0,0 +1,87 @@ +import { onUnmounted } from 'vue' + +const VOICE_BASE = '/static/training/voice' + +export type VoicePromptKey = + | 'start' + | 'ready' + | 'rest' + | 'next-set' + | 'last-rep' + | 'keep-it-up' + | 'good-job' + | 'breathe-in' + | 'breathe-out' + +export interface UseVoiceCoachOptions { + enabled?: boolean + volume?: number +} + +export function useVoiceCoach(options: UseVoiceCoachOptions = {}) { + const { enabled = true, volume = 1 } = options + + let ctx: UniApp.InnerAudioContext | null = null + let queue: string[] = [] + let isPlayingQueue = false + + const ensureCtx = () => { + if (ctx) return + ctx = uni.createInnerAudioContext() + ctx.volume = volume + ctx.obeyMuteSwitch = false + ctx.onEnded(() => { + playNextInQueue() + }) + ctx.onError(() => { + playNextInQueue() + }) + } + + const playNextInQueue = () => { + if (!ctx || queue.length === 0) { + isPlayingQueue = false + return + } + const nextSrc = queue.shift()! + ctx.src = nextSrc + ctx.play() + } + + const enqueue = (src: string) => { + if (!enabled) return + ensureCtx() + queue.push(src) + if (!isPlayingQueue) { + isPlayingQueue = true + playNextInQueue() + } + } + + const speakNumber = (n: number) => { + if (n < 1 || n > 30) return + enqueue(`${VOICE_BASE}/numbers/${n}.mp3`) + } + + const speakPrompt = (key: VoicePromptKey) => { + enqueue(`${VOICE_BASE}/prompts/${key}.mp3`) + } + + const stop = () => { + queue = [] + isPlayingQueue = false + ctx?.stop() + } + + onUnmounted(() => { + stop() + ctx?.destroy?.() + ctx = null + }) + + return { + speakNumber, + speakPrompt, + stop, + } +} diff --git a/TUICallKit-Vue3/pages.json b/TUICallKit-Vue3/pages.json index e19aca2a..ca65d0d2 100644 --- a/TUICallKit-Vue3/pages.json +++ b/TUICallKit-Vue3/pages.json @@ -69,6 +69,20 @@ "style": { "navigationBarTitleText": "付款" } + }, + { + "path": "pages/training/index", + "style": { + "navigationBarTitleText": "练一练" + } + }, + { + "path": "pages/training/metronome", + "style": { + "navigationBarTitleText": "节拍器", + "navigationBarBackgroundColor": "#0f172a", + "navigationBarTextStyle": "white" + } } ], "subPackages": [ diff --git a/TUICallKit-Vue3/pages/training/components/exercise-anim.vue b/TUICallKit-Vue3/pages/training/components/exercise-anim.vue new file mode 100644 index 00000000..c53b4d77 --- /dev/null +++ b/TUICallKit-Vue3/pages/training/components/exercise-anim.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/TUICallKit-Vue3/pages/training/exercises.ts b/TUICallKit-Vue3/pages/training/exercises.ts new file mode 100644 index 00000000..b72f9c0f --- /dev/null +++ b/TUICallKit-Vue3/pages/training/exercises.ts @@ -0,0 +1,116 @@ +export type AnimationType = + | 'curl' + | 'press' + | 'sidefly' + | 'grip' + | 'crunch' + +export interface ExercisePreset { + id: string + name: string + equipment: '哑铃' | '握力环' | '健身环' | '徒手' + icon: string + animationType: AnimationType + description: string + tips: string[] + contraindication?: string + + defaultBpm: number + bpmMin: number + bpmMax: number + defaultSets: number + defaultReps: number + defaultRestSec: number + + beatsPerRep: number +} + +export const EXERCISE_PRESETS: ExercisePreset[] = [ + { + id: 'dumbbell-curl', + name: '哑铃弯举', + equipment: '哑铃', + icon: '🏋', + animationType: 'curl', + description: '锻炼肱二头肌,注意肘部固定不动', + tips: ['肘部贴紧身体', '上举吸气,下落呼气', '动作放慢,控制重量'], + contraindication: '肘关节炎症急性期不宜', + defaultBpm: 60, + bpmMin: 40, + bpmMax: 100, + defaultSets: 3, + defaultReps: 12, + defaultRestSec: 60, + beatsPerRep: 2, + }, + { + id: 'dumbbell-press', + name: '哑铃推举', + equipment: '哑铃', + icon: '🏋', + animationType: 'press', + description: '锻炼肩部三角肌,核心保持收紧', + tips: ['不要含胸驼背', '推举时呼气', '不要锁死肘关节'], + contraindication: '肩袖损伤者请咨询医生', + defaultBpm: 60, + bpmMin: 40, + bpmMax: 90, + defaultSets: 3, + defaultReps: 10, + defaultRestSec: 90, + beatsPerRep: 2, + }, + { + id: 'dumbbell-sidefly', + name: '哑铃侧平举', + equipment: '哑铃', + icon: '🏋', + animationType: 'sidefly', + description: '锻炼三角肌中束,重量宜轻不宜重', + tips: ['手肘微屈', '抬至与肩平齐即可', '想象用肩膀发力,不是手臂'], + defaultBpm: 60, + bpmMin: 40, + bpmMax: 80, + defaultSets: 3, + defaultReps: 12, + defaultRestSec: 60, + beatsPerRep: 2, + }, + { + id: 'grip-ring', + name: '握力环训练', + equipment: '握力环', + icon: '🟢', + animationType: 'grip', + description: '锻炼前臂屈肌群,改善握力', + tips: ['完全握紧停顿 1 秒', '完全张开放松', '左右手交替'], + defaultBpm: 80, + bpmMin: 60, + bpmMax: 120, + defaultSets: 4, + defaultReps: 20, + defaultRestSec: 45, + beatsPerRep: 2, + }, + { + id: 'crunch', + name: '集中机(仰卧卷腹)', + equipment: '健身环', + icon: '💪', + animationType: 'crunch', + description: '锻炼腹直肌,借助健身环增加阻力', + tips: ['下背贴地', '用腹部发力,不是脖子', '上抬呼气,下落吸气'], + contraindication: '腰椎间盘突出急性期不宜', + defaultBpm: 50, + bpmMin: 40, + bpmMax: 80, + defaultSets: 3, + defaultReps: 15, + defaultRestSec: 60, + beatsPerRep: 2, + }, +] + +export function getPresetById(id: string): ExercisePreset | undefined { + return EXERCISE_PRESETS.find((e) => e.id === id) +} diff --git a/TUICallKit-Vue3/pages/training/index.vue b/TUICallKit-Vue3/pages/training/index.vue new file mode 100644 index 00000000..36577310 --- /dev/null +++ b/TUICallKit-Vue3/pages/training/index.vue @@ -0,0 +1,610 @@ + + + + + diff --git a/TUICallKit-Vue3/pages/training/metronome.vue b/TUICallKit-Vue3/pages/training/metronome.vue new file mode 100644 index 00000000..0e795eb3 --- /dev/null +++ b/TUICallKit-Vue3/pages/training/metronome.vue @@ -0,0 +1,381 @@ + + + + + diff --git a/TUICallKit-Vue3/pages/user/user.vue b/TUICallKit-Vue3/pages/user/user.vue index 4cc3c0dc..24c8a1a7 100644 --- a/TUICallKit-Vue3/pages/user/user.vue +++ b/TUICallKit-Vue3/pages/user/user.vue @@ -88,13 +88,18 @@ + + +