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>
This commit is contained in:
2026-05-25 11:28:55 +08:00
co-authored by Cursor
parent e5d0d7db93
commit 2beace89f4
19 changed files with 2423 additions and 19 deletions
@@ -0,0 +1,259 @@
<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>