Files
zyt/TUICallKit-Vue3/pages/training/metronome.vue
T
longandCursor 6be5b4c45d feat(metronome): walker 卡片改左右布局 + 圆形蒙版小人
老布局问题:视频被困在小白卡里、灰白渐变底跟暗卡片不搭、
左右大量空白、右上角小窗按钮没办法靠属性彻底隐藏。

新布局:
- 圆形蒙版裁切 video,灰白底自动收成柔和光晕,
  原生右上角小窗/投屏按钮被切到圆外,看不见
- video 加 transform:scale(1.08) 让小人居中且裁掉边缘灰边
- 圆外加随节拍脉动的彩色 glow,颜色跟 badge 联动
  (健走绿/快走黄/慢跑橙/快跑红)
- 视频右侧填充节奏数据三行:
  · 每分钟 X 步
  · 每步耗时 X ms
  · 动画速度 X ×
  把空白填上有意义的信息层级

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

749 lines
19 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="runner-orb">
<view class="orb-glow" :class="badgeClass" />
<view class="orb-clip">
<video
id="runner-video"
class="runner-video"
:src="RUNNER_VIDEO_URL"
:muted="true"
:loop="true"
:autoplay="true"
:show-controls="false"
:show-fullscreen-btn="false"
:show-play-btn="false"
:show-center-play-btn="false"
:enable-progress-gesture="false"
:show-mute-btn="false"
:picture-in-picture-mode="[]"
object-fit="cover"
@error="onVideoError"
@loadedmetadata="onVideoLoaded"
/>
</view>
</view>
<view class="walker-stats">
<view class="stat-row">
<text class="stat-label">每分钟</text>
<view class="stat-value">
<text class="stat-num">{{ bpm }}</text>
<text class="stat-unit"></text>
</view>
</view>
<view class="stat-row">
<text class="stat-label">每步耗时</text>
<view class="stat-value">
<text class="stat-num">{{ intervalMs }}</text>
<text class="stat-unit">ms</text>
</view>
</view>
<view class="stat-row">
<text class="stat-label">动画速度</text>
<view class="stat-value">
<text class="stat-num">{{ playbackRate.toFixed(2) }}</text>
<text class="stat-unit">×</text>
</view>
</view>
</view>
</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, onMounted, onUnmounted, ref, watch } from 'vue'
import { onShow, onHide } from '@dcloudio/uni-app'
import { useMetronome } from '@/hooks/useMetronome'
/**
* 跑步动画视频(腾讯云 COS CDN)
* 注意:小程序后台需将 cos.ap-guangzhou.myqcloud.com 加入 downloadFile 合法域名,
* 否则正式发布版会被拦截(开发版默认不校验)
*/
const RUNNER_VIDEO_URL =
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260525/20260525164014ef89f8862.mp4'
/**
* 视频原速对应的 BPM 基准
* runner.mp4 时长 0.97s, 一个 walk cycle (左右脚各一次) ≈ 124 BPM 步频
* 用户调速时 playbackRate = bpm / VIDEO_BASE_BPM
* 微信小程序限制 playback-rate 范围 [0.5, 2.0]
*/
const VIDEO_BASE_BPM = 124
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 })
}
}
/* 视频实例与 BPM 同步 */
let videoCtx: UniApp.VideoContext | null = null
const playbackRate = computed(() => {
const rate = bpm.value / VIDEO_BASE_BPM
return Math.max(0.5, Math.min(2.0, +rate.toFixed(2)))
})
onMounted(() => {
videoCtx = uni.createVideoContext('runner-video')
/* 进页面默认让视频保持暂停,跟节拍器 isPlaying 状态同步 */
setTimeout(() => videoCtx?.pause(), 50)
})
const onVideoError = (e: any) => {
console.error('[runner-video] error:', e?.detail || e)
uni.showToast({
title: '视频加载失败,请用真机预览',
icon: 'none',
duration: 2500,
})
}
const onVideoLoaded = (e: any) => {
console.log('[runner-video] metadata loaded:', e?.detail)
/* 元数据加载完后再设 playbackRate,部分基础库初始化时设非 1.0 会卡 */
try {
videoCtx?.playbackRate?.(playbackRate.value)
} catch (_) {}
}
/* 节拍器状态变化 → 同步视频播放/暂停 */
watch(isPlaying, (playing) => {
if (playing) {
videoCtx?.play()
} else {
videoCtx?.pause()
}
})
/* BPM 变化 → 调整视频播放速度(部分基础库需要手动 API 调用) */
watch(playbackRate, (rate) => {
try {
videoCtx?.playbackRate?.(rate)
} catch (_) {}
})
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 {
height: 100vh;
box-sizing: border-box;
padding: 30rpx 28rpx 60rpx;
background: $bg-grad;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
color: $text-1;
overflow: hidden;
}
/* ============================================================
* 节拍器主舞台
* ============================================================ */
.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: 240rpx;
display: flex;
align-items: center;
gap: 28rpx;
padding: 0 8rpx;
}
/* ============================================================
* 跑步小人 圆形蒙版(裁掉灰白底 + 右上角原生按钮)
* ============================================================ */
.runner-orb {
position: relative;
width: 220rpx;
height: 220rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
/* 外圈彩色光晕,跟 badge 颜色联动 */
.orb-glow {
position: absolute;
inset: -10rpx;
border-radius: 50%;
background: radial-gradient(
circle at 50% 50%,
rgba(34, 197, 94, 0.32) 0%,
rgba(34, 197, 94, 0.12) 55%,
transparent 75%
);
filter: blur(8rpx);
transition: background 0.3s;
&.badge-mid {
background: radial-gradient(
circle at 50% 50%,
rgba(251, 191, 36, 0.32) 0%,
rgba(251, 191, 36, 0.12) 55%,
transparent 75%
);
}
&.badge-strong {
background: radial-gradient(
circle at 50% 50%,
rgba(249, 115, 22, 0.34) 0%,
rgba(249, 115, 22, 0.14) 55%,
transparent 75%
);
}
&.badge-hot {
background: radial-gradient(
circle at 50% 50%,
rgba(239, 68, 68, 0.36) 0%,
rgba(239, 68, 68, 0.14) 55%,
transparent 75%
);
}
}
.walker.playing .orb-glow {
animation: orb-pulse var(--beat-duration, 750ms) ease-in-out infinite;
}
@keyframes orb-pulse {
0%,
100% {
transform: scale(1);
opacity: 0.85;
}
50% {
transform: scale(1.08);
opacity: 1;
}
}
/* 圆形裁切容器 */
.orb-clip {
position: relative;
width: 220rpx;
height: 220rpx;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(160deg, #f5f7fa 0%, #c9d1dc 100%);
box-shadow:
inset 0 0 0 2rpx rgba(255, 255, 255, 0.5),
inset 0 -20rpx 36rpx rgba(0, 0, 0, 0.08);
}
.runner-video {
width: 100%;
height: 100%;
/* video 比 orb 略大,object-fit:cover 配合定位让小人居中且裁掉灰边 */
transform: scale(1.08);
background: transparent;
}
/* ============================================================
* 走路节奏数据(右侧)
* ============================================================ */
.walker-stats {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 14rpx;
padding: 8rpx 0;
}
.stat-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12rpx;
}
.stat-label {
font-size: 24rpx;
color: $text-3;
letter-spacing: 1rpx;
}
.stat-value {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.stat-num {
font-size: 40rpx;
font-weight: 700;
color: $text-1;
line-height: 1;
font-variant-numeric: tabular-nums;
letter-spacing: -0.5rpx;
}
.stat-unit {
font-size: 22rpx;
color: $text-3;
font-weight: 500;
}
/* ============================================================
* 控制卡片(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>