Files
zyt/TUICallKit-Vue3/pages/training/metronome.vue
T
longandCursor e104249707 style(metronome): 脚印改用 SVG 矢量,单笔成型告别卡通感
div 拼接(椭圆+5 个圆)无论怎么调都摆脱不了"青蛙脚"既视感,
根本原因是 div 画不出曲线轮廓。改用 SVG path 单笔成型:

- 新增 static/training/footprint.svg(775B)
  · path 一笔走完:前足→足弓凹陷→脚跟,模拟真实脚印拓印
  · 5 个脚趾用 ellipse + rotate 转角,大小递减(7.2→3.8rpx)
  · 内侧(拇趾侧)凹陷、外侧饱满,符合解剖学
  · 单色 #e2e8f0 fill-opacity 0.92,接近湿沙脚印质感

- metronome.vue:
  · 模板:删 heel + 5×toe,改用 image src=footprint.svg
  · CSS:删除 70+ 行 heel/toe 位置定位,只保留 footprint 容器
  · 左脚保持 scaleX(-1) 镜像,动画 keyframe 不变

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

639 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="page" :style="rootStyle">
<!-- ===== 主舞台:节拍器圆 ===== -->
<view class="stage" @click="togglePlay">
<view class="ring r1" :class="{ playing: isPlaying }" />
<view class="ring r2" :class="{ playing: isPlaying }" />
<view class="core" :class="{ playing: isPlaying, accent: isAccent }">
<text class="bpm-num">{{ bpm }}</text>
<text class="bpm-label">BPM</text>
<view class="ctrl">
<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="walker-card">
<view class="walker-header">
<view class="badge" :class="badgeClass">
<view class="badge-dot" />
<text>{{ walkerTip }}</text>
</view>
<text class="status">{{ statusHint }}</text>
</view>
<view class="walker" :class="{ playing: isPlaying }">
<view class="foot foot-left">
<image
class="footprint"
src="/static/training/footprint.svg"
mode="aspectFit"
/>
<view class="shadow" />
</view>
<view class="foot foot-right">
<image
class="footprint"
src="/static/training/footprint.svg"
mode="aspectFit"
/>
<view class="shadow" />
</view>
<view class="ground" />
</view>
</view>
<!-- ===== 控制卡片:BPM + 拍号 ===== -->
<view class="control-card">
<view class="bpm-row">
<view class="step-btn" @click="adjustBpm(-10)">
<text class="step-num">10</text>
</view>
<view class="step-btn" @click="adjustBpm(-1)">
<text class="step-num">1</text>
</view>
<view class="step-btn" @click="adjustBpm(1)">
<text class="step-num">+1</text>
</view>
<view class="step-btn" @click="adjustBpm(10)">
<text class="step-num">+10</text>
</view>
</view>
<view class="divider" />
<view class="meter-row">
<text class="meter-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>{{ m.label }}</text>
</view>
</view>
</view>
</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 (bpm.value < 70) return '慢走 · 热身'
if (bpm.value < 100) return '健走 · 日常'
if (bpm.value < 130) return '快走 · 提速'
if (bpm.value < 170) return '慢跑 · 有氧'
return '快跑 · 高强'
})
const badgeClass = computed(() => {
if (bpm.value < 100) return 'badge-mild'
if (bpm.value < 130) return 'badge-mid'
if (bpm.value < 170) return 'badge-strong'
return 'badge-hot'
})
onShow(() => {
preload()
})
onHide(() => {
if (isPlaying.value) {
stop()
uni.setKeepScreenOn({ keepScreenOn: false })
}
})
onUnmounted(() => {
uni.setKeepScreenOn({ keepScreenOn: false })
})
</script>
<style lang="scss" scoped>
/* ============================================================
* 设计 token
* ============================================================ */
$bg-grad: linear-gradient(180deg, #0a0f1c 0%, #141a2a 100%);
$card-bg: rgba(255, 255, 255, 0.035);
$card-border: rgba(255, 255, 255, 0.06);
$text-1: #f1f5f9;
$text-2: #cbd5e1;
$text-3: #64748b;
$brand: #22c55e;
$brand-deep: #15803d;
$accent: #f59e0b;
$accent-deep: #b45309;
$radius-card: 28rpx;
$radius-btn: 20rpx;
/* ============================================================
* 页面容器
* ============================================================ */
.page {
min-height: 100vh;
padding: 30rpx 28rpx 60rpx;
background: $bg-grad;
display: flex;
flex-direction: column;
align-items: center;
gap: 28rpx;
color: $text-1;
}
/* ============================================================
* 节拍器主舞台
* ============================================================ */
.stage {
position: relative;
width: 540rpx;
height: 540rpx;
margin-top: 20rpx;
display: flex;
align-items: center;
justify-content: center;
&:active .core {
transform: scale(0.97);
}
}
.ring {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
border: 3rpx solid rgba(34, 197, 94, 0.14);
pointer-events: none;
&.playing {
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
}
&.r2.playing {
animation-delay: calc(var(--beat-duration, 750ms) * -0.5);
}
}
@keyframes ring-pulse {
0% {
transform: scale(0.94);
opacity: 0.85;
border-color: rgba(34, 197, 94, 0.45);
}
100% {
transform: scale(1.18);
opacity: 0;
border-color: rgba(34, 197, 94, 0);
}
}
.core {
position: relative;
width: 420rpx;
height: 420rpx;
border-radius: 50%;
background: linear-gradient(140deg, $brand 0%, $brand-deep 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow:
inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.22),
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.1),
0 20rpx 50rpx rgba(34, 197, 94, 0.32);
transition: transform 0.12s, background 0.18s, box-shadow 0.18s;
&.playing {
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
}
&.playing.accent {
background: linear-gradient(140deg, #fbbf24 0%, $accent-deep 100%);
box-shadow:
inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.22),
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.18),
0 20rpx 50rpx rgba(245, 158, 11, 0.4);
}
.bpm-num {
font-size: 180rpx;
font-weight: 800;
color: #fff;
line-height: 1;
letter-spacing: -2rpx;
font-variant-numeric: tabular-nums;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.18);
}
.bpm-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.65);
letter-spacing: 6rpx;
margin-top: 10rpx;
font-weight: 600;
}
.ctrl {
margin-top: 26rpx;
height: 76rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
@keyframes core-beat {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.04);
}
}
/* 播放/暂停 icon (CSS 几何) */
.icon-play {
width: 0;
height: 0;
border-left: 60rpx solid #fff;
border-top: 38rpx solid transparent;
border-bottom: 38rpx solid transparent;
margin-left: 14rpx;
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.2));
}
.icon-pause {
display: flex;
gap: 18rpx;
height: 70rpx;
.bar {
width: 18rpx;
height: 100%;
background: #fff;
border-radius: 5rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.2);
}
}
/* ============================================================
* 走路动画卡片
* ============================================================ */
.walker-card {
width: 100%;
background: $card-bg;
border: 1rpx solid $card-border;
border-radius: $radius-card;
padding: 24rpx 28rpx 32rpx;
backdrop-filter: blur(20rpx);
}
.walker-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.badge {
display: flex;
align-items: center;
gap: 10rpx;
padding: 8rpx 20rpx;
background: rgba(34, 197, 94, 0.12);
border: 1rpx solid rgba(34, 197, 94, 0.3);
border-radius: 999rpx;
font-size: 24rpx;
color: $brand;
font-weight: 600;
.badge-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8rpx currentColor;
}
&.badge-mild {
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.3);
color: #4ade80;
}
&.badge-mid {
background: rgba(251, 191, 36, 0.12);
border-color: rgba(251, 191, 36, 0.3);
color: #fbbf24;
}
&.badge-strong {
background: rgba(249, 115, 22, 0.12);
border-color: rgba(249, 115, 22, 0.3);
color: #fb923c;
}
&.badge-hot {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.32);
color: #f87171;
}
}
.status {
font-size: 24rpx;
color: $text-3;
letter-spacing: 1rpx;
}
.walker {
position: relative;
width: 100%;
height: 200rpx;
display: flex;
justify-content: center;
align-items: flex-end;
}
.walker .ground {
position: absolute;
bottom: 18rpx;
left: 10%;
right: 10%;
height: 1rpx;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.18) 30%,
rgba(255, 255, 255, 0.18) 70%,
transparent 100%
);
}
/* ============================================================
* 拟真脚印(SVG 矢量,单笔成型)
* ============================================================ */
.foot {
position: absolute;
bottom: 18rpx;
width: 100rpx;
height: 140rpx;
.footprint {
display: block;
width: 100%;
height: 100%;
transform-origin: bottom center;
}
.shadow {
position: absolute;
bottom: -6rpx;
left: 50%;
transform: translateX(-50%);
width: 76rpx;
height: 8rpx;
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0.45),
transparent 70%
);
border-radius: 50%;
}
}
.foot-left {
left: calc(50% - 124rpx);
}
.foot-left .footprint {
transform: scaleX(-1);
}
.foot-right {
left: calc(50% + 24rpx);
}
/* 跟节拍同步:1 拍 1 步,左右各占半周期 */
.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(-40rpx) rotate(8deg);
}
50% {
transform: translateY(-2rpx) rotate(0deg);
}
75% {
transform: translateY(0) rotate(0deg);
}
}
@keyframes foot-step-left {
0%,
100% {
transform: scaleX(-1) translateY(0) rotate(0deg);
}
25% {
transform: scaleX(-1) translateY(-40rpx) 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.18;
transform: translateX(-50%) scaleX(0.5);
}
50% {
opacity: 0.7;
transform: translateX(-50%) scaleX(1.08);
}
}
/* ============================================================
* 控制卡片(BPM + 拍号)
* ============================================================ */
.control-card {
width: 100%;
background: $card-bg;
border: 1rpx solid $card-border;
border-radius: $radius-card;
padding: 28rpx;
backdrop-filter: blur(20rpx);
}
.bpm-row {
display: flex;
gap: 14rpx;
}
.step-btn {
flex: 1;
height: 96rpx;
border-radius: $radius-btn;
background: rgba(255, 255, 255, 0.05);
border: 1rpx solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
&:active {
background: rgba(34, 197, 94, 0.18);
border-color: rgba(34, 197, 94, 0.5);
transform: scale(0.96);
}
.step-num {
font-size: 38rpx;
color: $text-1;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -1rpx;
}
}
.divider {
height: 1rpx;
background: rgba(255, 255, 255, 0.06);
margin: 24rpx 0;
}
.meter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.meter-label {
font-size: 26rpx;
color: $text-3;
letter-spacing: 1rpx;
}
.meter-options {
display: flex;
gap: 10rpx;
background: rgba(255, 255, 255, 0.04);
padding: 6rpx;
border-radius: 999rpx;
border: 1rpx solid rgba(255, 255, 255, 0.05);
}
.meter-btn {
min-width: 84rpx;
height: 60rpx;
padding: 0 20rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.18s;
text {
font-size: 30rpx;
color: $text-2;
font-weight: 600;
}
&.active {
background: $brand;
box-shadow: 0 4rpx 12rpx rgba(34, 197, 94, 0.4);
text {
color: #fff;
}
}
}
</style>