Files
zyt/TUICallKit-Vue3/pages/training/metronome.vue
T
longandCursor 69da8066a6 feat(metronome): 节拍器下方加脚步行走动画
- 节拍器圆下方新增"脚印踏步"动画区域
  · 纯 CSS+SVG-like(div 拼接),不依赖 canvas/Lottie/视频
  · 左右脚交替踏步,起脚时落地阴影变小变淡,落地时变大变浓
  · 1 拍 1 步,左右各占半周期(animation-delay 错相 0.5)
- 跟节拍器同步:CSS 变量 --step-duration = 60000/bpm,BPM 改了
  步频立刻跟着变;暂停时 animation-play-state 自动停
- 智能配速提示:
  · <70 BPM   慢走 · 适合热身
  · 70-99    健走 · 日常配速
  · 100-129  快走 · 心率提升
  · 130-169  慢跑 · 进入有氧
  · ≥170     快跑 · 高强度
- 重构 ringStyle/coreStyle 为统一 rootStyle,挂在根节点用
  CSS 变量级联(--beat-duration / --step-duration)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 12:10:44 +08:00

609 lines
14 KiB
Vue

<template>
<view class="metronome-page" :style="rootStyle">
<view class="pulse-stage" @click="togglePlay">
<view class="pulse-ring" :class="{ playing: isPlaying }" />
<view class="pulse-ring pulse-ring-2" :class="{ playing: isPlaying }" />
<view
class="pulse-core"
:class="{ playing: isPlaying, accent: isAccent }"
>
<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>
<!-- 脚步行走动画:跟随 BPM 同步,1 1 -->
<view class="walker" :class="{ playing: isPlaying }">
<view class="foot foot-left">
<view class="footprint">
<view class="heel" />
<view class="toe toe-1" />
<view class="toe toe-2" />
<view class="toe toe-3" />
<view class="toe toe-4" />
<view class="toe toe-5" />
</view>
<view class="shadow" />
</view>
<view class="foot foot-right">
<view class="footprint">
<view class="heel" />
<view class="toe toe-1" />
<view class="toe toe-2" />
<view class="toe toe-3" />
<view class="toe toe-4" />
<view class="toe toe-5" />
</view>
<view class="shadow" />
</view>
<view class="ground" />
<text class="walker-tip">{{ walkerTip }}</text>
</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 rootStyle = computed(() => ({
'--beat-duration': `${intervalMs.value}ms`,
'--step-duration': `${intervalMs.value}ms`,
}))
const statusHint = computed(() => {
if (isPlaying.value) {
const cur = ((beatIndex.value - 1) % accentEvery.value) + 1
return `第 ${cur} 拍 / 共 ${accentEvery.value} 拍`
}
return '轻点圆圈开始'
})
const walkerTip = computed(() => {
if (!isPlaying.value) return '开始后跟节奏踏步'
if (bpm.value < 70) return '慢走 · 适合热身'
if (bpm.value < 100) return '健走 · 日常配速'
if (bpm.value < 130) return '快走 · 心率提升'
if (bpm.value < 170) return '慢跑 · 进入有氧'
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);
}
}
/* ===== 脚步行走动画 ===== */
.walker {
position: relative;
width: 100%;
height: 220rpx;
margin-bottom: 50rpx;
display: flex;
justify-content: center;
align-items: flex-end;
overflow: hidden;
}
.walker .ground {
position: absolute;
bottom: 36rpx;
left: 50%;
transform: translateX(-50%);
width: 480rpx;
height: 2rpx;
background: linear-gradient(
90deg,
transparent,
rgba(34, 197, 94, 0.45) 30%,
rgba(34, 197, 94, 0.45) 70%,
transparent
);
}
.walker .walker-tip {
position: absolute;
top: 12rpx;
left: 50%;
transform: translateX(-50%);
font-size: 28rpx;
color: #cbd5e1;
letter-spacing: 2rpx;
padding: 6rpx 24rpx;
background: rgba(255, 255, 255, 0.06);
border-radius: 24rpx;
}
.foot {
position: absolute;
bottom: 36rpx;
width: 110rpx;
height: 130rpx;
.footprint {
position: relative;
width: 100%;
height: 100%;
transform-origin: bottom center;
}
/* 脚跟+脚掌:一颗椭圆形 */
.heel {
position: absolute;
bottom: 4rpx;
left: 50%;
transform: translateX(-50%);
width: 78rpx;
height: 90rpx;
background: linear-gradient(135deg, #22c55e, #16a34a);
border-radius: 50% 50% 46% 46% / 56% 56% 44% 44%;
box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.15);
}
/* 5 个脚趾(右脚视角:从左到右大→小) */
.toe {
position: absolute;
background: #16a34a;
border-radius: 50%;
}
.toe-1 {
width: 22rpx;
height: 24rpx;
bottom: 96rpx;
left: 18rpx;
}
.toe-2 {
width: 18rpx;
height: 20rpx;
bottom: 102rpx;
left: 40rpx;
}
.toe-3 {
width: 16rpx;
height: 18rpx;
bottom: 100rpx;
left: 58rpx;
}
.toe-4 {
width: 14rpx;
height: 16rpx;
bottom: 94rpx;
left: 72rpx;
}
.toe-5 {
width: 12rpx;
height: 14rpx;
bottom: 86rpx;
left: 84rpx;
}
.shadow {
position: absolute;
bottom: -8rpx;
left: 50%;
transform: translateX(-50%) scaleX(1);
width: 90rpx;
height: 12rpx;
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0.4),
transparent 70%
);
border-radius: 50%;
}
}
/* 左脚:置左,内层 footprint 镜像翻转(大脚趾朝外侧) */
.foot-left {
left: calc(50% - 130rpx);
}
.foot-left .footprint {
transform: scaleX(-1);
}
/* 右脚:置右 */
.foot-right {
left: calc(50% + 20rpx);
}
/* 跟节拍同步:1 拍 1 步,左右各占半周期(0.5 偏移) */
.walker.playing {
.foot-left .footprint {
animation: foot-step-left var(--step-duration, 750ms) ease-in-out infinite;
}
.foot-right .footprint {
animation: foot-step-right var(--step-duration, 750ms) ease-in-out infinite;
animation-delay: calc(var(--step-duration, 750ms) * -0.5);
}
.foot-left .shadow {
animation: shadow-pulse var(--step-duration, 750ms) ease-in-out infinite;
}
.foot-right .shadow {
animation: shadow-pulse var(--step-duration, 750ms) ease-in-out infinite;
animation-delay: calc(var(--step-duration, 750ms) * -0.5);
}
}
@keyframes foot-step-right {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(-44rpx) rotate(8deg);
}
50% {
transform: translateY(-2rpx) rotate(0deg);
}
75% {
transform: translateY(0) rotate(0deg);
}
}
/* 左脚保留 scaleX(-1) 镜像 */
@keyframes foot-step-left {
0%,
100% {
transform: scaleX(-1) translateY(0) rotate(0deg);
}
25% {
transform: scaleX(-1) translateY(-44rpx) rotate(8deg);
}
50% {
transform: scaleX(-1) translateY(-2rpx) rotate(0deg);
}
75% {
transform: scaleX(-1) translateY(0) rotate(0deg);
}
}
@keyframes shadow-pulse {
0%,
100% {
opacity: 0.55;
transform: translateX(-50%) scaleX(1);
}
25% {
/* 抬脚最高时阴影最淡最小 */
opacity: 0.15;
transform: translateX(-50%) scaleX(0.55);
}
50% {
/* 落地瞬间阴影最浓 */
opacity: 0.7;
transform: translateX(-50%) scaleX(1.1);
}
}
/* ===== 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>