update
This commit is contained in:
@@ -1,280 +0,0 @@
|
||||
<template>
|
||||
<view class="exercise-anim" :class="`anim-${animationType}`" :style="cssVars">
|
||||
<view class="stage">
|
||||
<!-- 哑铃弯举:摆臂 -->
|
||||
<view v-if="animationType === 'curl'" class="curl-arm">
|
||||
<view class="curl-forearm">
|
||||
<view class="curl-dumbbell">{{ icon }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 哑铃推举:上下移动 -->
|
||||
<view v-else-if="animationType === 'press'" class="press-wrap">
|
||||
<view class="press-dumbbell left">{{ icon }}</view>
|
||||
<view class="press-dumbbell right">{{ icon }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 哑铃侧平举:双臂张开 -->
|
||||
<view v-else-if="animationType === 'sidefly'" class="sidefly-wrap">
|
||||
<view class="sidefly-arm sidefly-left">
|
||||
<view class="sidefly-dumbbell">{{ icon }}</view>
|
||||
</view>
|
||||
<view class="sidefly-arm sidefly-right">
|
||||
<view class="sidefly-dumbbell">{{ icon }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 握力环:缩放 + 粒子特效 -->
|
||||
<view v-else-if="animationType === 'grip'" class="grip-container">
|
||||
<view class="grip-ring">
|
||||
<view class="grip-ring-inner">{{ icon }}</view>
|
||||
</view>
|
||||
<CrushCanvas ref="crushCanvasRef" />
|
||||
</view>
|
||||
|
||||
<!-- 卷腹:身体折叠 -->
|
||||
<view v-else-if="animationType === 'crunch'" class="crunch-wrap">
|
||||
<view class="crunch-body">{{ icon }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { AnimationType } from '../exercises'
|
||||
import CrushCanvas from './crush-canvas.vue'
|
||||
|
||||
interface Props {
|
||||
animationType: AnimationType
|
||||
bpm: number
|
||||
beatsPerRep?: number
|
||||
icon?: string
|
||||
isPlaying?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
beatsPerRep: 2,
|
||||
icon: '🏋',
|
||||
isPlaying: false,
|
||||
})
|
||||
|
||||
const cssVars = computed(() => {
|
||||
const repDurationMs = (60000 / props.bpm) * props.beatsPerRep
|
||||
return {
|
||||
'--rep-duration': `${repDurationMs}ms`,
|
||||
'--anim-state': props.isPlaying ? 'running' : 'paused',
|
||||
} as Record<string, string>
|
||||
})
|
||||
|
||||
const crushCanvasRef = ref()
|
||||
|
||||
// 暴露捏碎特效触发方法(供外部测试)
|
||||
const triggerCrushEffect = (itemType: 'egg' | 'walnut' | 'can' | 'balloon') => {
|
||||
crushCanvasRef.value?.triggerCrush(itemType)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
triggerCrushEffect
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.exercise-anim {
|
||||
width: 100%;
|
||||
height: 480rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stage {
|
||||
width: 320rpx;
|
||||
height: 320rpx;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ===== 弯举 ===== */
|
||||
.curl-arm {
|
||||
width: 60rpx;
|
||||
height: 240rpx;
|
||||
background: #fbbf24;
|
||||
border-radius: 30rpx;
|
||||
position: relative;
|
||||
|
||||
.curl-forearm {
|
||||
width: 60rpx;
|
||||
height: 200rpx;
|
||||
background: #f59e0b;
|
||||
border-radius: 30rpx;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transform-origin: bottom center;
|
||||
animation: curl-anim var(--rep-duration) ease-in-out infinite;
|
||||
animation-play-state: var(--anim-state);
|
||||
}
|
||||
|
||||
.curl-dumbbell {
|
||||
position: absolute;
|
||||
top: -20rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 56rpx;
|
||||
}
|
||||
}
|
||||
@keyframes curl-anim {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(-115deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 推举 ===== */
|
||||
.press-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
.press-dumbbell {
|
||||
font-size: 88rpx;
|
||||
animation: press-anim var(--rep-duration) ease-in-out infinite;
|
||||
animation-play-state: var(--anim-state);
|
||||
}
|
||||
@keyframes press-anim {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(80rpx);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-80rpx);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 侧平举 ===== */
|
||||
.sidefly-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.sidefly-arm {
|
||||
position: absolute;
|
||||
width: 140rpx;
|
||||
height: 24rpx;
|
||||
background: #f59e0b;
|
||||
border-radius: 12rpx;
|
||||
top: 50%;
|
||||
transform-origin: center right;
|
||||
animation: sidefly-left var(--rep-duration) ease-in-out infinite;
|
||||
animation-play-state: var(--anim-state);
|
||||
|
||||
&.sidefly-left {
|
||||
right: 50%;
|
||||
}
|
||||
&.sidefly-right {
|
||||
left: 50%;
|
||||
transform-origin: center left;
|
||||
animation-name: sidefly-right;
|
||||
}
|
||||
}
|
||||
.sidefly-dumbbell {
|
||||
position: absolute;
|
||||
top: -36rpx;
|
||||
font-size: 56rpx;
|
||||
}
|
||||
.sidefly-left .sidefly-dumbbell {
|
||||
left: -20rpx;
|
||||
}
|
||||
.sidefly-right .sidefly-dumbbell {
|
||||
right: -20rpx;
|
||||
}
|
||||
@keyframes sidefly-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(80deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
@keyframes sidefly-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-80deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 握力环 ===== */
|
||||
.grip-container {
|
||||
position: relative;
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
}
|
||||
|
||||
.grip-ring {
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
animation: grip-anim var(--rep-duration) ease-in-out infinite;
|
||||
animation-play-state: var(--anim-state);
|
||||
border: 16rpx solid #22c55e;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
.grip-ring-inner {
|
||||
font-size: 80rpx;
|
||||
}
|
||||
@keyframes grip-anim {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.65);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 卷腹 ===== */
|
||||
.crunch-wrap {
|
||||
width: 280rpx;
|
||||
height: 280rpx;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
.crunch-body {
|
||||
font-size: 120rpx;
|
||||
animation: crunch-anim var(--rep-duration) ease-in-out infinite;
|
||||
animation-play-state: var(--anim-state);
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
@keyframes crunch-anim {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(1) translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(0.6) translateY(-10rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -72,26 +72,9 @@
|
||||
|
||||
<!-- ===== 控制面板:统计 + 档位 + 目标 ===== -->
|
||||
<view class="control-deck" :class="{ 'is-playing': isPlaying, 'is-paused': isPaused }">
|
||||
<view class="stats-row">
|
||||
<view class="stat-chip">
|
||||
<text class="stat-chip-label">次数</text>
|
||||
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? totalReps : '—' }}</text>
|
||||
</view>
|
||||
<view class="stat-chip">
|
||||
<text class="stat-chip-label">时长</text>
|
||||
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? formatTime(elapsedSeconds) : '—' }}</text>
|
||||
</view>
|
||||
<view class="stat-chip">
|
||||
<text class="stat-chip-label">糖分</text>
|
||||
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? `≈${totalSugar.toFixed(1)}g` : '—' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="deck-divider" />
|
||||
|
||||
<view class="presets-block">
|
||||
<view class="presets-header">
|
||||
<text class="presets-title">训练强度</text>
|
||||
<text class="presets-title">选择训练强度</text>
|
||||
<text v-if="isPlaying" class="presets-hint">训练中不可切换</text>
|
||||
</view>
|
||||
<view class="presets">
|
||||
@@ -102,26 +85,24 @@
|
||||
:class="{ active: currentPreset === p.id, disabled: isPlaying }"
|
||||
@click="onPresetTap(p.id)"
|
||||
>
|
||||
<text class="preset-icon">{{ PRESET_ICONS[p.id] }}</text>
|
||||
<text class="preset-name">{{ p.label }}</text>
|
||||
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
||||
<text class="preset-desc">{{ p.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="intensity-estimate">
|
||||
<text class="estimate-icon">🔥</text>
|
||||
<text class="estimate-text">{{ estimateText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="deck-divider" />
|
||||
|
||||
<!-- 训练时长(默认折叠,点击展开) -->
|
||||
<view class="target-block">
|
||||
<view class="target-header">
|
||||
<text class="target-title">训练目标</text>
|
||||
<text class="target-hint">到时间自动结束</text>
|
||||
<view class="target-header" @click="targetExpanded = !targetExpanded">
|
||||
<text class="target-title">训练时长</text>
|
||||
<view class="target-current">
|
||||
<text class="target-current-text">{{ currentTargetLabel }}</text>
|
||||
<text class="target-toggle">{{ targetExpanded ? '▲' : '▼' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="target-chips">
|
||||
<view v-if="targetExpanded" class="target-chips">
|
||||
<view
|
||||
v-for="t in TARGET_OPTIONS"
|
||||
:key="t.minutes"
|
||||
@@ -134,10 +115,9 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 仅暂停时显示一行提示 + 结束训练文字链 -->
|
||||
<view class="deck-divider" />
|
||||
|
||||
<!-- 训练功效介绍(可折叠) -->
|
||||
<!-- 训练功效介绍(可折叠,默认收起) -->
|
||||
<view class="benefit-block">
|
||||
<view class="benefit-header" @click="benefitExpanded = !benefitExpanded">
|
||||
<text class="benefit-title">训练功效</text>
|
||||
@@ -264,21 +244,6 @@ const DUMBBELL_PRESETS: readonly DumbbellPreset[] = [
|
||||
{ id: 'heavy', bpm: 80, met: 6.5, label: '重度', desc: '强化 · 挑战' },
|
||||
]
|
||||
|
||||
// 训练功效介绍(专业文案)
|
||||
const TRAINING_BENEFIT = {
|
||||
muscles: ['肱二头肌', '三角肌', '胸大肌', '背阔肌'],
|
||||
effect: '多角度抗阻训练上肢与肩背肌群,提升肌肉力量与耐力,雕塑手臂与肩部线条,增强骨密度,改善上肢日常功能。',
|
||||
}
|
||||
|
||||
// 强度预估参考时长(选择"自由"目标时按此估算)
|
||||
const ESTIMATE_REF_MINUTES = 10
|
||||
|
||||
const PRESET_ICONS: Record<PresetId, string> = {
|
||||
light: '🌱',
|
||||
medium: '💪',
|
||||
heavy: '🔥',
|
||||
}
|
||||
|
||||
// 画布指导视频
|
||||
const TUTORIAL_VIDEO_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260528/202605281739117bf3c2914.mp4'
|
||||
const TUTORIAL_POSTER_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260529/202605291142269212d9402.jpg'
|
||||
@@ -349,16 +314,21 @@ const timerInterval = ref<number | null>(null)
|
||||
const preset = computed(() => DUMBBELL_PRESETS.find(p => p.id === currentPreset.value) || DUMBBELL_PRESETS[1])
|
||||
const currentBpm = computed(() => preset.value.bpm)
|
||||
const currentMet = computed(() => preset.value.met)
|
||||
const benefitExpanded = ref(false)
|
||||
|
||||
// 当前强度预估消耗文案: 按选中目标时长(自由→10分钟参考)估算
|
||||
const estimateText = computed(() => {
|
||||
const mins = targetMinutes.value > 0 ? targetMinutes.value : ESTIMATE_REF_MINUTES
|
||||
const sugar = (currentMet.value * 60 * (mins / 60)) / 4
|
||||
const suffix = targetMinutes.value > 0 ? '' : '(按 10 分钟估算)'
|
||||
return `${preset.value.label}训练 ${mins} 分钟,约消耗 ${sugar.toFixed(1)}g 糖分${suffix}`
|
||||
// 训练时长折叠状态 + 当前时长文案
|
||||
const targetExpanded = ref(false)
|
||||
const currentTargetLabel = computed(() => {
|
||||
const opt = TARGET_OPTIONS.find(t => t.minutes === targetMinutes.value)
|
||||
return opt ? opt.label : '自由'
|
||||
})
|
||||
|
||||
// 训练功效介绍(专业文案) + 折叠状态
|
||||
const benefitExpanded = ref(false)
|
||||
const TRAINING_BENEFIT = {
|
||||
muscles: ['肱二头肌', '三角肌', '胸大肌', '背阔肌'],
|
||||
effect: '多角度抗阻训练上肢与肩背肌群,提升肌肉力量与耐力,雕塑手臂与肩部线条,增强骨密度,改善上肢日常功能。',
|
||||
}
|
||||
|
||||
// 示范视频跟随训练状态
|
||||
watch(isPlaying, (playing) => {
|
||||
const videoCtx = uni.createVideoContext('dumbbellDemoVideo')
|
||||
@@ -826,7 +796,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
margin-left: -28rpx;
|
||||
margin-right: -28rpx;
|
||||
flex-shrink: 0;
|
||||
height: 520rpx;
|
||||
height: 740rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -859,8 +829,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.ring-container {
|
||||
position: relative;
|
||||
width: 560rpx;
|
||||
height: 560rpx;
|
||||
width: 720rpx;
|
||||
height: 720rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -869,8 +839,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.beat-halo {
|
||||
position: absolute;
|
||||
width: 560rpx;
|
||||
height: 560rpx;
|
||||
width: 720rpx;
|
||||
height: 720rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle,
|
||||
rgba(59, 130, 246, 0.45) 0%,
|
||||
@@ -899,8 +869,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.video-ring {
|
||||
position: relative;
|
||||
width: 460rpx;
|
||||
height: 460rpx;
|
||||
width: 640rpx;
|
||||
height: 640rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
@@ -964,8 +934,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
width: 116rpx;
|
||||
height: 116rpx;
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -981,10 +951,10 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
.play-triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 34rpx solid #ffffff;
|
||||
border-top: 23rpx solid transparent;
|
||||
border-bottom: 23rpx solid transparent;
|
||||
margin-left: 8rpx;
|
||||
border-left: 44rpx solid #ffffff;
|
||||
border-top: 30rpx solid transparent;
|
||||
border-bottom: 30rpx solid transparent;
|
||||
margin-left: 10rpx;
|
||||
}
|
||||
|
||||
@keyframes hint-bounce {
|
||||
@@ -997,7 +967,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 26rpx;
|
||||
font-size: 34rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3rpx;
|
||||
@@ -1182,8 +1152,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.presets-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
@@ -1200,38 +1170,26 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
.preset {
|
||||
flex: 1;
|
||||
background: #f8fafc;
|
||||
border: 2rpx solid transparent;
|
||||
border-radius: 20rpx;
|
||||
padding: 20rpx 6rpx 18rpx;
|
||||
border: 3rpx solid transparent;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx 8rpx 28rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
gap: 10rpx;
|
||||
transition: all 0.18s;
|
||||
|
||||
.preset-icon {
|
||||
font-size: 32rpx;
|
||||
line-height: 1;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.preset-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
}
|
||||
.preset-bpm {
|
||||
font-size: 22rpx;
|
||||
font-size: 26rpx;
|
||||
color: $text-2;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.preset-desc {
|
||||
font-size: 18rpx;
|
||||
color: $text-3;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
@@ -1270,17 +1228,34 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 4rpx;
|
||||
padding: 12rpx 6rpx;
|
||||
border-radius: 16rpx;
|
||||
|
||||
&:active {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.target-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.target-hint {
|
||||
font-size: 20rpx;
|
||||
.target-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.target-current-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $brand-deep;
|
||||
}
|
||||
|
||||
.target-toggle {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
}
|
||||
|
||||
@@ -1291,7 +1266,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.target-chip {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1301,8 +1276,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
transition: all 0.18s;
|
||||
|
||||
.target-chip-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
export type AnimationType =
|
||||
| 'curl'
|
||||
| 'press'
|
||||
| 'sidefly'
|
||||
| 'crunch'
|
||||
|
||||
export interface ExercisePreset {
|
||||
id: string
|
||||
name: string
|
||||
equipment: '哑铃' | '健身环' | '徒手'
|
||||
icon: string
|
||||
animationType: AnimationType
|
||||
description: string
|
||||
tips: string[]
|
||||
contraindication?: string
|
||||
|
||||
defaultBpm: number
|
||||
bpmMin: number
|
||||
bpmMax: number
|
||||
defaultSets: number
|
||||
defaultReps: number
|
||||
defaultRestSec: number
|
||||
|
||||
beatsPerRep: number
|
||||
}
|
||||
|
||||
export const EXERCISE_PRESETS: ExercisePreset[] = [
|
||||
{
|
||||
id: 'dumbbell-curl',
|
||||
name: '哑铃弯举',
|
||||
equipment: '哑铃',
|
||||
icon: '🏋',
|
||||
animationType: 'curl',
|
||||
description: '锻炼肱二头肌,注意肘部固定不动',
|
||||
tips: ['肘部贴紧身体', '上举吸气,下落呼气', '动作放慢,控制重量'],
|
||||
contraindication: '肘关节炎症急性期不宜',
|
||||
defaultBpm: 60,
|
||||
bpmMin: 40,
|
||||
bpmMax: 100,
|
||||
defaultSets: 3,
|
||||
defaultReps: 12,
|
||||
defaultRestSec: 60,
|
||||
beatsPerRep: 2,
|
||||
},
|
||||
{
|
||||
id: 'dumbbell-press',
|
||||
name: '哑铃推举',
|
||||
equipment: '哑铃',
|
||||
icon: '🏋',
|
||||
animationType: 'press',
|
||||
description: '锻炼肩部三角肌,核心保持收紧',
|
||||
tips: ['不要含胸驼背', '推举时呼气', '不要锁死肘关节'],
|
||||
contraindication: '肩袖损伤者请咨询医生',
|
||||
defaultBpm: 60,
|
||||
bpmMin: 40,
|
||||
bpmMax: 90,
|
||||
defaultSets: 3,
|
||||
defaultReps: 10,
|
||||
defaultRestSec: 90,
|
||||
beatsPerRep: 2,
|
||||
},
|
||||
{
|
||||
id: 'dumbbell-sidefly',
|
||||
name: '哑铃侧平举',
|
||||
equipment: '哑铃',
|
||||
icon: '🏋',
|
||||
animationType: 'sidefly',
|
||||
description: '锻炼三角肌中束,重量宜轻不宜重',
|
||||
tips: ['手肘微屈', '抬至与肩平齐即可', '想象用肩膀发力,不是手臂'],
|
||||
defaultBpm: 60,
|
||||
bpmMin: 40,
|
||||
bpmMax: 80,
|
||||
defaultSets: 3,
|
||||
defaultReps: 12,
|
||||
defaultRestSec: 60,
|
||||
beatsPerRep: 2,
|
||||
},
|
||||
{
|
||||
id: 'crunch',
|
||||
name: '集中机(仰卧卷腹)',
|
||||
equipment: '健身环',
|
||||
icon: '💪',
|
||||
animationType: 'crunch',
|
||||
description: '锻炼腹直肌,借助健身环增加阻力',
|
||||
tips: ['下背贴地', '用腹部发力,不是脖子', '上抬呼气,下落吸气'],
|
||||
contraindication: '腰椎间盘突出急性期不宜',
|
||||
defaultBpm: 50,
|
||||
bpmMin: 40,
|
||||
bpmMax: 80,
|
||||
defaultSets: 3,
|
||||
defaultReps: 15,
|
||||
defaultRestSec: 60,
|
||||
beatsPerRep: 2,
|
||||
},
|
||||
]
|
||||
|
||||
export function getPresetById(id: string): ExercisePreset | undefined {
|
||||
return EXERCISE_PRESETS.find((e) => e.id === id)
|
||||
}
|
||||
@@ -72,26 +72,9 @@
|
||||
|
||||
<!-- ===== 控制面板:统计 + 档位 + 目标 ===== -->
|
||||
<view class="control-deck" :class="{ 'is-playing': isPlaying, 'is-paused': isPaused }">
|
||||
<view class="stats-row">
|
||||
<view class="stat-chip">
|
||||
<text class="stat-chip-label">次数</text>
|
||||
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? totalReps : '—' }}</text>
|
||||
</view>
|
||||
<view class="stat-chip">
|
||||
<text class="stat-chip-label">时长</text>
|
||||
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? formatTime(elapsedSeconds) : '—' }}</text>
|
||||
</view>
|
||||
<view class="stat-chip">
|
||||
<text class="stat-chip-label">糖分</text>
|
||||
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? `≈${totalSugar.toFixed(1)}g` : '—' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="deck-divider" />
|
||||
|
||||
<view class="presets-block">
|
||||
<view class="presets-header">
|
||||
<text class="presets-title">训练强度</text>
|
||||
<text class="presets-title">选择训练强度</text>
|
||||
<text v-if="isPlaying" class="presets-hint">训练中不可切换</text>
|
||||
</view>
|
||||
<view class="presets">
|
||||
@@ -102,26 +85,24 @@
|
||||
:class="{ active: currentPreset === p.id, disabled: isPlaying }"
|
||||
@click="onPresetTap(p.id)"
|
||||
>
|
||||
<text class="preset-icon">{{ PRESET_ICONS[p.id] }}</text>
|
||||
<text class="preset-name">{{ p.label }}</text>
|
||||
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
||||
<text class="preset-desc">{{ p.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="intensity-estimate">
|
||||
<text class="estimate-icon">🔥</text>
|
||||
<text class="estimate-text">{{ estimateText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="deck-divider" />
|
||||
|
||||
<!-- 训练时长(默认折叠,点击展开) -->
|
||||
<view class="target-block">
|
||||
<view class="target-header">
|
||||
<text class="target-title">训练目标</text>
|
||||
<text class="target-hint">到时间自动结束</text>
|
||||
<view class="target-header" @click="targetExpanded = !targetExpanded">
|
||||
<text class="target-title">训练时长</text>
|
||||
<view class="target-current">
|
||||
<text class="target-current-text">{{ currentTargetLabel }}</text>
|
||||
<text class="target-toggle">{{ targetExpanded ? '▲' : '▼' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="target-chips">
|
||||
<view v-if="targetExpanded" class="target-chips">
|
||||
<view
|
||||
v-for="t in TARGET_OPTIONS"
|
||||
:key="t.minutes"
|
||||
@@ -134,10 +115,9 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 仅暂停时显示一行提示 + 结束训练文字链 -->
|
||||
<view class="deck-divider" />
|
||||
|
||||
<!-- 训练功效介绍(可折叠) -->
|
||||
<!-- 训练功效介绍(可折叠,默认收起) -->
|
||||
<view class="benefit-block">
|
||||
<view class="benefit-header" @click="benefitExpanded = !benefitExpanded">
|
||||
<text class="benefit-title">训练功效</text>
|
||||
@@ -264,21 +244,6 @@ const FOOT_PEDAL_PRESETS: readonly FootPedalPreset[] = [
|
||||
{ id: 'heavy', bpm: 85, met: 7.0, label: '重度', desc: '强化 · 挑战' },
|
||||
]
|
||||
|
||||
// 训练功效介绍(专业文案)
|
||||
const TRAINING_BENEFIT = {
|
||||
muscles: ['股四头肌', '腘绳肌', '小腿肌群', '臀大肌'],
|
||||
effect: '低冲击有氧蹬踏,强化下肢肌力与心肺耐力,促进腿部血液循环、帮助燃脂塑形,特别适合久坐人群激活双腿。',
|
||||
}
|
||||
|
||||
// 强度预估参考时长(选择"自由"目标时按此估算)
|
||||
const ESTIMATE_REF_MINUTES = 10
|
||||
|
||||
const PRESET_ICONS: Record<PresetId, string> = {
|
||||
light: '🌱',
|
||||
medium: '💪',
|
||||
heavy: '🔥',
|
||||
}
|
||||
|
||||
// 画布指导视频
|
||||
const TUTORIAL_VIDEO_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260528/20260528173911fd8002487.mp4'
|
||||
const TUTORIAL_POSTER_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260529/2026052911251788eb49233.jpg'
|
||||
@@ -349,16 +314,21 @@ const timerInterval = ref<number | null>(null)
|
||||
const preset = computed(() => FOOT_PEDAL_PRESETS.find(p => p.id === currentPreset.value) || FOOT_PEDAL_PRESETS[1])
|
||||
const currentBpm = computed(() => preset.value.bpm)
|
||||
const currentMet = computed(() => preset.value.met)
|
||||
const benefitExpanded = ref(false)
|
||||
|
||||
// 当前强度预估消耗文案: 按选中目标时长(自由→10分钟参考)估算
|
||||
const estimateText = computed(() => {
|
||||
const mins = targetMinutes.value > 0 ? targetMinutes.value : ESTIMATE_REF_MINUTES
|
||||
const sugar = (currentMet.value * 60 * (mins / 60)) / 4
|
||||
const suffix = targetMinutes.value > 0 ? '' : '(按 10 分钟估算)'
|
||||
return `${preset.value.label}训练 ${mins} 分钟,约消耗 ${sugar.toFixed(1)}g 糖分${suffix}`
|
||||
// 训练时长折叠状态 + 当前时长文案
|
||||
const targetExpanded = ref(false)
|
||||
const currentTargetLabel = computed(() => {
|
||||
const opt = TARGET_OPTIONS.find(t => t.minutes === targetMinutes.value)
|
||||
return opt ? opt.label : '自由'
|
||||
})
|
||||
|
||||
// 训练功效介绍(专业文案) + 折叠状态
|
||||
const benefitExpanded = ref(false)
|
||||
const TRAINING_BENEFIT = {
|
||||
muscles: ['股四头肌', '腘绳肌', '小腿肌群', '臀大肌'],
|
||||
effect: '低冲击有氧蹬踏,强化下肢肌力与心肺耐力,促进腿部血液循环、帮助燃脂塑形,特别适合久坐人群激活双腿。',
|
||||
}
|
||||
|
||||
// 示范视频跟随训练状态
|
||||
watch(isPlaying, (playing) => {
|
||||
const videoCtx = uni.createVideoContext('footPedalDemoVideo')
|
||||
@@ -826,7 +796,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
margin-left: -28rpx;
|
||||
margin-right: -28rpx;
|
||||
flex-shrink: 0;
|
||||
height: 520rpx;
|
||||
height: 740rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -859,8 +829,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.ring-container {
|
||||
position: relative;
|
||||
width: 560rpx;
|
||||
height: 560rpx;
|
||||
width: 720rpx;
|
||||
height: 720rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -869,8 +839,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.beat-halo {
|
||||
position: absolute;
|
||||
width: 560rpx;
|
||||
height: 560rpx;
|
||||
width: 720rpx;
|
||||
height: 720rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle,
|
||||
rgba(249, 115, 22, 0.45) 0%,
|
||||
@@ -899,8 +869,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.video-ring {
|
||||
position: relative;
|
||||
width: 460rpx;
|
||||
height: 460rpx;
|
||||
width: 640rpx;
|
||||
height: 640rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
@@ -964,8 +934,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
width: 116rpx;
|
||||
height: 116rpx;
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -981,10 +951,10 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
.play-triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 34rpx solid #ffffff;
|
||||
border-top: 23rpx solid transparent;
|
||||
border-bottom: 23rpx solid transparent;
|
||||
margin-left: 8rpx;
|
||||
border-left: 44rpx solid #ffffff;
|
||||
border-top: 30rpx solid transparent;
|
||||
border-bottom: 30rpx solid transparent;
|
||||
margin-left: 10rpx;
|
||||
}
|
||||
|
||||
@keyframes hint-bounce {
|
||||
@@ -997,7 +967,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 26rpx;
|
||||
font-size: 34rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3rpx;
|
||||
@@ -1182,8 +1152,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.presets-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
@@ -1200,38 +1170,26 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
.preset {
|
||||
flex: 1;
|
||||
background: #f8fafc;
|
||||
border: 2rpx solid transparent;
|
||||
border-radius: 20rpx;
|
||||
padding: 20rpx 6rpx 18rpx;
|
||||
border: 3rpx solid transparent;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx 8rpx 28rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
gap: 10rpx;
|
||||
transition: all 0.18s;
|
||||
|
||||
.preset-icon {
|
||||
font-size: 32rpx;
|
||||
line-height: 1;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.preset-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
}
|
||||
.preset-bpm {
|
||||
font-size: 22rpx;
|
||||
font-size: 26rpx;
|
||||
color: $text-2;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.preset-desc {
|
||||
font-size: 18rpx;
|
||||
color: $text-3;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
@@ -1270,17 +1228,34 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 4rpx;
|
||||
padding: 12rpx 6rpx;
|
||||
border-radius: 16rpx;
|
||||
|
||||
&:active {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.target-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.target-hint {
|
||||
font-size: 20rpx;
|
||||
.target-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.target-current-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $brand-deep;
|
||||
}
|
||||
|
||||
.target-toggle {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
}
|
||||
|
||||
@@ -1291,7 +1266,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.target-chip {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1301,8 +1276,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
transition: all 0.18s;
|
||||
|
||||
.target-chip-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,28 +74,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 控制面板:统计 + 档位 + 目标 ===== -->
|
||||
<!-- ===== 控制面板:训练强度(主) + 训练时长(折叠) ===== -->
|
||||
<view class="control-deck" :class="{ 'is-playing': isPlaying, 'is-paused': isPaused }">
|
||||
<view class="stats-row">
|
||||
<view class="stat-chip">
|
||||
<text class="stat-chip-label">次数</text>
|
||||
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? totalReps : '—' }}</text>
|
||||
</view>
|
||||
<view class="stat-chip">
|
||||
<text class="stat-chip-label">时长</text>
|
||||
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? formatTime(elapsedSeconds) : '—' }}</text>
|
||||
</view>
|
||||
<view class="stat-chip">
|
||||
<text class="stat-chip-label">糖分</text>
|
||||
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? `≈${totalSugar.toFixed(1)}g` : '—' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="deck-divider" />
|
||||
|
||||
<view class="presets-block">
|
||||
<view class="presets-header">
|
||||
<text class="presets-title">训练强度</text>
|
||||
<text class="presets-title">选择训练强度</text>
|
||||
<text v-if="isPlaying" class="presets-hint">训练中不可切换</text>
|
||||
</view>
|
||||
<view class="presets">
|
||||
@@ -106,27 +89,24 @@
|
||||
:class="{ active: currentPreset === p.id, disabled: isPlaying }"
|
||||
@click="onPresetTap(p.id)"
|
||||
>
|
||||
<text class="preset-icon">{{ PRESET_ICONS[p.id] }}</text>
|
||||
<text class="preset-name">{{ p.label }}</text>
|
||||
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
||||
<text class="preset-desc">{{ p.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 当前强度预估消耗 -->
|
||||
<view class="intensity-estimate">
|
||||
<text class="estimate-icon">🔥</text>
|
||||
<text class="estimate-text">{{ estimateText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="deck-divider" />
|
||||
|
||||
<!-- 训练时长(默认折叠,点击展开) -->
|
||||
<view class="target-block">
|
||||
<view class="target-header">
|
||||
<text class="target-title">训练目标</text>
|
||||
<text class="target-hint">到时间自动结束</text>
|
||||
<view class="target-header" @click="targetExpanded = !targetExpanded">
|
||||
<text class="target-title">训练时长</text>
|
||||
<view class="target-current">
|
||||
<text class="target-current-text">{{ currentTargetLabel }}</text>
|
||||
<text class="target-toggle">{{ targetExpanded ? '▲' : '▼' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="target-chips">
|
||||
<view v-if="targetExpanded" class="target-chips">
|
||||
<view
|
||||
v-for="t in TARGET_OPTIONS"
|
||||
:key="t.minutes"
|
||||
@@ -141,7 +121,7 @@
|
||||
|
||||
<view class="deck-divider" />
|
||||
|
||||
<!-- 训练功效介绍(可折叠) -->
|
||||
<!-- 训练功效介绍(可折叠,默认收起) -->
|
||||
<view class="benefit-block">
|
||||
<view class="benefit-header" @click="benefitExpanded = !benefitExpanded">
|
||||
<text class="benefit-title">训练功效</text>
|
||||
@@ -270,21 +250,6 @@ const GRIP_PRESETS: readonly GripPreset[] = [
|
||||
{ id: 'heavy', bpm: 100, met: 4.5, label: '重度', desc: '强化 · 挑战' },
|
||||
]
|
||||
|
||||
// 训练功效介绍(专业文案)
|
||||
const TRAINING_BENEFIT = {
|
||||
muscles: ['前臂屈肌', '手部小肌群', '握力'],
|
||||
effect: '增强握力与前臂肌肉耐力,促进手部血液循环,缓解手指与腕部僵硬,适合日常碎片化锻炼及手部功能康复。',
|
||||
}
|
||||
|
||||
// 强度预估参考时长(选择"自由"目标时按此估算)
|
||||
const ESTIMATE_REF_MINUTES = 10
|
||||
|
||||
const PRESET_ICONS: Record<PresetId, string> = {
|
||||
light: '🌱',
|
||||
medium: '💪',
|
||||
heavy: '🔥',
|
||||
}
|
||||
|
||||
// 画布指导视频(跟随训练状态播放)
|
||||
const TUTORIAL_VIDEO_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260528/20260528173911bf6f93386.mp4'
|
||||
// 视频封面(未开始/暂停时显示的静态首帧)
|
||||
@@ -391,20 +356,25 @@ const timerInterval = ref<number | null>(null)
|
||||
const preset = computed(() => GRIP_PRESETS.find(p => p.id === currentPreset.value) || GRIP_PRESETS[1])
|
||||
const currentBpm = computed(() => preset.value.bpm)
|
||||
const currentMet = computed(() => preset.value.met)
|
||||
const benefitExpanded = ref(false)
|
||||
|
||||
// 当前强度预估消耗文案: 按选中目标时长(自由→10分钟参考)估算
|
||||
const estimateText = computed(() => {
|
||||
const mins = targetMinutes.value > 0 ? targetMinutes.value : ESTIMATE_REF_MINUTES
|
||||
const sugar = (currentMet.value * 60 * (mins / 60)) / 4
|
||||
const suffix = targetMinutes.value > 0 ? '' : '(按 10 分钟估算)'
|
||||
return `${preset.value.label}训练 ${mins} 分钟,约消耗 ${sugar.toFixed(1)}g 糖分${suffix}`
|
||||
// 训练时长折叠状态 + 当前时长文案
|
||||
const targetExpanded = ref(false)
|
||||
const currentTargetLabel = computed(() => {
|
||||
const opt = TARGET_OPTIONS.find(t => t.minutes === targetMinutes.value)
|
||||
return opt ? opt.label : '自由'
|
||||
})
|
||||
|
||||
// 训练功效介绍(专业文案) + 折叠状态
|
||||
const benefitExpanded = ref(false)
|
||||
const TRAINING_BENEFIT = {
|
||||
muscles: ['前臂屈肌', '手部小肌群', '握力'],
|
||||
effect: '增强握力与前臂肌肉耐力,促进手部血液循环,缓解手指与腕部僵硬,适合日常碎片化锻炼及手部功能康复。',
|
||||
}
|
||||
|
||||
const metronome = useMetronome({
|
||||
initialBpm: currentBpm.value,
|
||||
accentEvery: 2,
|
||||
volume: 0.35, // 节拍音音量调小,避免木鱼声太大
|
||||
silent: true, // 移除木鱼节奏音,仅保留节拍驱动(视觉缩放/次数统计)
|
||||
onBeat: onBeat,
|
||||
})
|
||||
const { isPlaying } = metronome
|
||||
@@ -905,7 +875,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
margin-left: -28rpx;
|
||||
margin-right: -28rpx;
|
||||
flex-shrink: 0;
|
||||
height: 520rpx;
|
||||
height: 740rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -938,8 +908,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.ring-container {
|
||||
position: relative;
|
||||
width: 560rpx;
|
||||
height: 560rpx;
|
||||
width: 720rpx;
|
||||
height: 720rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -948,8 +918,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.beat-halo {
|
||||
position: absolute;
|
||||
width: 560rpx;
|
||||
height: 560rpx;
|
||||
width: 720rpx;
|
||||
height: 720rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle,
|
||||
rgba(20, 184, 166, 0.45) 0%,
|
||||
@@ -978,8 +948,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.video-ring {
|
||||
position: relative;
|
||||
width: 460rpx;
|
||||
height: 460rpx;
|
||||
width: 640rpx;
|
||||
height: 640rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
@@ -1043,8 +1013,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
width: 116rpx;
|
||||
height: 116rpx;
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1060,10 +1030,10 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
.play-triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 34rpx solid #ffffff;
|
||||
border-top: 23rpx solid transparent;
|
||||
border-bottom: 23rpx solid transparent;
|
||||
margin-left: 8rpx;
|
||||
border-left: 44rpx solid #ffffff;
|
||||
border-top: 30rpx solid transparent;
|
||||
border-bottom: 30rpx solid transparent;
|
||||
margin-left: 10rpx;
|
||||
}
|
||||
|
||||
@keyframes hint-bounce {
|
||||
@@ -1076,7 +1046,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 26rpx;
|
||||
font-size: 34rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3rpx;
|
||||
@@ -1261,8 +1231,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.presets-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
@@ -1279,38 +1249,26 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
.preset {
|
||||
flex: 1;
|
||||
background: #f8fafc;
|
||||
border: 2rpx solid transparent;
|
||||
border-radius: 20rpx;
|
||||
padding: 20rpx 6rpx 18rpx;
|
||||
border: 3rpx solid transparent;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx 8rpx 28rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
gap: 10rpx;
|
||||
transition: all 0.18s;
|
||||
|
||||
.preset-icon {
|
||||
font-size: 32rpx;
|
||||
line-height: 1;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.preset-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
}
|
||||
.preset-bpm {
|
||||
font-size: 22rpx;
|
||||
font-size: 26rpx;
|
||||
color: $text-2;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.preset-desc {
|
||||
font-size: 18rpx;
|
||||
color: $text-3;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
@@ -1349,17 +1307,34 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 4rpx;
|
||||
padding: 12rpx 6rpx;
|
||||
border-radius: 16rpx;
|
||||
|
||||
&:active {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.target-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.target-hint {
|
||||
font-size: 20rpx;
|
||||
.target-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.target-current-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $brand-deep;
|
||||
}
|
||||
|
||||
.target-toggle {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
}
|
||||
|
||||
@@ -1370,7 +1345,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.target-chip {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1380,8 +1355,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
transition: all 0.18s;
|
||||
|
||||
.target-chip-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,739 +0,0 @@
|
||||
<template>
|
||||
<view class="training-page">
|
||||
<view class="header">
|
||||
<view class="header-main">
|
||||
<text class="title">练一练</text>
|
||||
<text class="subtitle">器械动作跟练</text>
|
||||
</view>
|
||||
<view class="header-links">
|
||||
<view class="header-link" @click="goGripRing">
|
||||
🟢 握力环
|
||||
</view>
|
||||
<view class="header-link" @click="goMetronome">
|
||||
🎵 节拍器
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="equipment-tabs">
|
||||
<view
|
||||
v-for="eq in equipmentList"
|
||||
:key="eq"
|
||||
class="equipment-tab"
|
||||
:class="{ active: currentEquipment === eq }"
|
||||
@click="onSwitchEquipment(eq)"
|
||||
>
|
||||
{{ eq }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-x class="exercise-scroll">
|
||||
<view class="exercise-list">
|
||||
<view
|
||||
v-for="ex in filteredExercises"
|
||||
:key="ex.id"
|
||||
class="exercise-card"
|
||||
:class="{ active: selectedId === ex.id }"
|
||||
@click="onSelect(ex.id)"
|
||||
>
|
||||
<text class="exercise-icon">{{ ex.icon }}</text>
|
||||
<text class="exercise-name">{{ ex.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="selected" class="exercise-detail">
|
||||
<exercise-anim
|
||||
ref="exerciseAnimRef"
|
||||
:animation-type="selected.animationType"
|
||||
:bpm="bpm"
|
||||
:beats-per-rep="selected.beatsPerRep"
|
||||
:icon="selected.icon"
|
||||
:is-playing="metronomeIsPlaying"
|
||||
/>
|
||||
|
||||
<view class="beat-indicator">
|
||||
<view
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="beat-dot"
|
||||
:class="{ active: ((beatIndex - 1) % 4) + 1 === i && metronomeIsPlaying }"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="meta-row">
|
||||
<text class="meta-label">{{ selected.description }}</text>
|
||||
</view>
|
||||
|
||||
<view class="settings">
|
||||
<view class="setting-row">
|
||||
<text class="setting-label">节奏</text>
|
||||
<slider
|
||||
:value="bpm"
|
||||
:min="selected.bpmMin"
|
||||
:max="selected.bpmMax"
|
||||
:step="2"
|
||||
block-size="24"
|
||||
active-color="#22c55e"
|
||||
@change="onBpmChange"
|
||||
/>
|
||||
<text class="setting-value">{{ bpm }} BPM</text>
|
||||
</view>
|
||||
|
||||
<view class="setting-row inline">
|
||||
<view class="inline-item">
|
||||
<text class="setting-label">组数</text>
|
||||
<view class="stepper">
|
||||
<text class="step-btn" @click="adjust('sets', -1)">-</text>
|
||||
<text class="step-val">{{ sets }}</text>
|
||||
<text class="step-btn" @click="adjust('sets', 1)">+</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="inline-item">
|
||||
<text class="setting-label">次数</text>
|
||||
<view class="stepper">
|
||||
<text class="step-btn" @click="adjust('reps', -1)">-</text>
|
||||
<text class="step-val">{{ reps }}</text>
|
||||
<text class="step-btn" @click="adjust('reps', 1)">+</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="inline-item">
|
||||
<text class="setting-label">休息(秒)</text>
|
||||
<view class="stepper">
|
||||
<text class="step-btn" @click="adjust('rest', -15)">-</text>
|
||||
<text class="step-val">{{ restSec }}</text>
|
||||
<text class="step-btn" @click="adjust('rest', 15)">+</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!isTraining && !isResting && !isDone" class="action-row">
|
||||
<button class="btn-primary" @click="onStart">开始训练</button>
|
||||
</view>
|
||||
|
||||
<view v-if="isTraining" class="status-card training">
|
||||
<text class="status-title">训练中</text>
|
||||
<text class="status-line">第 {{ currentSet }} / {{ totalSets }} 组</text>
|
||||
<text class="status-line big">{{ currentRep }} / {{ totalReps }}</text>
|
||||
<view class="action-row">
|
||||
<button class="btn-secondary" @click="onStop">停止</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isResting" class="status-card resting">
|
||||
<text class="status-title">休息中</text>
|
||||
<text class="status-line big">{{ restRemainingSec }}s</text>
|
||||
<view class="action-row">
|
||||
<button class="btn-secondary" @click="onSkipRest">跳过休息</button>
|
||||
<button class="btn-text" @click="onStop">结束训练</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isDone" class="status-card done">
|
||||
<text class="status-title">训练完成 🎉</text>
|
||||
|
||||
<view v-if="trainingStats" class="completion-content">
|
||||
<view class="stats-grid">
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">组数</text>
|
||||
<text class="stat-value">{{ trainingStats.totalSets }}</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">次数</text>
|
||||
<text class="stat-value">{{ trainingStats.totalReps }}</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">时长</text>
|
||||
<text class="stat-value">{{ formatDuration(trainingStats.durationSeconds) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="calorie-card">
|
||||
<text class="calorie-label">消耗卡路里</text>
|
||||
<text class="calorie-value">{{ trainingStats.calories }} kcal</text>
|
||||
<text class="food-comparison">
|
||||
{{ trainingStats.foodComparison.message }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-row">
|
||||
<button class="btn-primary" @click="onStart">再来一次</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tips-card">
|
||||
<text class="tips-title">动作要领</text>
|
||||
<text v-for="(tip, i) in selected.tips" :key="i" class="tips-item">
|
||||
• {{ tip }}
|
||||
</text>
|
||||
<text v-if="selected.contraindication" class="tips-warn">
|
||||
⚠ {{ selected.contraindication }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { onShow, onHide } from '@dcloudio/uni-app'
|
||||
import ExerciseAnim from './components/exercise-anim.vue'
|
||||
import { EXERCISE_PRESETS, getPresetById } from './exercises'
|
||||
import type { ExercisePreset } from './exercises'
|
||||
import { useMetronome } from '../hooks/useMetronome'
|
||||
import { useTrainingSession } from '../hooks/useTrainingSession'
|
||||
import type { CrushItemType, TrainingStats } from '../hooks/useTrainingSession'
|
||||
import { useVoiceCoach } from '../hooks/useVoiceCoach'
|
||||
import { useTrainingBgm } from '../hooks/useTrainingBgm'
|
||||
import { useTTS } from '../hooks/useTTS'
|
||||
import { formatDuration } from '../utils/calorie'
|
||||
|
||||
const equipmentList = ['哑铃', '健身环', '徒手'] as const
|
||||
type Equipment = (typeof equipmentList)[number]
|
||||
|
||||
const currentEquipment = ref<Equipment>('哑铃')
|
||||
const selectedId = ref<string>(EXERCISE_PRESETS[0].id)
|
||||
|
||||
const filteredExercises = computed(() =>
|
||||
EXERCISE_PRESETS.filter((e) => e.equipment === currentEquipment.value),
|
||||
)
|
||||
|
||||
const selected = computed<ExercisePreset | undefined>(() =>
|
||||
getPresetById(selectedId.value),
|
||||
)
|
||||
|
||||
const bpm = ref<number>(60)
|
||||
const sets = ref<number>(3)
|
||||
const reps = ref<number>(12)
|
||||
const restSec = ref<number>(60)
|
||||
|
||||
watch(
|
||||
selected,
|
||||
(val) => {
|
||||
if (!val) return
|
||||
bpm.value = val.defaultBpm
|
||||
sets.value = val.defaultSets
|
||||
reps.value = val.defaultReps
|
||||
restSec.value = val.defaultRestSec
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const voice = useVoiceCoach()
|
||||
const bgm = useTrainingBgm()
|
||||
const tts = useTTS()
|
||||
|
||||
const exerciseAnimRef = ref()
|
||||
|
||||
const session = useTrainingSession(bpm.value)
|
||||
const {
|
||||
currentSet,
|
||||
currentRep,
|
||||
restRemainingSec,
|
||||
totalReps,
|
||||
totalSets,
|
||||
isTraining,
|
||||
isResting,
|
||||
isDone,
|
||||
getStats,
|
||||
} = session
|
||||
|
||||
const trainingStats = ref<TrainingStats | null>(null)
|
||||
|
||||
const metronome = useMetronome({
|
||||
initialBpm: bpm.value,
|
||||
onBeat: () => session.onBeat(),
|
||||
})
|
||||
const { beatIndex, isPlaying: metronomeIsPlaying } = metronome
|
||||
|
||||
watch(bpm, (v) => {
|
||||
metronome.setBpm(v)
|
||||
session.setBpm(v)
|
||||
})
|
||||
|
||||
const onSwitchEquipment = (eq: Equipment) => {
|
||||
if (isTraining.value || isResting.value) return
|
||||
currentEquipment.value = eq
|
||||
const first = EXERCISE_PRESETS.find((e) => e.equipment === eq)
|
||||
if (first) selectedId.value = first.id
|
||||
}
|
||||
|
||||
const onSelect = (id: string) => {
|
||||
if (isTraining.value || isResting.value) return
|
||||
selectedId.value = id
|
||||
}
|
||||
|
||||
const onBpmChange = (e: any) => {
|
||||
const v = e.detail?.value ?? e
|
||||
bpm.value = Number(v)
|
||||
}
|
||||
|
||||
const adjust = (key: 'sets' | 'reps' | 'rest', delta: number) => {
|
||||
if (isTraining.value || isResting.value) return
|
||||
if (key === 'sets') sets.value = Math.max(1, Math.min(10, sets.value + delta))
|
||||
if (key === 'reps') reps.value = Math.max(1, Math.min(50, reps.value + delta))
|
||||
if (key === 'rest') restSec.value = Math.max(0, Math.min(300, restSec.value + delta))
|
||||
}
|
||||
|
||||
const onCrushTrigger = (item: CrushItemType) => {
|
||||
// 触发粒子特效
|
||||
exerciseAnimRef.value?.triggerCrushEffect(item)
|
||||
|
||||
// 播放语音鼓励
|
||||
tts.playEncouragement()
|
||||
}
|
||||
|
||||
const onStart = () => {
|
||||
if (!selected.value) return
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
|
||||
voice.speakPrompt('start')
|
||||
|
||||
session.start({
|
||||
sets: sets.value,
|
||||
reps: reps.value,
|
||||
restSec: restSec.value,
|
||||
beatsPerRep: selected.value.beatsPerRep,
|
||||
onRepComplete: (rep, total) => {
|
||||
if (rep <= 30) voice.speakNumber(rep)
|
||||
if (rep === total - 1) voice.speakPrompt('last-rep')
|
||||
},
|
||||
onSetComplete: (set, total) => {
|
||||
if (set < total) voice.speakPrompt('keep-it-up')
|
||||
},
|
||||
onEnterRest: () => {
|
||||
metronome.stop()
|
||||
voice.speakPrompt('rest')
|
||||
bgm.switchTo('resting')
|
||||
},
|
||||
onExitRest: () => {
|
||||
voice.speakPrompt('next-set')
|
||||
bgm.switchTo('training')
|
||||
metronome.start()
|
||||
},
|
||||
onDone: () => {
|
||||
metronome.stop()
|
||||
bgm.switchTo('none')
|
||||
voice.speakPrompt('good-job')
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
// 获取训练统计数据
|
||||
trainingStats.value = getStats()
|
||||
},
|
||||
onCrushTrigger: onCrushTrigger,
|
||||
})
|
||||
|
||||
bgm.switchTo('training')
|
||||
metronome.start()
|
||||
}
|
||||
|
||||
const onStop = () => {
|
||||
metronome.stop()
|
||||
session.stop()
|
||||
bgm.switchTo('none')
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
}
|
||||
|
||||
const onSkipRest = () => {
|
||||
session.skipRest()
|
||||
}
|
||||
|
||||
const goMetronome = () => {
|
||||
uni.navigateTo({ url: '/training/pages/metronome' })
|
||||
}
|
||||
|
||||
const goGripRing = () => {
|
||||
uni.navigateTo({ url: '/training/pages/grip-ring' })
|
||||
}
|
||||
|
||||
onHide(() => {
|
||||
if (isTraining.value || isResting.value) {
|
||||
onStop()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.training-page {
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
padding: 32rpx 24rpx 80rpx;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
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;
|
||||
color: #111827;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 26rpx;
|
||||
color: #6b7280;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.header-links {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.header-link {
|
||||
padding: 12rpx 24rpx;
|
||||
background: #f3f4f6;
|
||||
border-radius: 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: #4b5563;
|
||||
|
||||
&:active {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.equipment-tabs {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
overflow-x: auto;
|
||||
|
||||
.equipment-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 14rpx 32rpx;
|
||||
background: #fff;
|
||||
border-radius: 32rpx;
|
||||
font-size: 28rpx;
|
||||
color: #4b5563;
|
||||
border: 2rpx solid transparent;
|
||||
|
||||
&.active {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
border-color: #22c55e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.exercise-scroll {
|
||||
width: 100%;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.exercise-list {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding-right: 16rpx;
|
||||
}
|
||||
|
||||
.exercise-card {
|
||||
flex-shrink: 0;
|
||||
width: 160rpx;
|
||||
padding: 20rpx 12rpx;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
text-align: center;
|
||||
border: 2rpx solid transparent;
|
||||
|
||||
&.active {
|
||||
border-color: #22c55e;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.exercise-icon {
|
||||
display: block;
|
||||
font-size: 56rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.exercise-name {
|
||||
font-size: 24rpx;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.exercise-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.beat-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
padding: 8rpx 0;
|
||||
|
||||
.beat-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
background: #d1d5db;
|
||||
transition: transform 0.15s, background 0.15s;
|
||||
|
||||
&.active {
|
||||
background: #22c55e;
|
||||
transform: scale(1.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
.meta-label {
|
||||
font-size: 26rpx;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.settings {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
|
||||
.setting-label {
|
||||
font-size: 26rpx;
|
||||
color: #4b5563;
|
||||
min-width: 80rpx;
|
||||
}
|
||||
.setting-value {
|
||||
font-size: 26rpx;
|
||||
color: #22c55e;
|
||||
font-weight: 600;
|
||||
min-width: 120rpx;
|
||||
text-align: right;
|
||||
}
|
||||
slider {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&.inline {
|
||||
justify-content: space-between;
|
||||
gap: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
|
||||
.step-btn {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
line-height: 48rpx;
|
||||
text-align: center;
|
||||
background: #f3f4f6;
|
||||
border-radius: 24rpx;
|
||||
font-size: 32rpx;
|
||||
color: #4b5563;
|
||||
}
|
||||
.step-val {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
min-width: 56rpx;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
justify-content: center;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
max-width: 320rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
border-radius: 999rpx;
|
||||
font-size: 30rpx;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #fff;
|
||||
color: #22c55e;
|
||||
border: 2rpx solid #22c55e;
|
||||
border-radius: 999rpx;
|
||||
font-size: 28rpx;
|
||||
height: 80rpx;
|
||||
line-height: 76rpx;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
color: #ef4444;
|
||||
font-size: 26rpx;
|
||||
height: 80rpx;
|
||||
line-height: 80rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 32rpx 24rpx;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
align-items: center;
|
||||
|
||||
&.training {
|
||||
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
|
||||
}
|
||||
&.resting {
|
||||
background: linear-gradient(135deg, #fef3c7, #fef9c3);
|
||||
}
|
||||
&.done {
|
||||
background: linear-gradient(135deg, #dbeafe, #ede9fe);
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
font-size: 30rpx;
|
||||
color: #4b5563;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-line {
|
||||
font-size: 28rpx;
|
||||
color: #6b7280;
|
||||
&.big {
|
||||
font-size: 64rpx;
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.completion-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
background: #f8fafc;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx 12rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 24rpx;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.calorie-card {
|
||||
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calorie-label {
|
||||
font-size: 24rpx;
|
||||
color: #065f46;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.calorie-value {
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: #047857;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.food-comparison {
|
||||
font-size: 24rpx;
|
||||
color: #065f46;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.tips-card {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
|
||||
.tips-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.tips-item {
|
||||
font-size: 26rpx;
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.tips-warn {
|
||||
font-size: 26rpx;
|
||||
color: #ef4444;
|
||||
margin-top: 12rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -72,26 +72,9 @@
|
||||
|
||||
<!-- ===== 控制面板:统计 + 档位 + 目标 ===== -->
|
||||
<view class="control-deck" :class="{ 'is-playing': isPlaying, 'is-paused': isPaused }">
|
||||
<view class="stats-row">
|
||||
<view class="stat-chip">
|
||||
<text class="stat-chip-label">次数</text>
|
||||
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? totalReps : '—' }}</text>
|
||||
</view>
|
||||
<view class="stat-chip">
|
||||
<text class="stat-chip-label">时长</text>
|
||||
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? formatTime(elapsedSeconds) : '—' }}</text>
|
||||
</view>
|
||||
<view class="stat-chip">
|
||||
<text class="stat-chip-label">糖分</text>
|
||||
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? `≈${totalSugar.toFixed(1)}g` : '—' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="deck-divider" />
|
||||
|
||||
<view class="presets-block">
|
||||
<view class="presets-header">
|
||||
<text class="presets-title">训练强度</text>
|
||||
<text class="presets-title">选择训练强度</text>
|
||||
<text v-if="isPlaying" class="presets-hint">训练中不可切换</text>
|
||||
</view>
|
||||
<view class="presets">
|
||||
@@ -102,26 +85,24 @@
|
||||
:class="{ active: currentPreset === p.id, disabled: isPlaying }"
|
||||
@click="onPresetTap(p.id)"
|
||||
>
|
||||
<text class="preset-icon">{{ PRESET_ICONS[p.id] }}</text>
|
||||
<text class="preset-name">{{ p.label }}</text>
|
||||
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
||||
<text class="preset-desc">{{ p.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="intensity-estimate">
|
||||
<text class="estimate-icon">🔥</text>
|
||||
<text class="estimate-text">{{ estimateText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="deck-divider" />
|
||||
|
||||
<!-- 训练时长(默认折叠,点击展开) -->
|
||||
<view class="target-block">
|
||||
<view class="target-header">
|
||||
<text class="target-title">训练目标</text>
|
||||
<text class="target-hint">到时间自动结束</text>
|
||||
<view class="target-header" @click="targetExpanded = !targetExpanded">
|
||||
<text class="target-title">训练时长</text>
|
||||
<view class="target-current">
|
||||
<text class="target-current-text">{{ currentTargetLabel }}</text>
|
||||
<text class="target-toggle">{{ targetExpanded ? '▲' : '▼' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="target-chips">
|
||||
<view v-if="targetExpanded" class="target-chips">
|
||||
<view
|
||||
v-for="t in TARGET_OPTIONS"
|
||||
:key="t.minutes"
|
||||
@@ -134,10 +115,9 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 仅暂停时显示一行提示 + 结束训练文字链 -->
|
||||
<view class="deck-divider" />
|
||||
|
||||
<!-- 训练功效介绍(可折叠) -->
|
||||
<!-- 训练功效介绍(可折叠,默认收起) -->
|
||||
<view class="benefit-block">
|
||||
<view class="benefit-header" @click="benefitExpanded = !benefitExpanded">
|
||||
<text class="benefit-title">训练功效</text>
|
||||
@@ -264,21 +244,6 @@ const PILATES_PRESETS: readonly PilatesPreset[] = [
|
||||
{ id: 'heavy', bpm: 80, met: 5.0, label: '重度', desc: '强化 · 挑战' },
|
||||
]
|
||||
|
||||
// 训练功效介绍(专业文案)
|
||||
const TRAINING_BENEFIT = {
|
||||
muscles: ['核心肌群', '盆底肌', '大腿内侧', '臀部'],
|
||||
effect: '通过持续抗阻收紧核心与盆底肌,强化深层稳定肌群,改善体态、收紧腹部与腿部线条,提升身体协调性与柔韧度。',
|
||||
}
|
||||
|
||||
// 强度预估参考时长(选择"自由"目标时按此估算)
|
||||
const ESTIMATE_REF_MINUTES = 10
|
||||
|
||||
const PRESET_ICONS: Record<PresetId, string> = {
|
||||
light: '🌱',
|
||||
medium: '💪',
|
||||
heavy: '🔥',
|
||||
}
|
||||
|
||||
// 画布指导视频
|
||||
const TUTORIAL_VIDEO_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260528/202605281739110fa2d3784.mp4'
|
||||
const TUTORIAL_POSTER_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260529/2026052911251785db25125.jpg'
|
||||
@@ -349,16 +314,21 @@ const timerInterval = ref<number | null>(null)
|
||||
const preset = computed(() => PILATES_PRESETS.find(p => p.id === currentPreset.value) || PILATES_PRESETS[1])
|
||||
const currentBpm = computed(() => preset.value.bpm)
|
||||
const currentMet = computed(() => preset.value.met)
|
||||
const benefitExpanded = ref(false)
|
||||
|
||||
// 当前强度预估消耗文案: 按选中目标时长(自由→10分钟参考)估算
|
||||
const estimateText = computed(() => {
|
||||
const mins = targetMinutes.value > 0 ? targetMinutes.value : ESTIMATE_REF_MINUTES
|
||||
const sugar = (currentMet.value * 60 * (mins / 60)) / 4
|
||||
const suffix = targetMinutes.value > 0 ? '' : '(按 10 分钟估算)'
|
||||
return `${preset.value.label}训练 ${mins} 分钟,约消耗 ${sugar.toFixed(1)}g 糖分${suffix}`
|
||||
// 训练时长折叠状态 + 当前时长文案
|
||||
const targetExpanded = ref(false)
|
||||
const currentTargetLabel = computed(() => {
|
||||
const opt = TARGET_OPTIONS.find(t => t.minutes === targetMinutes.value)
|
||||
return opt ? opt.label : '自由'
|
||||
})
|
||||
|
||||
// 训练功效介绍(专业文案) + 折叠状态
|
||||
const benefitExpanded = ref(false)
|
||||
const TRAINING_BENEFIT = {
|
||||
muscles: ['核心肌群', '盆底肌', '大腿内侧', '臀部'],
|
||||
effect: '通过持续抗阻收紧核心与盆底肌,强化深层稳定肌群,改善体态、收紧腹部与腿部线条,提升身体协调性与柔韧度。',
|
||||
}
|
||||
|
||||
// 示范视频跟随训练状态
|
||||
watch(isPlaying, (playing) => {
|
||||
const videoCtx = uni.createVideoContext('pilatesDemoVideo')
|
||||
@@ -826,7 +796,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
margin-left: -28rpx;
|
||||
margin-right: -28rpx;
|
||||
flex-shrink: 0;
|
||||
height: 520rpx;
|
||||
height: 740rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -859,8 +829,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.ring-container {
|
||||
position: relative;
|
||||
width: 560rpx;
|
||||
height: 560rpx;
|
||||
width: 720rpx;
|
||||
height: 720rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -869,8 +839,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.beat-halo {
|
||||
position: absolute;
|
||||
width: 560rpx;
|
||||
height: 560rpx;
|
||||
width: 720rpx;
|
||||
height: 720rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle,
|
||||
rgba(139, 92, 246, 0.45) 0%,
|
||||
@@ -899,8 +869,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.video-ring {
|
||||
position: relative;
|
||||
width: 460rpx;
|
||||
height: 460rpx;
|
||||
width: 640rpx;
|
||||
height: 640rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
@@ -964,8 +934,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
width: 116rpx;
|
||||
height: 116rpx;
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -981,10 +951,10 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
.play-triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 34rpx solid #ffffff;
|
||||
border-top: 23rpx solid transparent;
|
||||
border-bottom: 23rpx solid transparent;
|
||||
margin-left: 8rpx;
|
||||
border-left: 44rpx solid #ffffff;
|
||||
border-top: 30rpx solid transparent;
|
||||
border-bottom: 30rpx solid transparent;
|
||||
margin-left: 10rpx;
|
||||
}
|
||||
|
||||
@keyframes hint-bounce {
|
||||
@@ -997,7 +967,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 26rpx;
|
||||
font-size: 34rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3rpx;
|
||||
@@ -1182,8 +1152,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.presets-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
@@ -1200,38 +1170,26 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
.preset {
|
||||
flex: 1;
|
||||
background: #f8fafc;
|
||||
border: 2rpx solid transparent;
|
||||
border-radius: 20rpx;
|
||||
padding: 20rpx 6rpx 18rpx;
|
||||
border: 3rpx solid transparent;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx 8rpx 28rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
gap: 10rpx;
|
||||
transition: all 0.18s;
|
||||
|
||||
.preset-icon {
|
||||
font-size: 32rpx;
|
||||
line-height: 1;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.preset-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
}
|
||||
.preset-bpm {
|
||||
font-size: 22rpx;
|
||||
font-size: 26rpx;
|
||||
color: $text-2;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.preset-desc {
|
||||
font-size: 18rpx;
|
||||
color: $text-3;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
@@ -1270,17 +1228,34 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 4rpx;
|
||||
padding: 12rpx 6rpx;
|
||||
border-radius: 16rpx;
|
||||
|
||||
&:active {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.target-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.target-hint {
|
||||
font-size: 20rpx;
|
||||
.target-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.target-current-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $brand-deep;
|
||||
}
|
||||
|
||||
.target-toggle {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
}
|
||||
|
||||
@@ -1291,7 +1266,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.target-chip {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1301,8 +1276,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
transition: all 0.18s;
|
||||
|
||||
.target-chip-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user