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 @@
+
+
+
+
+
+ {{ expanded ? '✕' : '💪' }}
+ 练
+ DEV
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ {{ icon }}
+
+
+
+
+
+ {{ icon }}
+ {{ icon }}
+
+
+
+
+
+ {{ icon }}
+
+
+ {{ icon }}
+
+
+
+
+
+ {{ icon }}
+
+
+
+
+ {{ icon }}
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ {{ eq }}
+
+
+
+
+
+
+ {{ ex.icon }}
+ {{ ex.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selected.description }}
+
+
+
+
+ 节奏
+
+ {{ bpm }} BPM
+
+
+
+
+ 组数
+
+ -
+ {{ sets }}
+ +
+
+
+
+ 次数
+
+ -
+ {{ reps }}
+ +
+
+
+
+ 休息(秒)
+
+ -
+ {{ restSec }}
+ +
+
+
+
+
+
+
+
+
+
+
+ 训练中
+ 第 {{ currentSet }} / {{ totalSets }} 组
+ {{ currentRep }} / {{ totalReps }}
+
+
+
+
+
+
+ 休息中
+ {{ restRemainingSec }}s
+
+
+
+
+
+
+
+ 训练完成 🎉
+ 共 {{ totalSets }} 组 × {{ totalReps }} 次
+
+
+
+
+
+
+ 动作要领
+
+ • {{ tip }}
+
+
+ ⚠ {{ selected.contraindication }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ {{ bpm }}
+ 速度
+
+
+
+
+
+
+
+
+
+
+
+
+ -10
+
+
+ -1
+
+
+ +1
+
+
+ +10
+
+
+
+
+ 每组拍数
+
+
+ {{ m.label }}
+
+
+
+
+
+ {{ statusHint }}
+
+
+
+
+
+
+
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 @@
+
+
+