diff --git a/uniapp/scripts/generate-voice.mjs b/uniapp/scripts/generate-voice.mjs
deleted file mode 100644
index e34090c6..00000000
--- a/uniapp/scripts/generate-voice.mjs
+++ /dev/null
@@ -1,213 +0,0 @@
-#!/usr/bin/env node
-/* eslint-disable no-console */
-/**
- * 用小米 MiMo TTS 生成训练语音素材
- *
- * 文档: https://platform.xiaomimimo.com/docs/en-US/usage-guide/speech-synthesis-v2.5
- * 接口: POST https://api.xiaomimimo.com/v1/chat/completions
- *
- * 准备:
- * export MIMO_API_KEY=sk-your-key-here
- * (或 export XIAOMI_API_KEY=...)
- *
- * 运行:
- * cd uniapp
- * node scripts/generate-voice.mjs # 默认音色 冰糖
- * node scripts/generate-voice.mjs --voice 茉莉
- * node scripts/generate-voice.mjs --force # 已存在的也重新生成
- *
- * 输出:
- * src/static/training/voice/numbers/{1..30}.mp3
- * src/static/training/voice/prompts/{key}.mp3
- *
- * 需要 Node 18+(用内置 fetch)
- */
-
-import { mkdir, writeFile, access, stat } from 'node:fs/promises'
-import { dirname, join } from 'node:path'
-import { fileURLToPath } from 'node:url'
-import { Buffer } from 'node:buffer'
-
-const __filename = fileURLToPath(import.meta.url)
-const __dirname = dirname(__filename)
-
-const ROOT = join(__dirname, '..')
-const VOICE_DIR = join(ROOT, 'src/static/training/voice')
-const NUMBERS_DIR = join(VOICE_DIR, 'numbers')
-const PROMPTS_DIR = join(VOICE_DIR, 'prompts')
-
-// ============ 参数 ============
-const args = process.argv.slice(2)
-const FORCE = args.includes('--force')
-const VOICE = pickArg('--voice') || '冰糖'
-const MODEL = pickArg('--model') || 'mimo-v2.5-tts'
-const FORMAT = pickArg('--format') || 'mp3'
-
-const API_KEY =
- process.env.MIMO_API_KEY ||
- process.env.XIAOMI_API_KEY ||
- process.env.XIAOMI_MIMO_API_KEY
-const ENDPOINT =
- process.env.MIMO_ENDPOINT || 'https://api.xiaomimimo.com/v1/chat/completions'
-
-if (!API_KEY) {
- console.error('❌ 未找到 API Key')
- console.error(' 请先 export MIMO_API_KEY=your-key')
- process.exit(1)
-}
-
-function pickArg(name) {
- const idx = args.indexOf(name)
- if (idx === -1) return null
- return args[idx + 1]
-}
-
-// ============ 语音内容 ============
-
-// 数字报数:简短的"教练点数"风格
-const NUMBER_STYLE =
- '用简短有力的健身教练口吻报数,干净利落,节奏明快,每个数字独立清晰。'
-
-// 口令:温柔有鼓励性的女教练
-const PROMPT_STYLE =
- '用温柔但有力量的女教练口吻说话,语调亲切自然,节奏适中,像在带学员训练。'
-
-const PROMPTS = {
- start: '开始',
- ready: '准备',
- rest: '休息一下',
- 'next-set': '下一组开始',
- 'last-rep': '最后一次',
- 'keep-it-up': '继续坚持',
- 'good-job': '很棒,训练完成',
- 'breathe-in': '吸气',
- 'breathe-out': '呼气',
-}
-
-// ============ 工具 ============
-
-async function fileExists(p) {
- try {
- await access(p)
- return true
- } catch {
- return false
- }
-}
-
-async function ensureDir(d) {
- await mkdir(d, { recursive: true })
-}
-
-async function tts(text, outPath, styleInstruction) {
- if (!FORCE && (await fileExists(outPath))) {
- const s = await stat(outPath)
- if (s.size > 0) {
- console.log(`[skip] ${outPath} (已存在)`)
- return
- }
- }
-
- const body = {
- model: MODEL,
- messages: [
- { role: 'user', content: styleInstruction },
- { role: 'assistant', content: text },
- ],
- audio: { format: FORMAT, voice: VOICE },
- }
-
- const res = await fetch(ENDPOINT, {
- method: 'POST',
- headers: {
- 'api-key': API_KEY,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(body),
- })
-
- if (!res.ok) {
- const errText = await res.text().catch(() => '')
- throw new Error(`HTTP ${res.status} ${res.statusText}: ${errText.slice(0, 300)}`)
- }
-
- const json = await res.json()
- const audioBase64 = json?.choices?.[0]?.message?.audio?.data
- if (!audioBase64) {
- throw new Error(
- `响应里找不到 audio.data 字段。完整响应: ${JSON.stringify(json).slice(0, 500)}`,
- )
- }
-
- const buf = Buffer.from(audioBase64, 'base64')
- await writeFile(outPath, buf)
- console.log(`[ok] ${outPath} (${buf.length} bytes) → "${text}"`)
-}
-
-async function sleep(ms) {
- return new Promise((r) => setTimeout(r, ms))
-}
-
-// ============ 主流程 ============
-
-async function main() {
- console.log('====== 小米 MiMo TTS 语音生成 ======')
- console.log(`Endpoint : ${ENDPOINT}`)
- console.log(`Model : ${MODEL}`)
- console.log(`Voice : ${VOICE}`)
- console.log(`Format : ${FORMAT}`)
- console.log(`Force : ${FORCE}`)
- console.log(`Output : ${VOICE_DIR}`)
- console.log('====================================\n')
-
- await ensureDir(NUMBERS_DIR)
- await ensureDir(PROMPTS_DIR)
-
- let successCount = 0
- let failCount = 0
-
- console.log('--- 数字 1-30 ---')
- for (let n = 1; n <= 30; n++) {
- try {
- await tts(String(n), join(NUMBERS_DIR, `${n}.${FORMAT}`), NUMBER_STYLE)
- successCount++
- await sleep(120)
- } catch (e) {
- failCount++
- console.error(`[fail] 数字 ${n}: ${e.message}`)
- }
- }
-
- console.log('\n--- 口令 ---')
- for (const [key, text] of Object.entries(PROMPTS)) {
- try {
- await tts(text, join(PROMPTS_DIR, `${key}.${FORMAT}`), PROMPT_STYLE)
- successCount++
- await sleep(120)
- } catch (e) {
- failCount++
- console.error(`[fail] ${key}: ${e.message}`)
- }
- }
-
- console.log('\n====================================')
- console.log(`成功 ${successCount} | 失败 ${failCount}`)
- console.log('====================================')
-
- if (FORMAT !== 'mp3') {
- console.log(
- `\n⚠️ 当前生成格式是 ${FORMAT},但 hooks/useVoiceCoach.ts 默认引用 .mp3。`,
- )
- console.log(' 要么换 --format mp3 重跑,要么修改 hooks 里的 src 后缀。')
- }
-
- console.log('\n下一步:')
- console.log(' 1. 准备 click 音 → src/static/training/audio/click.mp3')
- console.log(' 2. 准备 BGM → src/static/training/bgm/{train-light,rest-meditation}.mp3')
- console.log(' 3. npm run dev:mp-weixin → 进入 /pages/training/index')
-}
-
-main().catch((e) => {
- console.error('\n❌ 致命错误:', e)
- process.exit(1)
-})
diff --git a/uniapp/src/components/dev-training-entry/index.vue b/uniapp/src/components/dev-training-entry/index.vue
deleted file mode 100644
index 487e52d1..00000000
--- a/uniapp/src/components/dev-training-entry/index.vue
+++ /dev/null
@@ -1,172 +0,0 @@
-
-
-
-
-
- {{ expanded ? '✕' : '💪' }}
- 练
- DEV
-
-
-
-
-
-
-
diff --git a/uniapp/src/hooks/useMetronome.ts b/uniapp/src/hooks/useMetronome.ts
deleted file mode 100644
index 8bbaa905..00000000
--- a/uniapp/src/hooks/useMetronome.ts
+++ /dev/null
@@ -1,205 +0,0 @@
-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/uniapp/src/hooks/useTrainingBgm.ts b/uniapp/src/hooks/useTrainingBgm.ts
deleted file mode 100644
index 13dfc585..00000000
--- a/uniapp/src/hooks/useTrainingBgm.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-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/uniapp/src/hooks/useTrainingSession.ts b/uniapp/src/hooks/useTrainingSession.ts
deleted file mode 100644
index f33499b8..00000000
--- a/uniapp/src/hooks/useTrainingSession.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-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/uniapp/src/hooks/useVoiceCoach.ts b/uniapp/src/hooks/useVoiceCoach.ts
deleted file mode 100644
index c17c26d9..00000000
--- a/uniapp/src/hooks/useVoiceCoach.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-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/uniapp/src/pages/training/components/exercise-anim.vue b/uniapp/src/pages/training/components/exercise-anim.vue
deleted file mode 100644
index c53b4d77..00000000
--- a/uniapp/src/pages/training/components/exercise-anim.vue
+++ /dev/null
@@ -1,259 +0,0 @@
-
-
-
-
-
-
- {{ icon }}
-
-
-
-
-
- {{ icon }}
- {{ icon }}
-
-
-
-
-
- {{ icon }}
-
-
- {{ icon }}
-
-
-
-
-
- {{ icon }}
-
-
-
-
- {{ icon }}
-
-
-
-
-
-
-
-
diff --git a/uniapp/src/pages/training/exercises.ts b/uniapp/src/pages/training/exercises.ts
deleted file mode 100644
index b72f9c0f..00000000
--- a/uniapp/src/pages/training/exercises.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-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/uniapp/src/pages/training/index.vue b/uniapp/src/pages/training/index.vue
deleted file mode 100644
index 36577310..00000000
--- a/uniapp/src/pages/training/index.vue
+++ /dev/null
@@ -1,610 +0,0 @@
-
-
-
-
-
-
- {{ 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/uniapp/src/pages/training/metronome.vue b/uniapp/src/pages/training/metronome.vue
deleted file mode 100644
index 0e795eb3..00000000
--- a/uniapp/src/pages/training/metronome.vue
+++ /dev/null
@@ -1,381 +0,0 @@
-
-
-
-
-
-
- {{ bpm }}
- 速度
-
-
-
-
-
-
-
-
-
-
-
-
- -10
-
-
- -1
-
-
- +1
-
-
- +10
-
-
-
-
- 每组拍数
-
-
- {{ m.label }}
-
-
-
-
-
- {{ statusHint }}
-
-
-
-
-
-
-
diff --git a/uniapp/src/static/training/README.md b/uniapp/src/static/training/README.md
deleted file mode 100644
index f97b585d..00000000
--- a/uniapp/src/static/training/README.md
+++ /dev/null
@@ -1,105 +0,0 @@
-# 训练模块静态资源
-
-本目录用于存放"练一练"功能的所有音频素材。
-
-## 目录结构
-
-```
-src/static/training/
-├── audio/
-│ └── click.mp3 # 节拍器主音(短促金属敲击声,约 50ms)
-├── voice/
-│ ├── numbers/
-│ │ └── 1.mp3 ~ 30.mp3 # 数字报数
-│ └── prompts/
-│ ├── start.mp3
-│ ├── ready.mp3
-│ ├── rest.mp3
-│ ├── next-set.mp3
-│ ├── last-rep.mp3
-│ ├── keep-it-up.mp3
-│ ├── good-job.mp3
-│ ├── breathe-in.mp3
-│ └── breathe-out.mp3
-└── bgm/
- ├── train-light.mp3 # 训练时背景乐(轻快有节奏感)
- └── rest-meditation.mp3 # 休息时背景乐(舒缓冥想)
-```
-
-## 准备步骤
-
-### 1. 语音素材(小米 MiMo TTS 自动生成)
-
-需要 Node 18+(用内置 `fetch`)。先准备小米 MiMo API Key:
-
-- 文档: https://platform.xiaomimimo.com/
-- API Key 形如 `sk-xxxxxxxx`
-
-```bash
-export MIMO_API_KEY=sk-your-key-here
-
-cd uniapp
-node scripts/generate-voice.mjs
-```
-
-会自动调用 `mimo-v2.5-tts` 模型生成 39 个 mp3(30 个数字 + 9 个口令)到 `voice/` 目录。
-
-**可选参数**:
-
-```bash
-# 换音色(默认 冰糖;可选:冰糖/茉莉/苏打/白桦/Mia/Chloe/Milo/Dean)
-node scripts/generate-voice.mjs --voice 茉莉
-
-# 换格式(默认 mp3,需要跟 hooks/useVoiceCoach.ts 里的 .mp3 后缀对应)
-node scripts/generate-voice.mjs --format wav
-
-# 强制重新生成(默认存在则跳过)
-node scripts/generate-voice.mjs --force
-```
-
-**音色推荐**(中文女声更适合健身教练):
-- `冰糖`:温柔甜美,亲和力强(默认)
-- `茉莉`:清爽利落,有"运动博主"感
-- `苏打`:年轻男声,有力量感
-- `白桦`:成熟男声,沉稳
-
-### 2. 节拍器 click 音
-
-**已内置 3 种音色**(来自 [chrono-bump](https://github.com/johnnysn/chrono-bump) MIT 协议项目):
-
-| 文件 | 用途 | 大小 |
-|------|------|------|
-| `audio/click.mp3` | 默认 click(标准节拍器音) | 3.4 KB |
-| `audio/click-soft.mp3` | 柔和音色 | 4.2 KB |
-| `audio/click-wood.mp3` | 木鱼/原木质感(推荐用于重音 accent) | 4.6 KB |
-
-如果想换更好听的,可以去:
-- [freesound.org](https://freesound.org) 搜 "click" / "tick"(注意 CC 协议)
-- [pixabay.com/sound-effects](https://pixabay.com/sound-effects/) 搜 "metronome"(免费可商用)
-
-替换时保持文件名不变即可,无需改代码。
-
-### 3. 背景音乐
-
-每首 30 秒~2 分钟即可(loop 后听不出接缝)。
-
-推荐来源(免费可商用):
-- [pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
-- [freemusicarchive.org](https://freemusicarchive.org)
-- [bensound.com](https://bensound.com)(含署名)
-
-文件大小建议 < 1MB(mp3 128kbps 单声道即可)。
-
-## 在代码里被引用的位置
-
-- `src/hooks/useMetronome.ts` → `audio/click.mp3`
-- `src/hooks/useVoiceCoach.ts` → `voice/numbers/*.mp3`、`voice/prompts/*.mp3`
-- `src/hooks/useTrainingBgm.ts` → `bgm/*.mp3`
-
-如需修改路径,修改对应 hook 文件里的常量即可。
-
-## 注意事项
-
-- 微信小程序对单个文件大小有 10MB 上限,本目录所有文件加起来建议控制在 5MB 以内。
-- 小程序整包大小限制(主包 2MB / 总包 20MB),如果资源较大建议放 CDN 而非本地 static。
- - 把 `useVoiceCoach.ts` 里的 `VOICE_BASE` 改成 CDN URL 即可。
diff --git a/uniapp/src/static/training/audio/click-soft.mp3 b/uniapp/src/static/training/audio/click-soft.mp3
deleted file mode 100644
index 5c70b921..00000000
Binary files a/uniapp/src/static/training/audio/click-soft.mp3 and /dev/null differ
diff --git a/uniapp/src/static/training/audio/click-wood.mp3 b/uniapp/src/static/training/audio/click-wood.mp3
deleted file mode 100644
index a6127c85..00000000
Binary files a/uniapp/src/static/training/audio/click-wood.mp3 and /dev/null differ
diff --git a/uniapp/src/static/training/audio/click.mp3 b/uniapp/src/static/training/audio/click.mp3
deleted file mode 100644
index a25fa2b0..00000000
Binary files a/uniapp/src/static/training/audio/click.mp3 and /dev/null differ