之前误将训练模块加在 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>
260 lines
5.9 KiB
Vue
260 lines
5.9 KiB
Vue
<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>
|