From d10678763f1d5a4565ec12a98d839432ad09c279 Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Mon, 25 May 2026 11:29:03 +0800 Subject: [PATCH] =?UTF-8?q?revert(uniapp):=20=E7=A7=BB=E9=99=A4=E8=AE=AD?= =?UTF-8?q?=E7=BB=83=E6=A8=A1=E5=9D=97,=E4=BB=A3=E7=A0=81=E5=B7=B2?= =?UTF-8?q?=E8=BF=81=E8=87=B3=20TUICallKit-Vue3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前 6 个 commit 误把节拍器/练一练训练模块加在 uniapp 项目, 实际目标项目是 TUICallKit-Vue3(甄养堂主壳)。本提交清理 uniapp 项目所有相关改动: - 删除 hooks/useMetronome.ts、useTrainingSession.ts、 useVoiceCoach.ts、useTrainingBgm.ts - 删除 pages/training/* 与 components/dev-training-entry/ - 删除 static/training/* 与 scripts/generate-voice.mjs - 还原 pages.json、pages/index/index.vue、pages/user/user.vue Co-authored-by: Cursor --- uniapp/scripts/generate-voice.mjs | 213 ------ .../components/dev-training-entry/index.vue | 172 ----- uniapp/src/hooks/useMetronome.ts | 205 ------ uniapp/src/hooks/useTrainingBgm.ts | 115 ---- uniapp/src/hooks/useTrainingSession.ts | 133 ---- uniapp/src/hooks/useVoiceCoach.ts | 87 --- .../training/components/exercise-anim.vue | 259 -------- uniapp/src/pages/training/exercises.ts | 116 ---- uniapp/src/pages/training/index.vue | 610 ------------------ uniapp/src/pages/training/metronome.vue | 381 ----------- uniapp/src/static/training/README.md | 105 --- .../src/static/training/audio/click-soft.mp3 | Bin 4280 -> 0 bytes .../src/static/training/audio/click-wood.mp3 | Bin 4697 -> 0 bytes uniapp/src/static/training/audio/click.mp3 | Bin 3444 -> 0 bytes 14 files changed, 2396 deletions(-) delete mode 100644 uniapp/scripts/generate-voice.mjs delete mode 100644 uniapp/src/components/dev-training-entry/index.vue delete mode 100644 uniapp/src/hooks/useMetronome.ts delete mode 100644 uniapp/src/hooks/useTrainingBgm.ts delete mode 100644 uniapp/src/hooks/useTrainingSession.ts delete mode 100644 uniapp/src/hooks/useVoiceCoach.ts delete mode 100644 uniapp/src/pages/training/components/exercise-anim.vue delete mode 100644 uniapp/src/pages/training/exercises.ts delete mode 100644 uniapp/src/pages/training/index.vue delete mode 100644 uniapp/src/pages/training/metronome.vue delete mode 100644 uniapp/src/static/training/README.md delete mode 100644 uniapp/src/static/training/audio/click-soft.mp3 delete mode 100644 uniapp/src/static/training/audio/click-wood.mp3 delete mode 100644 uniapp/src/static/training/audio/click.mp3 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 @@ - - - - - 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 @@ - - - - - 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 @@ - - - - - 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 @@ - - - - - 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 5c70b9217a6aabde611d97f61f657990ea4b116f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4280 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 A{Qv*} 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 a6127c85b8409bcc86b1111f68ca3893b5f1f689..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4697 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 diff --git a/uniapp/src/static/training/audio/click.mp3 b/uniapp/src/static/training/audio/click.mp3 deleted file mode 100644 index a25fa2b0fa13017eb572999424b3756d2c663466..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3444 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