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:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,14 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": "练一练"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/training/metronome",
|
||||
"style": {
|
||||
"navigationBarTitleText": "节拍器",
|
||||
"navigationBarBackgroundColor": "#0f172a",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
}
|
||||
],
|
||||
"subPackages": [{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user