Files
zyt/TUICallKit-Vue3/pages/training/index.vue
T
longandCursor 2beace89f4 feat(TUICallKit-Vue3): 迁入节拍器与练一练训练模块
之前误将训练模块加在 uniapp 项目,实际目标项目是 TUICallKit-Vue3
(甄养堂主小程序壳),整体迁入:

- hooks/useMetronome.ts  节拍器(音频实例池+预热,无冷启动延迟)
- hooks/useTrainingSession.ts  训练 Session 状态机
- hooks/useVoiceCoach.ts  语音教练队列
- hooks/useTrainingBgm.ts  训练/休息 BGM 切换+渐变
- pages/training/index.vue  练一练主页(器械跟练)
- pages/training/metronome.vue  独立节拍器(老人友好版,
  中央圆即开始/暂停按钮,纯 CSS 几何 icon,大字大按钮)
- pages/training/components/exercise-anim.vue  CSS 动作动画
- pages/training/exercises.ts  5 个器械动作预设
- static/training/audio/*.mp3  3 种 click 音色(MIT 协议)
- scripts/generate-voice.mjs  MiMo TTS 批量生成语音脚本
- pages.json 注册 pages/training/index 与 pages/training/metronome
- 「我的」页加 DevTrainingEntry 悬浮入口,生产环境自动隐藏

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 11:28:55 +08:00

611 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>