feat(uniapp): 节拍器独立工具页面

- 新增 /pages/training/metronome 节拍器页面:
  · 中心大脉冲圆 + 重音变色(绿→金)
  · 拍号选择 1/4 ~ 6/8,第 1 拍重音视觉指示
  · BPM 滑块 (40-240) + 一键 ±1/±5 步进
  · Tap Tempo(连续 2-5 次点击自动测速)
  · 速度术语提示(Largo / Andante / Allegro 等)
  · 屏幕常亮、切后台自动暂停
- useMetronome 新增 setAccentEvery / tapTempo 方法,支持 accentSrc 重音音色
- 训练页右上角加节拍器入口
- DEV 悬浮按钮升级为可展开菜单(练一练 + 节拍器)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-25 10:48:06 +08:00
co-authored by Cursor
parent d46cfff079
commit 163e40c73f
5 changed files with 742 additions and 36 deletions
@@ -1,8 +1,27 @@
<template>
<view v-if="visible" class="dev-training-entry" @click="goTraining">
<view class="entry-icon">💪</view>
<view class="entry-text">练一练</view>
<view class="entry-tag">DEV</view>
<view v-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
<view v-if="expanded" class="menu-list" @click.stop>
<view class="menu-item" @click="goto('/pages/training/index')">
<view class="menu-icon">💪</view>
<view class="menu-info">
<text class="menu-title">练一练</text>
<text class="menu-sub">器械跟练</text>
</view>
</view>
<view class="menu-item" @click="goto('/pages/training/metronome')">
<view class="menu-icon metronome-icon">🎵</view>
<view class="menu-info">
<text class="menu-title">节拍器</text>
<text class="menu-sub">独立工具</text>
</view>
</view>
</view>
<view class="entry-fab" :class="{ expanded }" @click="toggle">
<text class="fab-icon">{{ expanded ? '✕' : '💪' }}</text>
<text v-if="!expanded" class="fab-text"></text>
<view v-if="!expanded" class="fab-tag">DEV</view>
</view>
</view>
</template>
@@ -18,49 +37,64 @@ const props = withDefaults(
)
const visible = ref(isDevMode())
const expanded = ref(false)
const goTraining = () => {
uni.navigateTo({ url: '/pages/training/index' })
const toggle = () => {
expanded.value = !expanded.value
}
const goto = (url: string) => {
expanded.value = false
uni.navigateTo({ url })
}
</script>
<style lang="scss" scoped>
.dev-training-entry {
.dev-entry-wrap {
position: fixed;
right: 24rpx;
bottom: v-bind('props.bottom + "rpx"');
z-index: 999;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 16rpx;
}
.entry-fab {
width: 110rpx;
height: 110rpx;
border-radius: 50%;
background: linear-gradient(135deg, #22c55e, #16a34a);
box-shadow: 0 6rpx 20rpx rgba(34, 197, 94, 0.4);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: transform 0.2s;
position: relative;
transition: transform 0.2s, background 0.2s;
&:active {
transform: scale(0.92);
}
.entry-icon {
font-size: 40rpx;
&.expanded {
background: linear-gradient(135deg, #64748b, #334155);
transform: rotate(90deg);
}
.fab-icon {
font-size: 36rpx;
line-height: 1;
}
.entry-text {
.fab-text {
font-size: 18rpx;
color: #fff;
margin-top: 4rpx;
line-height: 1;
}
.entry-tag {
.fab-tag {
position: absolute;
top: -8rpx;
right: -8rpx;
@@ -72,4 +106,67 @@ const goTraining = () => {
line-height: 1.4;
}
}
.menu-list {
display: flex;
flex-direction: column;
gap: 12rpx;
animation: slide-up 0.2s ease-out;
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.menu-item {
display: flex;
align-items: center;
gap: 16rpx;
background: #fff;
padding: 16rpx 24rpx;
border-radius: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
min-width: 240rpx;
&:active {
background: #f0fdf4;
}
.menu-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: linear-gradient(135deg, #22c55e, #16a34a);
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
&.metronome-icon {
background: linear-gradient(135deg, #6366f1, #4f46e5);
}
}
.menu-info {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.menu-title {
font-size: 28rpx;
color: #111827;
font-weight: 600;
}
.menu-sub {
font-size: 20rpx;
color: #9ca3af;
}
}
</style>
+58 -17
View File
@@ -5,6 +5,7 @@ export interface MetronomeOptions {
bpmMin?: number
bpmMax?: number
clickSrc?: string
accentSrc?: string
accentEvery?: number
onBeat?: (beatIndex: number, isAccent: boolean) => void
}
@@ -13,9 +14,9 @@ export function useMetronome(options: MetronomeOptions = {}) {
const {
initialBpm = 80,
bpmMin = 40,
bpmMax = 200,
bpmMax = 240,
clickSrc = '/static/training/audio/click.mp3',
accentEvery = 4,
accentSrc,
onBeat,
} = options
@@ -23,37 +24,47 @@ export function useMetronome(options: MetronomeOptions = {}) {
const isPlaying = ref<boolean>(false)
const beatIndex = ref<number>(0)
const isAccent = ref<boolean>(false)
const accentEveryRef = ref<number>(options.accentEvery ?? 4)
const intervalMs = computed(() => 60000 / bpm.value)
let audioCtx: UniApp.InnerAudioContext | null = null
let clickCtx: UniApp.InnerAudioContext | null = null
let accentCtx: UniApp.InnerAudioContext | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let nextTickAt = 0
const ensureAudio = () => {
if (audioCtx) return
audioCtx = uni.createInnerAudioContext()
audioCtx.src = clickSrc
audioCtx.obeyMuteSwitch = false
if (!clickCtx) {
clickCtx = uni.createInnerAudioContext()
clickCtx.src = clickSrc
clickCtx.obeyMuteSwitch = false
}
if (accentSrc && !accentCtx) {
accentCtx = uni.createInnerAudioContext()
accentCtx.src = accentSrc
accentCtx.obeyMuteSwitch = false
}
}
const playClick = () => {
if (!audioCtx) return
const playSound = (accent: boolean) => {
const ctx = accent && accentCtx ? accentCtx : clickCtx
if (!ctx) return
try {
audioCtx.seek(0)
audioCtx.play()
ctx.seek(0)
ctx.play()
} catch (_) {
audioCtx.play()
ctx.play()
}
}
const tick = () => {
if (!isPlaying.value) return
playClick()
const accent = beatIndex.value % accentEvery === 0
const every = Math.max(1, accentEveryRef.value)
const accent = beatIndex.value % every === 0
isAccent.value = accent
playSound(accent)
onBeat?.(beatIndex.value, accent)
beatIndex.value++
@@ -85,10 +96,37 @@ export function useMetronome(options: MetronomeOptions = {}) {
bpm.value = Math.max(bpmMin, Math.min(bpmMax, Math.round(val)))
}
const setAccentEvery = (n: number) => {
accentEveryRef.value = Math.max(1, Math.min(16, Math.round(n)))
beatIndex.value = 0
}
let tapTimes: number[] = []
const tapTempo = (): number | null => {
const now = Date.now()
if (tapTimes.length > 0 && now - tapTimes[tapTimes.length - 1] > 2000) {
tapTimes = []
}
tapTimes.push(now)
if (tapTimes.length > 5) tapTimes.shift()
if (tapTimes.length < 2) return null
const intervals: number[] = []
for (let i = 1; i < tapTimes.length; i++) {
intervals.push(tapTimes[i] - tapTimes[i - 1])
}
const avg = intervals.reduce((a, b) => a + b, 0) / intervals.length
const detected = Math.round(60000 / avg)
setBpm(detected)
return detected
}
onUnmounted(() => {
stop()
audioCtx?.destroy?.()
audioCtx = null
clickCtx?.destroy?.()
accentCtx?.destroy?.()
clickCtx = null
accentCtx = null
})
return {
@@ -97,8 +135,11 @@ export function useMetronome(options: MetronomeOptions = {}) {
beatIndex,
isAccent,
intervalMs,
accentEvery: accentEveryRef,
start,
stop,
setBpm,
setAccentEvery,
tapTempo,
}
}
+8
View File
@@ -166,6 +166,14 @@
"style": {
"navigationBarTitleText": "练一练"
}
},
{
"path": "pages/training/metronome",
"style": {
"navigationBarTitleText": "节拍器",
"navigationBarBackgroundColor": "#0f172a",
"navigationBarTextStyle": "white"
}
}
],
"subPackages": [{
+31 -4
View File
@@ -1,8 +1,13 @@
<template>
<view class="training-page">
<view class="header">
<text class="title">练一练</text>
<text class="subtitle">器械动作跟</text>
<view class="header-main">
<text class="title">练一</text>
<text class="subtitle">器械动作跟练</text>
</view>
<view class="header-link" @click="goMetronome">
🎵 节拍器
</view>
</view>
<view class="equipment-tabs">
@@ -280,6 +285,10 @@ const onSkipRest = () => {
session.skipRest()
}
const goMetronome = () => {
uni.navigateTo({ url: '/pages/training/metronome' })
}
onHide(() => {
if (isTraining.value || isResting.value) {
onStop()
@@ -300,10 +309,16 @@ onUnmounted(() => {
.header {
display: flex;
flex-direction: column;
align-items: flex-start;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
.header-main {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.title {
font-size: 44rpx;
font-weight: 700;
@@ -314,6 +329,18 @@ onUnmounted(() => {
color: #6b7280;
margin-top: 6rpx;
}
.header-link {
padding: 12rpx 24rpx;
background: #f3f4f6;
border-radius: 24rpx;
font-size: 24rpx;
color: #4b5563;
&:active {
background: #e5e7eb;
}
}
}
.equipment-tabs {
+533
View File
@@ -0,0 +1,533 @@
<template>
<view class="metronome-page">
<view class="header">
<text class="title">节拍器</text>
<text class="subtitle">METRONOME</text>
</view>
<view class="beat-bar">
<view
v-for="i in accentEvery"
:key="i"
class="beat-dot"
:class="{
accent: i === 1,
active: ((beatIndex - 1) % accentEvery) + 1 === i && isPlaying,
}"
/>
</view>
<view class="pulse-stage">
<view
class="pulse-ring"
: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">BPM</text>
</view>
</view>
<view class="bpm-control">
<view class="bpm-stepper">
<view class="step-btn" @click="adjustBpm(-5)">-5</view>
<view class="step-btn small" @click="adjustBpm(-1)">-1</view>
<view class="step-btn small" @click="adjustBpm(1)">+1</view>
<view class="step-btn" @click="adjustBpm(5)">+5</view>
</view>
<slider
:value="bpm"
:min="40"
:max="240"
:step="1"
block-size="28"
activeColor="#22c55e"
backgroundColor="#e5e7eb"
@change="onSliderChange"
/>
<view class="bpm-range">
<text>40</text>
<text>240</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)"
>
{{ m.label }}
</view>
</view>
</view>
<view class="tempo-mark-section" v-if="tempoMark">
<text class="tempo-mark">{{ tempoMark }}</text>
</view>
<view class="actions">
<view class="tap-btn" @click="onTap">
<text class="tap-icon">👆</text>
<text class="tap-label">Tap Tempo</text>
<text v-if="tapHint" class="tap-hint">{{ tapHint }}</text>
</view>
<view
class="play-btn"
:class="{ playing: isPlaying }"
@click="togglePlay"
>
<text class="play-icon">{{ isPlaying ? '■' : '▶' }}</text>
<text class="play-label">{{ isPlaying ? '停止' : '开始' }}</text>
</view>
</view>
<view class="hint">
<text>{{ statusHint }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { onHide } from '@dcloudio/uni-app'
import { useMetronome } from '@/hooks/useMetronome'
const METER_OPTIONS = [
{ label: '1/4', value: 1 },
{ label: '2/4', value: 2 },
{ label: '3/4', value: 3 },
{ label: '4/4', value: 4 },
{ label: '6/8', value: 6 },
]
const metronome = useMetronome({
initialBpm: 80,
accentEvery: 4,
})
const {
bpm,
isPlaying,
beatIndex,
isAccent,
accentEvery,
intervalMs,
start,
stop,
setBpm,
setAccentEvery,
tapTempo,
} = metronome
const adjustBpm = (delta: number) => {
setBpm(bpm.value + delta)
}
const onSliderChange = (e: any) => {
const v = e.detail?.value ?? e
setBpm(Number(v))
}
const onMeterChange = (val: number) => {
setAccentEvery(val)
}
const togglePlay = () => {
if (isPlaying.value) {
stop()
uni.setKeepScreenOn({ keepScreenOn: false })
} else {
start()
uni.setKeepScreenOn({ keepScreenOn: true })
}
}
const tapHint = ref<string>('')
let tapHintTimer: ReturnType<typeof setTimeout> | null = null
const onTap = () => {
const detected = tapTempo()
if (detected !== null) {
tapHint.value = `已设为 ${detected}`
} else {
tapHint.value = '继续点击...'
}
if (tapHintTimer) clearTimeout(tapHintTimer)
tapHintTimer = setTimeout(() => {
tapHint.value = ''
}, 1500)
}
const ringStyle = computed(() => ({
'--beat-duration': `${intervalMs.value}ms`,
}))
const coreStyle = computed(() => ({
'--beat-duration': `${intervalMs.value}ms`,
}))
const tempoMark = computed(() => {
const b = bpm.value
if (b < 60) return 'Largo · 极慢'
if (b < 76) return 'Adagio · 慢板'
if (b < 108) return 'Andante · 行板'
if (b < 120) return 'Moderato · 中板'
if (b < 156) return 'Allegro · 快板'
if (b < 200) return 'Vivace · 活泼'
return 'Presto · 急板'
})
const statusHint = computed(() => {
if (isPlaying.value) return `${((beatIndex.value - 1) % accentEvery.value) + 1} 拍 / 共 ${accentEvery.value}`
return '点击播放按钮开始'
})
watch(intervalMs, () => {})
onHide(() => {
if (isPlaying.value) {
stop()
uni.setKeepScreenOn({ keepScreenOn: false })
}
})
onUnmounted(() => {
if (tapHintTimer) clearTimeout(tapHintTimer)
uni.setKeepScreenOn({ keepScreenOn: false })
})
</script>
<style lang="scss" scoped>
.metronome-page {
min-height: 100vh;
padding: 32rpx 32rpx 80rpx;
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
display: flex;
flex-direction: column;
align-items: center;
color: #f1f5f9;
}
.header {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 40rpx;
margin-bottom: 40rpx;
.title {
font-size: 48rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.subtitle {
font-size: 22rpx;
color: #64748b;
letter-spacing: 8rpx;
margin-top: 8rpx;
}
}
/* ===== 拍点指示 ===== */
.beat-bar {
display: flex;
gap: 20rpx;
margin-bottom: 60rpx;
height: 32rpx;
align-items: center;
.beat-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #334155;
transition: all 0.15s;
&.accent {
background: #475569;
}
&.active {
background: #22c55e;
transform: scale(1.6);
box-shadow: 0 0 16rpx #22c55e;
&.accent {
background: #f59e0b;
box-shadow: 0 0 20rpx #f59e0b;
}
}
}
}
/* ===== 中心脉冲圆 ===== */
.pulse-stage {
position: relative;
width: 460rpx;
height: 460rpx;
margin-bottom: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.pulse-ring {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
border: 4rpx solid rgba(34, 197, 94, 0.2);
&.playing {
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
}
}
@keyframes ring-pulse {
0% {
transform: scale(0.95);
opacity: 1;
border-color: rgba(34, 197, 94, 0.6);
}
100% {
transform: scale(1.15);
opacity: 0;
border-color: rgba(34, 197, 94, 0);
}
}
.pulse-core {
position: relative;
width: 360rpx;
height: 360rpx;
border-radius: 50%;
background: linear-gradient(135deg, #22c55e, #16a34a);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 12rpx 40rpx rgba(34, 197, 94, 0.35);
transition: transform 0.1s, background 0.15s;
&.playing {
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
}
&.playing.accent {
background: linear-gradient(135deg, #f59e0b, #d97706);
box-shadow: 0 12rpx 40rpx rgba(245, 158, 11, 0.4);
}
.bpm-num {
font-size: 140rpx;
font-weight: 700;
color: #fff;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.bpm-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 4rpx;
margin-top: 8rpx;
}
}
@keyframes core-beat {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.06);
}
}
/* ===== BPM 控制 ===== */
.bpm-control {
width: 100%;
margin-bottom: 50rpx;
}
.bpm-stepper {
display: flex;
justify-content: center;
gap: 16rpx;
margin-bottom: 24rpx;
.step-btn {
min-width: 88rpx;
height: 64rpx;
line-height: 64rpx;
text-align: center;
background: rgba(255, 255, 255, 0.08);
color: #f1f5f9;
border-radius: 32rpx;
font-size: 26rpx;
font-weight: 600;
padding: 0 20rpx;
&.small {
min-width: 72rpx;
background: rgba(255, 255, 255, 0.04);
font-size: 24rpx;
}
&:active {
background: rgba(34, 197, 94, 0.25);
}
}
}
.bpm-range {
display: flex;
justify-content: space-between;
margin-top: 12rpx;
font-size: 22rpx;
color: #64748b;
}
slider {
margin: 0;
}
/* ===== 拍号 ===== */
.meter-section {
width: 100%;
margin-bottom: 32rpx;
}
.section-label {
display: block;
font-size: 24rpx;
color: #94a3b8;
margin-bottom: 16rpx;
text-align: center;
}
.meter-options {
display: flex;
gap: 16rpx;
justify-content: center;
flex-wrap: wrap;
.meter-btn {
min-width: 100rpx;
height: 64rpx;
line-height: 64rpx;
padding: 0 24rpx;
background: rgba(255, 255, 255, 0.06);
color: #cbd5e1;
border-radius: 32rpx;
font-size: 26rpx;
text-align: center;
&.active {
background: #22c55e;
color: #fff;
font-weight: 600;
}
}
}
/* ===== 速度术语 ===== */
.tempo-mark-section {
margin-bottom: 32rpx;
.tempo-mark {
font-size: 24rpx;
color: #94a3b8;
letter-spacing: 2rpx;
font-style: italic;
}
}
/* ===== 操作区 ===== */
.actions {
display: flex;
gap: 24rpx;
width: 100%;
margin-bottom: 32rpx;
}
.tap-btn {
flex: 1;
height: 120rpx;
background: rgba(255, 255, 255, 0.06);
border: 2rpx solid rgba(255, 255, 255, 0.1);
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
&:active {
background: rgba(245, 158, 11, 0.15);
border-color: #f59e0b;
}
.tap-icon {
font-size: 36rpx;
line-height: 1;
}
.tap-label {
font-size: 22rpx;
color: #cbd5e1;
margin-top: 4rpx;
}
.tap-hint {
position: absolute;
bottom: 8rpx;
font-size: 20rpx;
color: #f59e0b;
}
}
.play-btn {
flex: 2;
height: 120rpx;
background: linear-gradient(135deg, #22c55e, #16a34a);
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(34, 197, 94, 0.3);
&.playing {
background: linear-gradient(135deg, #ef4444, #dc2626);
box-shadow: 0 8rpx 24rpx rgba(239, 68, 68, 0.3);
}
&:active {
transform: scale(0.97);
}
.play-icon {
font-size: 44rpx;
color: #fff;
line-height: 1;
}
.play-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 4rpx;
}
}
/* ===== 状态提示 ===== */
.hint {
text-align: center;
font-size: 22rpx;
color: #64748b;
margin-top: 16rpx;
}
</style>