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