Files
zyt/TUICallKit-Vue3/pages/training/metronome.vue
T
longandCursor 9b6d9ce007 refactor(metronome): 整体视觉重设计 - 现代极简风
脚印拟真化:
- 颜色 鲜绿 → 浅白(rgba 241,245,249,0.92),降低饱和不再卡通
- 形状更紧凑:脚趾紧贴脚掌、大小递减弧形排布(22→18→16→14→12rpx)
- 不规则 border-radius 模拟真实脚印轮廓
- 移除多余渐变和厚重 box-shadow

布局重组(卡片化):
- 走路区域 + 配速 badge + 状态提示  → walker-card 统一卡片
- BPM 调节 + 拍号选择            → control-card 统一卡片
- 卡片背景 rgba(255,255,255,0.035) + 1rpx 描边,有 backdrop-blur
- 移除独立 hint 区,合并到 walker-card 顶部

控件精修:
- BPM 步进按钮 高度 120→96rpx,字号 44→38rpx,圆角更小
- 拍号选择器改为 iOS 风药丸式 segmented control,放在右侧
- 主圆 460→420rpx,BPM 字号 200→180rpx,腾出空间给下方
- "速度"标签 → "BPM"(更专业)

配速 badge 智能变色:
- <100 BPM  绿(健走)
- 100-129  黄(快走)
- 130-169  橙(慢跑)
- ≥170     红(快跑)

设计 token 抽取:
- 在 SCSS 顶部定义 $bg-grad / $card-bg / $text-* / $brand 等变量
- 圆角统一:卡片 28rpx, 按钮 20rpx, 药丸 999rpx
- 间距统一:卡片间距 28rpx, 控件间隔 14rpx

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

696 lines
17 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">
<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" />
</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%
);
}
/* ============================================================
* 拟真脚印(单色,内敛)
* ============================================================ */
.foot {
position: absolute;
bottom: 18rpx;
width: 100rpx;
height: 130rpx;
.footprint {
position: relative;
width: 100%;
height: 100%;
transform-origin: bottom center;
}
/* 脚跟+脚掌一体椭圆,不规则更像真实脚印 */
.heel {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 72rpx;
height: 86rpx;
background: rgba(241, 245, 249, 0.92);
border-radius: 48% 48% 50% 50% / 56% 56% 44% 44%;
}
/* 5 个脚趾 - 紧贴脚掌,大小递减,呈弧形排布 */
.toe {
position: absolute;
background: rgba(241, 245, 249, 0.92);
border-radius: 50%;
}
.toe-1 {
/* 大脚趾,内侧最大 */
width: 22rpx;
height: 22rpx;
bottom: 86rpx;
left: 16rpx;
}
.toe-2 {
width: 18rpx;
height: 18rpx;
bottom: 92rpx;
left: 36rpx;
}
.toe-3 {
width: 16rpx;
height: 16rpx;
bottom: 92rpx;
left: 52rpx;
}
.toe-4 {
width: 14rpx;
height: 14rpx;
bottom: 88rpx;
left: 66rpx;
}
.toe-5 {
/* 小脚趾,外侧最小 */
width: 12rpx;
height: 12rpx;
bottom: 82rpx;
left: 78rpx;
}
.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>