revert(uniapp): 移除训练模块,代码已迁至 TUICallKit-Vue3
之前 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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
|
||||||
})
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view v-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
|
|
||||||
<view v-if="expanded" class="menu-list" @click.stop>
|
|
||||||
<view class="menu-item" @click="goto('/pages/training/index')">
|
|
||||||
<view class="menu-icon">💪</view>
|
|
||||||
<view class="menu-info">
|
|
||||||
<text class="menu-title">练一练</text>
|
|
||||||
<text class="menu-sub">器械跟练</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="menu-item" @click="goto('/pages/training/metronome')">
|
|
||||||
<view class="menu-icon metronome-icon">🎵</view>
|
|
||||||
<view class="menu-info">
|
|
||||||
<text class="menu-title">节拍器</text>
|
|
||||||
<text class="menu-sub">独立工具</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="entry-fab" :class="{ expanded }" @click="toggle">
|
|
||||||
<text class="fab-icon">{{ expanded ? '✕' : '💪' }}</text>
|
|
||||||
<text v-if="!expanded" class="fab-text">练</text>
|
|
||||||
<view v-if="!expanded" class="fab-tag">DEV</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { isDevMode } from '@/utils/env'
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
bottom?: number
|
|
||||||
}>(),
|
|
||||||
{ bottom: 200 },
|
|
||||||
)
|
|
||||||
|
|
||||||
const visible = ref(isDevMode())
|
|
||||||
const expanded = ref(false)
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
expanded.value = !expanded.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const goto = (url: string) => {
|
|
||||||
expanded.value = false
|
|
||||||
uni.navigateTo({ url })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.dev-entry-wrap {
|
|
||||||
position: fixed;
|
|
||||||
right: 24rpx;
|
|
||||||
z-index: 999;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-fab {
|
|
||||||
width: 110rpx;
|
|
||||||
height: 110rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
|
||||||
box-shadow: 0 6rpx 20rpx rgba(34, 197, 94, 0.4);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
transition: transform 0.2s, background 0.2s;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: scale(0.92);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.expanded {
|
|
||||||
background: linear-gradient(135deg, #64748b, #334155);
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fab-icon {
|
|
||||||
font-size: 36rpx;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fab-text {
|
|
||||||
font-size: 18rpx;
|
|
||||||
color: #fff;
|
|
||||||
margin-top: 4rpx;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fab-tag {
|
|
||||||
position: absolute;
|
|
||||||
top: -8rpx;
|
|
||||||
right: -8rpx;
|
|
||||||
background: #ef4444;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 16rpx;
|
|
||||||
padding: 2rpx 8rpx;
|
|
||||||
border-radius: 8rpx;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12rpx;
|
|
||||||
animation: slide-up 0.2s ease-out;
|
|
||||||
}
|
|
||||||
@keyframes slide-up {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20rpx);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16rpx;
|
|
||||||
background: #fff;
|
|
||||||
padding: 16rpx 24rpx;
|
|
||||||
border-radius: 32rpx;
|
|
||||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
|
|
||||||
min-width: 240rpx;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background: #f0fdf4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-icon {
|
|
||||||
width: 64rpx;
|
|
||||||
height: 64rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 32rpx;
|
|
||||||
|
|
||||||
&.metronome-icon {
|
|
||||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-title {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #111827;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.menu-sub {
|
|
||||||
font-size: 20rpx;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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<number>(initialBpm)
|
|
||||||
const isPlaying = ref<boolean>(false)
|
|
||||||
const beatIndex = ref<number>(0)
|
|
||||||
const isAccent = ref<boolean>(false)
|
|
||||||
const accentEveryRef = ref<number>(options.accentEvery ?? 4)
|
|
||||||
|
|
||||||
const intervalMs = computed(() => 60000 / bpm.value)
|
|
||||||
|
|
||||||
let clickPool: AudioPool | null = null
|
|
||||||
let accentPool: AudioPool | null = null
|
|
||||||
let timer: ReturnType<typeof setTimeout> | 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<BgmMode>('none')
|
|
||||||
const enabled = ref<boolean>(true)
|
|
||||||
|
|
||||||
let trainCtx: UniApp.InnerAudioContext | null = null
|
|
||||||
let restCtx: UniApp.InnerAudioContext | null = null
|
|
||||||
let fadeTimers: ReturnType<typeof setInterval>[] = []
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="exercise-anim" :class="`anim-${animationType}`" :style="cssVars">
|
|
||||||
<view class="stage">
|
|
||||||
<!-- 哑铃弯举:摆臂 -->
|
|
||||||
<view v-if="animationType === 'curl'" class="curl-arm">
|
|
||||||
<view class="curl-forearm">
|
|
||||||
<view class="curl-dumbbell">{{ icon }}</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 哑铃推举:上下移动 -->
|
|
||||||
<view v-else-if="animationType === 'press'" class="press-wrap">
|
|
||||||
<view class="press-dumbbell left">{{ icon }}</view>
|
|
||||||
<view class="press-dumbbell right">{{ icon }}</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 哑铃侧平举:双臂张开 -->
|
|
||||||
<view v-else-if="animationType === 'sidefly'" class="sidefly-wrap">
|
|
||||||
<view class="sidefly-arm sidefly-left">
|
|
||||||
<view class="sidefly-dumbbell">{{ icon }}</view>
|
|
||||||
</view>
|
|
||||||
<view class="sidefly-arm sidefly-right">
|
|
||||||
<view class="sidefly-dumbbell">{{ icon }}</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 握力环:缩放 -->
|
|
||||||
<view v-else-if="animationType === 'grip'" class="grip-ring">
|
|
||||||
<view class="grip-ring-inner">{{ icon }}</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 卷腹:身体折叠 -->
|
|
||||||
<view v-else-if="animationType === 'crunch'" class="crunch-wrap">
|
|
||||||
<view class="crunch-body">{{ icon }}</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import type { AnimationType } from '../exercises'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
animationType: AnimationType
|
|
||||||
bpm: number
|
|
||||||
beatsPerRep?: number
|
|
||||||
icon?: string
|
|
||||||
isPlaying?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
beatsPerRep: 2,
|
|
||||||
icon: '🏋',
|
|
||||||
isPlaying: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const cssVars = computed(() => {
|
|
||||||
const repDurationMs = (60000 / props.bpm) * props.beatsPerRep
|
|
||||||
return {
|
|
||||||
'--rep-duration': `${repDurationMs}ms`,
|
|
||||||
'--anim-state': props.isPlaying ? 'running' : 'paused',
|
|
||||||
} as Record<string, string>
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.exercise-anim {
|
|
||||||
width: 100%;
|
|
||||||
height: 480rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
|
|
||||||
border-radius: 24rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stage {
|
|
||||||
width: 320rpx;
|
|
||||||
height: 320rpx;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 弯举 ===== */
|
|
||||||
.curl-arm {
|
|
||||||
width: 60rpx;
|
|
||||||
height: 240rpx;
|
|
||||||
background: #fbbf24;
|
|
||||||
border-radius: 30rpx;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.curl-forearm {
|
|
||||||
width: 60rpx;
|
|
||||||
height: 200rpx;
|
|
||||||
background: #f59e0b;
|
|
||||||
border-radius: 30rpx;
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
transform-origin: bottom center;
|
|
||||||
animation: curl-anim var(--rep-duration) ease-in-out infinite;
|
|
||||||
animation-play-state: var(--anim-state);
|
|
||||||
}
|
|
||||||
|
|
||||||
.curl-dumbbell {
|
|
||||||
position: absolute;
|
|
||||||
top: -20rpx;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
font-size: 56rpx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes curl-anim {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: rotate(-115deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 推举 ===== */
|
|
||||||
.press-wrap {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-end;
|
|
||||||
padding: 0 20rpx;
|
|
||||||
}
|
|
||||||
.press-dumbbell {
|
|
||||||
font-size: 88rpx;
|
|
||||||
animation: press-anim var(--rep-duration) ease-in-out infinite;
|
|
||||||
animation-play-state: var(--anim-state);
|
|
||||||
}
|
|
||||||
@keyframes press-anim {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(80rpx);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-80rpx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 侧平举 ===== */
|
|
||||||
.sidefly-wrap {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.sidefly-arm {
|
|
||||||
position: absolute;
|
|
||||||
width: 140rpx;
|
|
||||||
height: 24rpx;
|
|
||||||
background: #f59e0b;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
top: 50%;
|
|
||||||
transform-origin: center right;
|
|
||||||
animation: sidefly-left var(--rep-duration) ease-in-out infinite;
|
|
||||||
animation-play-state: var(--anim-state);
|
|
||||||
|
|
||||||
&.sidefly-left {
|
|
||||||
right: 50%;
|
|
||||||
}
|
|
||||||
&.sidefly-right {
|
|
||||||
left: 50%;
|
|
||||||
transform-origin: center left;
|
|
||||||
animation-name: sidefly-right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sidefly-dumbbell {
|
|
||||||
position: absolute;
|
|
||||||
top: -36rpx;
|
|
||||||
font-size: 56rpx;
|
|
||||||
}
|
|
||||||
.sidefly-left .sidefly-dumbbell {
|
|
||||||
left: -20rpx;
|
|
||||||
}
|
|
||||||
.sidefly-right .sidefly-dumbbell {
|
|
||||||
right: -20rpx;
|
|
||||||
}
|
|
||||||
@keyframes sidefly-left {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: rotate(80deg);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes sidefly-right {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: rotate(-80deg);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 握力环 ===== */
|
|
||||||
.grip-ring {
|
|
||||||
width: 240rpx;
|
|
||||||
height: 240rpx;
|
|
||||||
animation: grip-anim var(--rep-duration) ease-in-out infinite;
|
|
||||||
animation-play-state: var(--anim-state);
|
|
||||||
border: 16rpx solid #22c55e;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(34, 197, 94, 0.1);
|
|
||||||
}
|
|
||||||
.grip-ring-inner {
|
|
||||||
font-size: 80rpx;
|
|
||||||
}
|
|
||||||
@keyframes grip-anim {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(0.65);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 卷腹 ===== */
|
|
||||||
.crunch-wrap {
|
|
||||||
width: 280rpx;
|
|
||||||
height: 280rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.crunch-body {
|
|
||||||
font-size: 120rpx;
|
|
||||||
animation: crunch-anim var(--rep-duration) ease-in-out infinite;
|
|
||||||
animation-play-state: var(--anim-state);
|
|
||||||
transform-origin: bottom center;
|
|
||||||
}
|
|
||||||
@keyframes crunch-anim {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scaleY(1) translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scaleY(0.6) translateY(-10rpx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -1,610 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="training-page">
|
|
||||||
<view class="header">
|
|
||||||
<view class="header-main">
|
|
||||||
<text class="title">练一练</text>
|
|
||||||
<text class="subtitle">器械动作跟练</text>
|
|
||||||
</view>
|
|
||||||
<view class="header-link" @click="goMetronome">
|
|
||||||
🎵 节拍器
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="equipment-tabs">
|
|
||||||
<view
|
|
||||||
v-for="eq in equipmentList"
|
|
||||||
:key="eq"
|
|
||||||
class="equipment-tab"
|
|
||||||
:class="{ active: currentEquipment === eq }"
|
|
||||||
@click="onSwitchEquipment(eq)"
|
|
||||||
>
|
|
||||||
{{ eq }}
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<scroll-view scroll-x class="exercise-scroll">
|
|
||||||
<view class="exercise-list">
|
|
||||||
<view
|
|
||||||
v-for="ex in filteredExercises"
|
|
||||||
:key="ex.id"
|
|
||||||
class="exercise-card"
|
|
||||||
:class="{ active: selectedId === ex.id }"
|
|
||||||
@click="onSelect(ex.id)"
|
|
||||||
>
|
|
||||||
<text class="exercise-icon">{{ ex.icon }}</text>
|
|
||||||
<text class="exercise-name">{{ ex.name }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</scroll-view>
|
|
||||||
|
|
||||||
<view v-if="selected" class="exercise-detail">
|
|
||||||
<exercise-anim
|
|
||||||
:animation-type="selected.animationType"
|
|
||||||
:bpm="bpm"
|
|
||||||
:beats-per-rep="selected.beatsPerRep"
|
|
||||||
:icon="selected.icon"
|
|
||||||
:is-playing="metronomeIsPlaying"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<view class="beat-indicator">
|
|
||||||
<view
|
|
||||||
v-for="i in 4"
|
|
||||||
:key="i"
|
|
||||||
class="beat-dot"
|
|
||||||
:class="{ active: ((beatIndex - 1) % 4) + 1 === i && metronomeIsPlaying }"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="meta-row">
|
|
||||||
<text class="meta-label">{{ selected.description }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="settings">
|
|
||||||
<view class="setting-row">
|
|
||||||
<text class="setting-label">节奏</text>
|
|
||||||
<slider
|
|
||||||
:value="bpm"
|
|
||||||
:min="selected.bpmMin"
|
|
||||||
:max="selected.bpmMax"
|
|
||||||
:step="2"
|
|
||||||
block-size="24"
|
|
||||||
active-color="#22c55e"
|
|
||||||
@change="onBpmChange"
|
|
||||||
/>
|
|
||||||
<text class="setting-value">{{ bpm }} BPM</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="setting-row inline">
|
|
||||||
<view class="inline-item">
|
|
||||||
<text class="setting-label">组数</text>
|
|
||||||
<view class="stepper">
|
|
||||||
<text class="step-btn" @click="adjust('sets', -1)">-</text>
|
|
||||||
<text class="step-val">{{ sets }}</text>
|
|
||||||
<text class="step-btn" @click="adjust('sets', 1)">+</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="inline-item">
|
|
||||||
<text class="setting-label">次数</text>
|
|
||||||
<view class="stepper">
|
|
||||||
<text class="step-btn" @click="adjust('reps', -1)">-</text>
|
|
||||||
<text class="step-val">{{ reps }}</text>
|
|
||||||
<text class="step-btn" @click="adjust('reps', 1)">+</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="inline-item">
|
|
||||||
<text class="setting-label">休息(秒)</text>
|
|
||||||
<view class="stepper">
|
|
||||||
<text class="step-btn" @click="adjust('rest', -15)">-</text>
|
|
||||||
<text class="step-val">{{ restSec }}</text>
|
|
||||||
<text class="step-btn" @click="adjust('rest', 15)">+</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="!isTraining && !isResting && !isDone" class="action-row">
|
|
||||||
<button class="btn-primary" @click="onStart">开始训练</button>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="isTraining" class="status-card training">
|
|
||||||
<text class="status-title">训练中</text>
|
|
||||||
<text class="status-line">第 {{ currentSet }} / {{ totalSets }} 组</text>
|
|
||||||
<text class="status-line big">{{ currentRep }} / {{ totalReps }}</text>
|
|
||||||
<view class="action-row">
|
|
||||||
<button class="btn-secondary" @click="onStop">停止</button>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="isResting" class="status-card resting">
|
|
||||||
<text class="status-title">休息中</text>
|
|
||||||
<text class="status-line big">{{ restRemainingSec }}s</text>
|
|
||||||
<view class="action-row">
|
|
||||||
<button class="btn-secondary" @click="onSkipRest">跳过休息</button>
|
|
||||||
<button class="btn-text" @click="onStop">结束训练</button>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="isDone" class="status-card done">
|
|
||||||
<text class="status-title">训练完成 🎉</text>
|
|
||||||
<text class="status-line">共 {{ totalSets }} 组 × {{ totalReps }} 次</text>
|
|
||||||
<view class="action-row">
|
|
||||||
<button class="btn-primary" @click="onStart">再来一次</button>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="tips-card">
|
|
||||||
<text class="tips-title">动作要领</text>
|
|
||||||
<text v-for="(tip, i) in selected.tips" :key="i" class="tips-item">
|
|
||||||
• {{ tip }}
|
|
||||||
</text>
|
|
||||||
<text v-if="selected.contraindication" class="tips-warn">
|
|
||||||
⚠ {{ selected.contraindication }}
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
|
||||||
import { onShow, onHide } from '@dcloudio/uni-app'
|
|
||||||
import ExerciseAnim from './components/exercise-anim.vue'
|
|
||||||
import { EXERCISE_PRESETS, getPresetById } from './exercises'
|
|
||||||
import type { ExercisePreset } from './exercises'
|
|
||||||
import { useMetronome } from '@/hooks/useMetronome'
|
|
||||||
import { useTrainingSession } from '@/hooks/useTrainingSession'
|
|
||||||
import { useVoiceCoach } from '@/hooks/useVoiceCoach'
|
|
||||||
import { useTrainingBgm } from '@/hooks/useTrainingBgm'
|
|
||||||
|
|
||||||
const equipmentList = ['哑铃', '握力环', '健身环', '徒手'] as const
|
|
||||||
type Equipment = (typeof equipmentList)[number]
|
|
||||||
|
|
||||||
const currentEquipment = ref<Equipment>('哑铃')
|
|
||||||
const selectedId = ref<string>(EXERCISE_PRESETS[0].id)
|
|
||||||
|
|
||||||
const filteredExercises = computed(() =>
|
|
||||||
EXERCISE_PRESETS.filter((e) => e.equipment === currentEquipment.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
const selected = computed<ExercisePreset | undefined>(() =>
|
|
||||||
getPresetById(selectedId.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
const bpm = ref<number>(60)
|
|
||||||
const sets = ref<number>(3)
|
|
||||||
const reps = ref<number>(12)
|
|
||||||
const restSec = ref<number>(60)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
selected,
|
|
||||||
(val) => {
|
|
||||||
if (!val) return
|
|
||||||
bpm.value = val.defaultBpm
|
|
||||||
sets.value = val.defaultSets
|
|
||||||
reps.value = val.defaultReps
|
|
||||||
restSec.value = val.defaultRestSec
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
const voice = useVoiceCoach()
|
|
||||||
const bgm = useTrainingBgm()
|
|
||||||
|
|
||||||
const session = useTrainingSession()
|
|
||||||
const {
|
|
||||||
currentSet,
|
|
||||||
currentRep,
|
|
||||||
restRemainingSec,
|
|
||||||
totalReps,
|
|
||||||
totalSets,
|
|
||||||
isTraining,
|
|
||||||
isResting,
|
|
||||||
isDone,
|
|
||||||
} = session
|
|
||||||
|
|
||||||
const metronome = useMetronome({
|
|
||||||
initialBpm: bpm.value,
|
|
||||||
onBeat: () => session.onBeat(),
|
|
||||||
})
|
|
||||||
const { beatIndex, isPlaying: metronomeIsPlaying } = metronome
|
|
||||||
|
|
||||||
watch(bpm, (v) => metronome.setBpm(v))
|
|
||||||
|
|
||||||
const onSwitchEquipment = (eq: Equipment) => {
|
|
||||||
if (isTraining.value || isResting.value) return
|
|
||||||
currentEquipment.value = eq
|
|
||||||
const first = EXERCISE_PRESETS.find((e) => e.equipment === eq)
|
|
||||||
if (first) selectedId.value = first.id
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSelect = (id: string) => {
|
|
||||||
if (isTraining.value || isResting.value) return
|
|
||||||
selectedId.value = id
|
|
||||||
}
|
|
||||||
|
|
||||||
const onBpmChange = (e: any) => {
|
|
||||||
const v = e.detail?.value ?? e
|
|
||||||
bpm.value = Number(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
const adjust = (key: 'sets' | 'reps' | 'rest', delta: number) => {
|
|
||||||
if (isTraining.value || isResting.value) return
|
|
||||||
if (key === 'sets') sets.value = Math.max(1, Math.min(10, sets.value + delta))
|
|
||||||
if (key === 'reps') reps.value = Math.max(1, Math.min(50, reps.value + delta))
|
|
||||||
if (key === 'rest') restSec.value = Math.max(0, Math.min(300, restSec.value + delta))
|
|
||||||
}
|
|
||||||
|
|
||||||
const onStart = () => {
|
|
||||||
if (!selected.value) return
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
|
||||||
|
|
||||||
voice.speakPrompt('start')
|
|
||||||
|
|
||||||
session.start({
|
|
||||||
sets: sets.value,
|
|
||||||
reps: reps.value,
|
|
||||||
restSec: restSec.value,
|
|
||||||
beatsPerRep: selected.value.beatsPerRep,
|
|
||||||
onRepComplete: (rep, total) => {
|
|
||||||
if (rep <= 30) voice.speakNumber(rep)
|
|
||||||
if (rep === total - 1) voice.speakPrompt('last-rep')
|
|
||||||
},
|
|
||||||
onSetComplete: (set, total) => {
|
|
||||||
if (set < total) voice.speakPrompt('keep-it-up')
|
|
||||||
},
|
|
||||||
onEnterRest: () => {
|
|
||||||
metronome.stop()
|
|
||||||
voice.speakPrompt('rest')
|
|
||||||
bgm.switchTo('resting')
|
|
||||||
},
|
|
||||||
onExitRest: () => {
|
|
||||||
voice.speakPrompt('next-set')
|
|
||||||
bgm.switchTo('training')
|
|
||||||
metronome.start()
|
|
||||||
},
|
|
||||||
onDone: () => {
|
|
||||||
metronome.stop()
|
|
||||||
bgm.switchTo('none')
|
|
||||||
voice.speakPrompt('good-job')
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
bgm.switchTo('training')
|
|
||||||
metronome.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onStop = () => {
|
|
||||||
metronome.stop()
|
|
||||||
session.stop()
|
|
||||||
bgm.switchTo('none')
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSkipRest = () => {
|
|
||||||
session.skipRest()
|
|
||||||
}
|
|
||||||
|
|
||||||
const goMetronome = () => {
|
|
||||||
uni.navigateTo({ url: '/pages/training/metronome' })
|
|
||||||
}
|
|
||||||
|
|
||||||
onHide(() => {
|
|
||||||
if (isTraining.value || isResting.value) {
|
|
||||||
onStop()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.training-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #f9fafb;
|
|
||||||
padding: 32rpx 24rpx 80rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
|
|
||||||
.header-main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 44rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #111827;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #6b7280;
|
|
||||||
margin-top: 6rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-link {
|
|
||||||
padding: 12rpx 24rpx;
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-radius: 24rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #4b5563;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background: #e5e7eb;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.equipment-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 16rpx;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
overflow-x: auto;
|
|
||||||
|
|
||||||
.equipment-tab {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 14rpx 32rpx;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 32rpx;
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #4b5563;
|
|
||||||
border: 2rpx solid transparent;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: #22c55e;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #22c55e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-scroll {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-list {
|
|
||||||
display: flex;
|
|
||||||
gap: 16rpx;
|
|
||||||
padding-right: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 160rpx;
|
|
||||||
padding: 20rpx 12rpx;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
text-align: center;
|
|
||||||
border: 2rpx solid transparent;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
border-color: #22c55e;
|
|
||||||
background: #f0fdf4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-icon {
|
|
||||||
display: block;
|
|
||||||
font-size: 56rpx;
|
|
||||||
margin-bottom: 8rpx;
|
|
||||||
}
|
|
||||||
.exercise-name {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-detail {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.beat-indicator {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 16rpx;
|
|
||||||
padding: 8rpx 0;
|
|
||||||
|
|
||||||
.beat-dot {
|
|
||||||
width: 16rpx;
|
|
||||||
height: 16rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #d1d5db;
|
|
||||||
transition: transform 0.15s, background 0.15s;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: #22c55e;
|
|
||||||
transform: scale(1.6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-row {
|
|
||||||
.meta-label {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #6b7280;
|
|
||||||
text-align: center;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 24rpx;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16rpx;
|
|
||||||
|
|
||||||
.setting-label {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #4b5563;
|
|
||||||
min-width: 80rpx;
|
|
||||||
}
|
|
||||||
.setting-value {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #22c55e;
|
|
||||||
font-weight: 600;
|
|
||||||
min-width: 120rpx;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
slider {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.inline {
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8rpx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12rpx;
|
|
||||||
|
|
||||||
.step-btn {
|
|
||||||
width: 48rpx;
|
|
||||||
height: 48rpx;
|
|
||||||
line-height: 48rpx;
|
|
||||||
text-align: center;
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-radius: 24rpx;
|
|
||||||
font-size: 32rpx;
|
|
||||||
color: #4b5563;
|
|
||||||
}
|
|
||||||
.step-val {
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #111827;
|
|
||||||
min-width: 56rpx;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 16rpx;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
button {
|
|
||||||
flex: 1;
|
|
||||||
max-width: 320rpx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #22c55e;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
font-size: 30rpx;
|
|
||||||
height: 88rpx;
|
|
||||||
line-height: 88rpx;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #fff;
|
|
||||||
color: #22c55e;
|
|
||||||
border: 2rpx solid #22c55e;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
font-size: 28rpx;
|
|
||||||
height: 80rpx;
|
|
||||||
line-height: 76rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-text {
|
|
||||||
background: transparent;
|
|
||||||
color: #ef4444;
|
|
||||||
font-size: 26rpx;
|
|
||||||
height: 80rpx;
|
|
||||||
line-height: 80rpx;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 32rpx 24rpx;
|
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12rpx;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
&.training {
|
|
||||||
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
|
|
||||||
}
|
|
||||||
&.resting {
|
|
||||||
background: linear-gradient(135deg, #fef3c7, #fef9c3);
|
|
||||||
}
|
|
||||||
&.done {
|
|
||||||
background: linear-gradient(135deg, #dbeafe, #ede9fe);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-title {
|
|
||||||
font-size: 30rpx;
|
|
||||||
color: #4b5563;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.status-line {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #6b7280;
|
|
||||||
&.big {
|
|
||||||
font-size: 64rpx;
|
|
||||||
color: #111827;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tips-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 24rpx;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8rpx;
|
|
||||||
|
|
||||||
.tips-title {
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #111827;
|
|
||||||
margin-bottom: 8rpx;
|
|
||||||
}
|
|
||||||
.tips-item {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #4b5563;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.tips-warn {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #ef4444;
|
|
||||||
margin-top: 12rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="metronome-page">
|
|
||||||
<view class="pulse-stage" @click="togglePlay">
|
|
||||||
<view
|
|
||||||
class="pulse-ring"
|
|
||||||
:class="{ playing: isPlaying }"
|
|
||||||
:style="ringStyle"
|
|
||||||
/>
|
|
||||||
<view
|
|
||||||
class="pulse-ring pulse-ring-2"
|
|
||||||
:class="{ playing: isPlaying }"
|
|
||||||
:style="ringStyle"
|
|
||||||
/>
|
|
||||||
<view
|
|
||||||
class="pulse-core"
|
|
||||||
:class="{ playing: isPlaying, accent: isAccent }"
|
|
||||||
:style="coreStyle"
|
|
||||||
>
|
|
||||||
<text class="bpm-num">{{ bpm }}</text>
|
|
||||||
<text class="bpm-label">速度</text>
|
|
||||||
<view class="ctrl-icon">
|
|
||||||
<view v-if="isPlaying" class="icon-pause">
|
|
||||||
<view class="bar" />
|
|
||||||
<view class="bar" />
|
|
||||||
</view>
|
|
||||||
<view v-else class="icon-play" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="bpm-control">
|
|
||||||
<view class="step-btn big" @click="adjustBpm(-10)">
|
|
||||||
<text class="step-num">-10</text>
|
|
||||||
</view>
|
|
||||||
<view class="step-btn small" @click="adjustBpm(-1)">
|
|
||||||
<text class="step-num">-1</text>
|
|
||||||
</view>
|
|
||||||
<view class="step-btn small" @click="adjustBpm(1)">
|
|
||||||
<text class="step-num">+1</text>
|
|
||||||
</view>
|
|
||||||
<view class="step-btn big" @click="adjustBpm(10)">
|
|
||||||
<text class="step-num">+10</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="meter-section">
|
|
||||||
<text class="section-label">每组拍数</text>
|
|
||||||
<view class="meter-options">
|
|
||||||
<view
|
|
||||||
v-for="m in METER_OPTIONS"
|
|
||||||
:key="m.value"
|
|
||||||
class="meter-btn"
|
|
||||||
:class="{ active: accentEvery === m.value }"
|
|
||||||
@click="onMeterChange(m.value)"
|
|
||||||
>
|
|
||||||
<text class="meter-label">{{ m.label }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="hint">
|
|
||||||
<text>{{ statusHint }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onUnmounted } from 'vue'
|
|
||||||
import { onShow, onHide } from '@dcloudio/uni-app'
|
|
||||||
import { useMetronome } from '@/hooks/useMetronome'
|
|
||||||
|
|
||||||
const METER_OPTIONS = [
|
|
||||||
{ label: '2 拍', value: 2 },
|
|
||||||
{ label: '3 拍', value: 3 },
|
|
||||||
{ label: '4 拍', value: 4 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const metronome = useMetronome({
|
|
||||||
initialBpm: 80,
|
|
||||||
accentEvery: 4,
|
|
||||||
clickSrc: '/static/training/audio/click.mp3',
|
|
||||||
accentSrc: '/static/training/audio/click-wood.mp3',
|
|
||||||
})
|
|
||||||
const {
|
|
||||||
bpm,
|
|
||||||
isPlaying,
|
|
||||||
beatIndex,
|
|
||||||
isAccent,
|
|
||||||
accentEvery,
|
|
||||||
intervalMs,
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
setBpm,
|
|
||||||
setAccentEvery,
|
|
||||||
preload,
|
|
||||||
} = metronome
|
|
||||||
|
|
||||||
const adjustBpm = (delta: number) => {
|
|
||||||
setBpm(bpm.value + delta)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onMeterChange = (val: number) => {
|
|
||||||
setAccentEvery(val)
|
|
||||||
}
|
|
||||||
|
|
||||||
const togglePlay = () => {
|
|
||||||
if (isPlaying.value) {
|
|
||||||
stop()
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
|
||||||
} else {
|
|
||||||
start()
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ringStyle = computed(() => ({
|
|
||||||
'--beat-duration': `${intervalMs.value}ms`,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const coreStyle = computed(() => ({
|
|
||||||
'--beat-duration': `${intervalMs.value}ms`,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const statusHint = computed(() => {
|
|
||||||
if (isPlaying.value) {
|
|
||||||
const cur = ((beatIndex.value - 1) % accentEvery.value) + 1
|
|
||||||
return `第 ${cur} 拍 / 共 ${accentEvery.value} 拍`
|
|
||||||
}
|
|
||||||
return '轻点圆圈开始'
|
|
||||||
})
|
|
||||||
|
|
||||||
onShow(() => {
|
|
||||||
preload()
|
|
||||||
})
|
|
||||||
|
|
||||||
onHide(() => {
|
|
||||||
if (isPlaying.value) {
|
|
||||||
stop()
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.metronome-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 20rpx 32rpx 80rpx;
|
|
||||||
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
color: #f1f5f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 中心可点击圆 ===== */
|
|
||||||
.pulse-stage {
|
|
||||||
position: relative;
|
|
||||||
width: 600rpx;
|
|
||||||
height: 600rpx;
|
|
||||||
margin: 40rpx 0 50rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:active .pulse-core {
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pulse-ring {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 4rpx solid rgba(34, 197, 94, 0.18);
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
&.playing {
|
|
||||||
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.pulse-ring-2 {
|
|
||||||
animation-delay: calc(var(--beat-duration, 750ms) * -0.4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes ring-pulse {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.92);
|
|
||||||
opacity: 0.9;
|
|
||||||
border-color: rgba(34, 197, 94, 0.55);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1.18);
|
|
||||||
opacity: 0;
|
|
||||||
border-color: rgba(34, 197, 94, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pulse-core {
|
|
||||||
position: relative;
|
|
||||||
width: 460rpx;
|
|
||||||
height: 460rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(140deg, #22c55e 0%, #15803d 100%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 -20rpx 60rpx rgba(0, 0, 0, 0.18),
|
|
||||||
inset 0 20rpx 60rpx rgba(255, 255, 255, 0.12),
|
|
||||||
0 24rpx 60rpx rgba(34, 197, 94, 0.45);
|
|
||||||
transition: transform 0.12s, background 0.15s, box-shadow 0.15s;
|
|
||||||
|
|
||||||
&.playing {
|
|
||||||
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.playing.accent {
|
|
||||||
background: linear-gradient(140deg, #fbbf24 0%, #b45309 100%);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 -20rpx 60rpx rgba(0, 0, 0, 0.18),
|
|
||||||
inset 0 20rpx 60rpx rgba(255, 255, 255, 0.18),
|
|
||||||
0 24rpx 60rpx rgba(245, 158, 11, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bpm-num {
|
|
||||||
font-size: 200rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #fff;
|
|
||||||
line-height: 1;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bpm-label {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
letter-spacing: 8rpx;
|
|
||||||
margin-top: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 中央播放/暂停 icon(CSS 几何绘制,跨机型一致) ===== */
|
|
||||||
.ctrl-icon {
|
|
||||||
margin-top: 36rpx;
|
|
||||||
width: 110rpx;
|
|
||||||
height: 90rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-play {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-left: 70rpx solid #fff;
|
|
||||||
border-top: 44rpx solid transparent;
|
|
||||||
border-bottom: 44rpx solid transparent;
|
|
||||||
margin-left: 16rpx;
|
|
||||||
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.2));
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-pause {
|
|
||||||
display: flex;
|
|
||||||
gap: 22rpx;
|
|
||||||
height: 84rpx;
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
width: 22rpx;
|
|
||||||
height: 100%;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 6rpx;
|
|
||||||
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes core-beat {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.045);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== BPM 步进按钮 ===== */
|
|
||||||
.bpm-control {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 20rpx;
|
|
||||||
margin-bottom: 60rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-btn {
|
|
||||||
flex: 1;
|
|
||||||
height: 120rpx;
|
|
||||||
border-radius: 24rpx;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border: 2rpx solid rgba(255, 255, 255, 0.12);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background: rgba(34, 197, 94, 0.25);
|
|
||||||
border-color: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-num {
|
|
||||||
font-size: 44rpx;
|
|
||||||
color: #f1f5f9;
|
|
||||||
font-weight: 700;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.big .step-num {
|
|
||||||
font-size: 48rpx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 拍号 ===== */
|
|
||||||
.meter-section {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #94a3b8;
|
|
||||||
margin-bottom: 20rpx;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meter-options {
|
|
||||||
display: flex;
|
|
||||||
gap: 20rpx;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
.meter-btn {
|
|
||||||
flex: 1;
|
|
||||||
max-width: 180rpx;
|
|
||||||
height: 100rpx;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 2rpx solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 20rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
.meter-label {
|
|
||||||
font-size: 36rpx;
|
|
||||||
color: #cbd5e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: #22c55e;
|
|
||||||
border-color: #22c55e;
|
|
||||||
|
|
||||||
.meter-label {
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 状态提示 ===== */
|
|
||||||
.hint {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #64748b;
|
|
||||||
margin-top: 32rpx;
|
|
||||||
height: 40rpx;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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 即可。
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user