Files
2026-05-29 18:17:44 +08:00

1874 lines
46 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page" :style="rootStyle">
<!-- ===== 页头 ===== -->
<view class="page-header">
<view class="header-main">
<text class="page-title">瑜伽环训练</text>
<text class="page-subtitle">跟随示范 · 控制呼吸 · 稳定核心</text>
</view>
<view class="status-pill" :class="{ active: isPlaying, paused: isPaused }">
<view class="status-dot" />
<text class="status-text">{{ statusText }}</text>
</view>
</view>
<!-- ===== 瑜伽环画布区域 ===== -->
<view class="stage-canvas-wrapper">
<view class="ambient-glow" />
<!-- 瑜伽环容器 -->
<view class="ring-container" :class="{ playing: isPlaying }">
<!-- 节拍光环 - 跟随 BPM 精确缩放 -->
<view class="beat-halo" />
<!-- 视频示范主体(圆形) -->
<view class="video-ring">
<video
id="pilatesDemoVideo"
class="demo-video"
:src="TUTORIAL_VIDEO_URL"
:poster="TUTORIAL_POSTER_URL"
muted
loop
:controls="false"
:show-center-play-btn="false"
:show-play-btn="false"
:show-fullscreen-btn="false"
:show-progress="false"
:enable-progress-gesture="false"
object-fit="cover"
/>
<view class="video-ring-border" />
</view>
<!-- 中心提示(仅未开始时显示) -->
<view v-if="!isPlaying" class="center-hint">
<view class="hint-icon">
<view class="play-triangle" />
</view>
<text class="hint-text">{{ centerHintText }}</text>
</view>
<!-- BPM 角标(播放中) -->
<view v-else class="bpm-chip">
<text class="bpm-value">{{ currentBpm }}</text>
<text class="bpm-label">BPM</text>
</view>
<!-- 点击层(捕获点击切换训练状态) -->
<view class="tap-overlay" @click="onTogglePlay" />
</view>
<view class="bgm-toggle" @click="toggleBgm">
<text class="bgm-icon">{{ bgmEnabled ? '🎵' : '🔇' }}</text>
</view>
<!-- 倒计时浮标(仅有目标且训练已开始时显示) -->
<view v-if="remainingSeconds !== null && hasStats" class="countdown-badge">
<text class="countdown-label">剩余</text>
<text class="countdown-value">{{ formatTime(remainingSeconds) }}</text>
</view>
</view>
<!-- ===== 控制面板统计 + 档位 + 目标 ===== -->
<view class="control-deck" :class="{ 'is-playing': isPlaying, 'is-paused': isPaused }">
<view class="presets-block">
<view class="presets-header">
<text class="presets-title">选择训练强度</text>
<text v-if="isPlaying" class="presets-hint">训练中不可切换</text>
</view>
<view class="presets">
<view
v-for="p in PILATES_PRESETS"
:key="p.id"
class="preset"
:class="{ active: currentPreset === p.id, disabled: isPlaying }"
@click="onPresetTap(p.id)"
>
<text class="preset-name">{{ p.label }}</text>
<text class="preset-bpm">{{ p.bpm }} BPM</text>
</view>
</view>
</view>
<view class="deck-divider" />
<!-- 训练时长(默认折叠,点击展开) -->
<view class="target-block">
<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 v-if="targetExpanded" class="target-chips">
<view
v-for="t in TARGET_OPTIONS"
:key="t.minutes"
class="target-chip"
:class="{ active: targetMinutes === t.minutes, disabled: isPlaying }"
@click="onTargetTap(t.minutes)"
>
<text class="target-chip-text">{{ t.label }}</text>
</view>
</view>
</view>
<view class="deck-divider" />
<!-- 训练功效介绍(可折叠,默认收起) -->
<view class="benefit-block">
<view class="benefit-header" @click="benefitExpanded = !benefitExpanded">
<text class="benefit-title">训练功效</text>
<text class="benefit-toggle">{{ benefitExpanded ? '收起 ▲' : '展开 ▼' }}</text>
</view>
<view v-if="benefitExpanded" class="benefit-content">
<view class="benefit-row">
<text class="benefit-label">主要锻炼</text>
<view class="muscle-tags">
<text v-for="m in TRAINING_BENEFIT.muscles" :key="m" class="muscle-tag">{{ m }}</text>
</view>
</view>
<view class="benefit-row">
<text class="benefit-label">训练效果</text>
<text class="benefit-effect">{{ TRAINING_BENEFIT.effect }}</text>
</view>
</view>
</view>
<!-- 暂停时显示醒目大按钮(老年友好) -->
<view v-if="isPaused" class="pause-actions">
<button class="big-btn big-btn-resume" @click="onTogglePlay">
<text class="big-btn-icon"></text>
<text class="big-btn-text">继续训练</text>
</button>
<button class="big-btn big-btn-end" @click="onEndTraining">
<text class="big-btn-icon"></text>
<text class="big-btn-text">结束训练</text>
</button>
</view>
</view>
</view>
<!-- ===== 奖励弹窗 ===== -->
<view v-if="showRewardPopup" class="reward-popup-mask" @click="closeRewardPopup">
<view class="reward-popup" @click.stop>
<text class="reward-emoji">{{ currentReward.emoji }}</text>
<text class="reward-title">{{ currentReward.title }}</text>
<text class="reward-desc">{{ currentReward.desc }}</text>
<text class="reward-count">已完成 {{ totalReps }} </text>
<view class="reward-btn" @click="closeRewardPopup">继续加油</view>
</view>
</view>
<!-- ===== 训练完成浮层 ===== -->
<view v-if="showSummary" class="summary-mask" @click="dismissSummary">
<view class="completion-card" @click.stop>
<view class="completion-header">
<text class="completion-emoji">🎉</text>
<text class="completion-title">训练完成</text>
<text class="completion-subtitle">辛苦啦,休息一下吧</text>
</view>
<view class="completion-content">
<!-- 核心统计 -->
<view class="stats-grid">
<view class="stat-item">
<text class="stat-icon">💪</text>
<text class="stat-value">{{ totalReps }}</text>
<text class="stat-label">总次数</text>
</view>
<view class="stat-item">
<text class="stat-icon">⏱️</text>
<text class="stat-value">{{ formatTime(elapsedSeconds) }}</text>
<text class="stat-label">总时长</text>
</view>
<view class="stat-item">
<text class="stat-icon">🔥</text>
<text class="stat-value">{{ totalSugar.toFixed(1) }}g</text>
<text class="stat-label">消耗糖分 (估算)</text>
</view>
</view>
<!-- 食物对照 -->
<view v-if="sugarComparisons.length > 0" class="food-comparison-card">
<view class="food-comparison-header">
<text class="food-comparison-title">大约相当于</text>
<text class="food-comparison-hint">这些食物的糖分 🍭</text>
</view>
<view class="food-list">
<view
v-for="(food, idx) in sugarComparisons"
:key="idx"
class="food-item"
>
<text class="food-emoji">{{ food.emoji }}</text>
<view class="food-info">
<text class="food-count">{{ food.count }}</text>
<text class="food-name">{{ food.unit }}{{ food.name }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="action-row">
<button class="btn-share" open-type="share">
<text class="share-icon">📤</text>
</button>
<button class="btn-secondary" @click="onRestart">再来一次</button>
<button class="btn-primary" @click="onFinish">完成</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { onHide, onUnload, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
type PresetId = 'light' | 'medium' | 'heavy'
interface PilatesPreset {
id: PresetId
bpm: number
met: number // 运动强度系数(用于估算糖分消耗)
label: string
desc: string
}
const PILATES_PRESETS: readonly PilatesPreset[] = [
{ id: 'light', bpm: 50, met: 3.0, label: '轻度', desc: '热身 · 激活' },
{ id: 'medium', bpm: 65, met: 4.0, label: '中度', desc: '日常 · 塑形' },
{ id: 'heavy', bpm: 80, met: 5.0, label: '重度', desc: '强化 · 挑战' },
]
// 画布指导视频
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'
interface TargetOption {
minutes: 0 | 5 | 10 | 15
label: string
}
const TARGET_OPTIONS: readonly TargetOption[] = [
{ minutes: 0, label: '自由' },
{ minutes: 5, label: '5 分钟' },
{ minutes: 10, label: '10 分钟' },
{ minutes: 15, label: '15 分钟' },
]
// 里程碑奖励(纯视觉弹窗,无音效)
interface MilestoneConfig {
emoji: string
title: string
desc: string
minReps: number
maxReps: number
}
const MILESTONES: MilestoneConfig[] = [
{ emoji: '🌸', title: '热身完成', desc: '身体已激活!', minReps: 8, maxReps: 12 },
{ emoji: '💫', title: '核心稳定', desc: '动作很标准!', minReps: 15, maxReps: 20 },
{ emoji: '✨', title: '塑形进阶', desc: '力量感很棒!', minReps: 25, maxReps: 30 },
{ emoji: '🏆', title: '完美收官', desc: '太棒了!', minReps: 40, maxReps: 50 },
]
// 背景音乐 URL
const BGM_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260528/20260528093709c669f4659.mp3'
// 食物糖分对照表 (每100g含糖量)
const FOOD_SUGAR_TABLE = [
{ emoji: '🍬', name: '糖果', sugar: 95.0, unit: '颗', weight: 5 }, // 1颗糖果约5g
{ emoji: '🍪', name: '饼干', sugar: 65.0, unit: '块', weight: 10 }, // 1块饼干约10g
{ emoji: '🍫', name: '巧克力', sugar: 51.5, unit: '块', weight: 50 }, // 1块巧克力约50g
{ emoji: '🍎', name: '苹果', sugar: 10.3, unit: '个', weight: 200 }, // 1个苹果约200g
{ emoji: '🍌', name: '香蕉', sugar: 12.2, unit: '根', weight: 120 }, // 1根香蕉约120g
{ emoji: '🍚', name: '米饭', sugar: 25.9, unit: '碗', weight: 150 }, // 1碗米饭约150g
{ emoji: '🥤', name: '可乐', sugar: 10.6, unit: '罐', weight: 330 }, // 1罐可乐330ml
]
const currentPreset = ref<PresetId>('medium')
const targetMinutes = ref<0 | 5 | 10 | 15>(0)
const isPlaying = ref(false)
const beatTimer = ref<number | null>(null)
const showRewardPopup = ref(false)
const showSummary = ref(false)
const currentReward = ref<MilestoneConfig>(MILESTONES[0])
const bgmAudioCtx = ref<UniApp.InnerAudioContext | null>(null)
const bgmEnabled = ref(true) // BGM 开关
// 训练统计
const totalReps = ref(0) // 总次数
const elapsedSeconds = ref(0) // 总时长(秒)
const totalSugar = ref(0) // 总消耗糖分(克)
const beatCount = ref(0) // 节拍计数
const rewardIndex = ref(0) // 当前奖励索引
const nextRewardAt = ref(0) // 下次奖励触发次数
const startTime = ref(0)
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 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')
if (!videoCtx) return
if (playing) {
videoCtx.play()
} else {
videoCtx.pause()
}
})
const intervalMs = computed(() => 60000 / currentBpm.value)
const hasStats = computed(() => isPlaying.value || totalReps.value > 0)
const isPaused = computed(() => !isPlaying.value && totalReps.value > 0 && !showSummary.value)
// 目标剩余秒数(targetMinutes=0 时返回 null,代表无目标)
const remainingSeconds = computed<number | null>(() => {
if (targetMinutes.value === 0) return null
const total = targetMinutes.value * 60
return Math.max(0, total - elapsedSeconds.value)
})
// 环中心显示的提示文字
const centerHintText = computed(() => (isPaused.value ? '点击继续' : '点击开始'))
// 状态胶囊文字
const statusText = computed(() => {
if (isPlaying.value) return '训练中'
if (isPaused.value) return '已暂停'
return '待开始'
})
// 分享文案
const shareTitle = computed(() => {
if (totalReps.value === 0) return '瑜伽环训练 · 一起练起来'
return `刚刚完成 ${totalReps.value} 次瑜伽环训练,约消耗 ${totalSugar.value.toFixed(1)}g 糖分 ✨`
})
const rootStyle = computed(() => ({
'--beat-duration': `${intervalMs.value}ms`,
}))
// 糖分对比 - 返回多个食物对照(显示用)
const sugarComparisons = computed(() => {
if (totalSugar.value === 0) return []
const results: Array<{ emoji: string; name: string; count: string; unit: string }> = []
for (const food of FOOD_SUGAR_TABLE) {
const foodSugar = (food.sugar * food.weight) / 100
const count = totalSugar.value / foodSugar
// 选择 0.1 ~ 50 范围内的食物展示
if (count >= 0.1 && count <= 50) {
results.push({
emoji: food.emoji,
name: food.name,
count: count >= 10 ? Math.round(count).toString() : count.toFixed(1),
unit: food.unit,
})
}
}
// 最多展示 3 个
return results.slice(0, 3)
})
// 节拍回调
function onBeat() {
beatCount.value++
// 每2拍算1次
if (beatCount.value % 2 === 0) {
totalReps.value++
// 检查是否触发奖励(递进式)
if (totalReps.value >= nextRewardAt.value && rewardIndex.value < MILESTONES.length) {
triggerReward()
}
}
}
// 计算消耗糖分(随训练强度变化)
function calculateSugar(seconds: number): number {
const hours = seconds / 3600
const weight = 60
const calories = currentMet.value * weight * hours
return calories / 4
}
// 格式化时间
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// 触发里程碑弹窗(无音效)
function triggerReward() {
const reward = MILESTONES[rewardIndex.value]
currentReward.value = reward
triggerHaptic()
rewardIndex.value++
if (rewardIndex.value < MILESTONES.length) {
const nextReward = MILESTONES[rewardIndex.value]
const range = nextReward.maxReps - nextReward.minReps
nextRewardAt.value = totalReps.value + nextReward.minReps + Math.floor(Math.random() * (range + 1))
}
setTimeout(() => {
showRewardPopup.value = true
}, 400)
}
function closeRewardPopup() {
showRewardPopup.value = false
}
function triggerHaptic() {
uni.vibrateShort({
type: 'medium',
fail: () => {
try { uni.vibrateShort({}) } catch (_) {}
},
} as any)
}
// 纯视觉节奏(无节拍音效)
function startRhythm() {
stopRhythm()
isPlaying.value = true
const beatMs = 60000 / currentBpm.value
beatTimer.value = setInterval(onBeat, beatMs) as unknown as number
}
function stopRhythm() {
isPlaying.value = false
if (beatTimer.value) {
clearInterval(beatTimer.value)
beatTimer.value = null
}
}
// 启动背景音乐
function startBgm() {
if (!bgmEnabled.value) return
// 如果已存在,直接 play
if (bgmAudioCtx.value) {
try {
bgmAudioCtx.value.play()
} catch (_) {}
return
}
bgmAudioCtx.value = uni.createInnerAudioContext()
bgmAudioCtx.value.src = BGM_URL
bgmAudioCtx.value.loop = true
bgmAudioCtx.value.obeyMuteSwitch = false
bgmAudioCtx.value.volume = 0.4 // 背景音乐音量较低,不抢戏
try {
bgmAudioCtx.value.play()
} catch (_) {}
}
// 停止背景音乐
function stopBgm() {
if (bgmAudioCtx.value) {
try {
bgmAudioCtx.value.pause()
} catch (_) {}
}
}
// 切换 BGM 开关
function toggleBgm() {
bgmEnabled.value = !bgmEnabled.value
if (bgmEnabled.value && isPlaying.value) {
startBgm()
} else {
stopBgm()
}
}
// 档位切换 - 仅训练中禁用(暂停时可重新选)
function onPresetTap(id: PresetId) {
if (isPlaying.value) return
currentPreset.value = id
if (beatTimer.value) {
stopRhythm()
startRhythm()
}
}
// 训练目标切换 - 训练中禁用
function onTargetTap(minutes: 0 | 5 | 10 | 15) {
if (isPlaying.value) return
targetMinutes.value = minutes
}
// 点击环:开始 / 暂停 / 继续(不再直接弹结算)
function onTogglePlay() {
if (isPlaying.value) {
pauseTraining()
} else {
if (totalReps.value === 0) {
resetStats()
}
startTrainingInternal()
}
}
function startTrainingInternal() {
startRhythm()
startBgm()
startTimer()
uni.setKeepScreenOn({ keepScreenOn: true })
}
function pauseTraining() {
stopRhythm()
stopBgm()
stopTimer()
uni.setKeepScreenOn({ keepScreenOn: false })
}
// 显式结束训练 - 弹结算页
function onEndTraining() {
if (totalReps.value === 0) return
pauseTraining()
showSummary.value = true
}
// 关闭结算(点遮罩) → 回到初始待开始态,避免卡在暂停态死循环
function dismissSummary() {
showSummary.value = false
resetStats()
}
// 完成: 退出结算并回到初始待开始态
function onFinish() {
showSummary.value = false
resetStats()
}
// 再来一次
function onRestart() {
showSummary.value = false
resetStats()
startTrainingInternal()
}
// 重置统计
function resetStats() {
totalReps.value = 0
elapsedSeconds.value = 0
totalSugar.value = 0
beatCount.value = 0
rewardIndex.value = 0
startTime.value = Date.now()
// 设置第一个奖励的触发次数
const firstReward = MILESTONES[0]
const range = firstReward.maxReps - firstReward.minReps
nextRewardAt.value = firstReward.minReps + Math.floor(Math.random() * (range + 1))
}
// 启动计时器
function startTimer() {
if (timerInterval.value) return
startTime.value = Date.now() - elapsedSeconds.value * 1000
timerInterval.value = setInterval(() => {
elapsedSeconds.value = Math.floor((Date.now() - startTime.value) / 1000)
totalSugar.value = calculateSugar(elapsedSeconds.value)
// 到达训练目标 → 自动结束
if (targetMinutes.value > 0 && elapsedSeconds.value >= targetMinutes.value * 60) {
onEndTraining()
}
}, 1000) as unknown as number
}
// 停止计时器
function stopTimer() {
if (timerInterval.value) {
clearInterval(timerInterval.value)
timerInterval.value = null
}
}
onShareAppMessage(() => ({
title: shareTitle.value,
path: '/training/pages/pilates-ring',
imageUrl: TUTORIAL_POSTER_URL,
}))
onShareTimeline(() => ({
title: shareTitle.value,
imageUrl: TUTORIAL_POSTER_URL,
}))
// 切出/卸载页面时强制释放所有媒体(含 BGM + 示范视频)
function cleanupAllMedia() {
stopRhythm()
stopTimer()
uni.setKeepScreenOn({ keepScreenOn: false })
try {
uni.createVideoContext('pilatesDemoVideo')?.pause()
} catch (_) {}
if (bgmAudioCtx.value) {
try { bgmAudioCtx.value.stop() } catch (_) {}
try { bgmAudioCtx.value.destroy() } catch (_) {}
bgmAudioCtx.value = null
}
}
onHide(cleanupAllMedia)
onUnload(cleanupAllMedia)
</script>
<style lang="scss" scoped>
/* ============================================================
* 设计 token
* ============================================================ */
$bg-color: #f8fafc;
$card-bg: #ffffff;
$card-border: rgba(15, 23, 42, 0.06);
$text-1: #0f172a;
$text-2: #475569;
$text-3: #94a3b8;
$brand: #8b5cf6; /* 主品牌色:紫(瑜伽环主题) */
$brand-soft: #ede9fe; /* 浅紫背景 */
$brand-deep: #6d28d9; /* 深紫文字 */
$radius-card: 24rpx;
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
/* ============================================================
* 页面容器
* ============================================================ */
.page {
position: relative;
min-height: 100vh;
box-sizing: border-box;
padding: 24rpx 28rpx calc(48rpx + env(safe-area-inset-bottom));
background: linear-gradient(180deg, #f5f3ff 0%, $bg-color 28%, $bg-color 100%);
display: flex;
flex-direction: column;
align-items: stretch;
gap: 20rpx;
color: $text-1;
overflow-x: hidden;
}
/* ============================================================
* 页头
* ============================================================ */
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
flex-shrink: 0;
}
.header-main {
display: flex;
flex-direction: column;
gap: 6rpx;
flex: 1;
min-width: 0;
}
.page-title {
font-size: 40rpx;
font-weight: 800;
color: $text-1;
letter-spacing: 1rpx;
line-height: 1.2;
}
.page-subtitle {
font-size: 24rpx;
color: $text-3;
line-height: 1.4;
}
.status-pill {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.85);
border: 2rpx solid $card-border;
box-shadow: $shadow-sm;
&.active {
background: $brand-soft;
border-color: rgba(139, 92, 246, 0.35);
.status-dot {
background: $brand;
box-shadow: 0 0 0 4rpx rgba(139, 92, 246, 0.25);
animation: status-pulse 1.2s ease-in-out infinite;
}
.status-text {
color: $brand-deep;
}
}
&.paused {
background: #fef3c7;
border-color: rgba(245, 158, 11, 0.4);
.status-dot {
background: #f59e0b;
}
.status-text {
color: #92400e;
}
}
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: $text-3;
}
.status-text {
font-size: 22rpx;
font-weight: 600;
color: $text-2;
white-space: nowrap;
}
@keyframes status-pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.15);
opacity: 0.85;
}
}
/* ============================================================
* 画布区域
* ============================================================ */
.stage-canvas-wrapper {
/* 用 calc 替代 100vw,避免右侧被裁切导致圆角不对称 */
width: calc(100% + 56rpx);
margin-left: -28rpx;
margin-right: -28rpx;
flex-shrink: 0;
height: 740rpx;
display: flex;
justify-content: center;
align-items: center;
position: relative;
border-radius: 0 0 40rpx 40rpx;
overflow: hidden;
box-sizing: border-box;
background: linear-gradient(180deg, #f5f3ff 0%, #f8fafc 55%, #f1f5f9 100%);
}
/* ============================================================
* 握力环舞台(由 grip-ring-canvas 组件内联而来)
* ============================================================ */
.ambient-glow {
position: absolute;
width: 1200rpx;
height: 1200rpx;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
background:
radial-gradient(circle,
rgba(221, 214, 254, 0.9) 0%,
rgba(237, 233, 254, 0.5) 30%,
rgba(241, 245, 249, 0.2) 55%,
transparent 72%);
pointer-events: none;
}
.ring-container {
position: relative;
width: 720rpx;
height: 720rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.beat-halo {
position: absolute;
width: 720rpx;
height: 720rpx;
border-radius: 50%;
background: radial-gradient(circle,
rgba(139, 92, 246, 0.45) 0%,
rgba(167, 139, 250, 0.18) 38%,
rgba(139, 92, 246, 0) 70%);
opacity: 0;
transition: opacity 0.3s ease;
will-change: transform, opacity;
}
.ring-container.playing .beat-halo {
opacity: 1;
animation: beat-pulse calc(var(--beat-duration, 750ms) * 2) ease-in-out infinite;
}
@keyframes beat-pulse {
0%, 100% {
transform: scale(1);
opacity: 0.4;
}
50% {
transform: scale(0.86);
opacity: 0.95;
}
}
.video-ring {
position: relative;
width: 640rpx;
height: 640rpx;
border-radius: 50%;
overflow: hidden;
box-shadow:
0 20rpx 56rpx rgba(139, 92, 246, 0.35),
0 10rpx 28rpx rgba(167, 139, 250, 0.22);
will-change: transform;
z-index: 1;
}
.ring-container.playing .video-ring {
animation: video-squeeze calc(var(--beat-duration, 750ms) * 2) ease-in-out infinite;
}
@keyframes video-squeeze {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(0.95);
}
}
.demo-video {
width: 100%;
height: 100%;
border-radius: 50%;
background: #ede9fe;
}
.video-ring-border {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
box-shadow:
inset 0 0 0 4rpx rgba(255, 255, 255, 0.6),
inset 0 8rpx 24rpx rgba(109, 40, 217, 0.25);
}
.center-hint {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
pointer-events: none;
z-index: 3;
animation: hint-fade-in 0.3s ease;
}
@keyframes hint-fade-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.hint-icon {
width: 140rpx;
height: 140rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(6rpx);
border: 3rpx solid rgba(255, 255, 255, 0.85);
border-radius: 50%;
box-shadow: 0 12rpx 32rpx rgba(15, 23, 42, 0.3);
animation: hint-bounce 2s ease-in-out infinite;
will-change: transform;
}
.play-triangle {
width: 0;
height: 0;
border-left: 44rpx solid #ffffff;
border-top: 30rpx solid transparent;
border-bottom: 30rpx solid transparent;
margin-left: 10rpx;
}
@keyframes hint-bounce {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.06);
}
}
.hint-text {
font-size: 34rpx;
color: #ffffff;
font-weight: 700;
letter-spacing: 3rpx;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.55);
}
.bpm-chip {
position: absolute;
bottom: 24rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: baseline;
gap: 6rpx;
padding: 8rpx 22rpx;
border-radius: 999rpx;
background: rgba(109, 40, 217, 0.78);
box-shadow: 0 6rpx 18rpx rgba(109, 40, 217, 0.35);
pointer-events: none;
z-index: 3;
}
.bpm-value {
font-size: 36rpx;
color: #ffffff;
font-weight: 900;
line-height: 1;
letter-spacing: -1rpx;
font-variant-numeric: tabular-nums;
}
.bpm-label {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.85);
font-weight: 700;
letter-spacing: 2rpx;
}
.tap-overlay {
position: absolute;
inset: 0;
z-index: 5;
}
/* ============================================================
* BGM 开关
* ============================================================ */
.bgm-toggle {
position: absolute;
top: 24rpx;
right: 28rpx;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
box-shadow:
0 4rpx 16rpx rgba(0, 0, 0, 0.1),
0 0 0 2rpx rgba(139, 92, 246, 0.2);
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
transition: all 0.2s ease;
&:active {
transform: scale(0.92);
background: #f5f3ff;
}
}
.bgm-icon {
font-size: 36rpx;
line-height: 1;
}
/* ============================================================
* 进度显示
* ============================================================ */
.progress-bar {
width: 100%;
background: $card-bg;
border-radius: $radius-card;
padding: 20rpx 24rpx;
box-shadow: $shadow-sm;
margin-bottom: 20rpx;
}
.progress-text {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-label {
font-size: 26rpx;
color: $text-2;
font-weight: 600;
}
.progress-count {
font-size: 32rpx;
color: $brand-deep;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
/* ============================================================
* 控制面板(统计 + 档位合一)
* ============================================================ */
.control-deck {
width: 100%;
box-sizing: border-box;
background: $card-bg;
border-radius: 28rpx;
padding: 24rpx 20rpx 20rpx;
box-shadow:
0 8rpx 32rpx rgba(15, 23, 42, 0.06),
0 1rpx 0 rgba(255, 255, 255, 0.9) inset;
border: 2rpx solid rgba(255, 255, 255, 0.8);
transition: box-shadow 0.25s ease;
&.is-playing {
box-shadow:
0 12rpx 40rpx rgba(139, 92, 246, 0.12),
0 0 0 2rpx rgba(139, 92, 246, 0.12);
}
}
.stats-row {
display: flex;
gap: 12rpx;
}
.stat-chip {
flex: 1;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
border-radius: 20rpx;
padding: 18rpx 8rpx 16rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
border: 2rpx solid rgba(15, 23, 42, 0.04);
}
.stat-chip-label {
font-size: 20rpx;
color: $text-3;
font-weight: 500;
}
.stat-chip-value {
font-size: 34rpx;
font-weight: 800;
color: $brand-deep;
font-variant-numeric: tabular-nums;
line-height: 1.1;
&.muted {
color: #cbd5e1;
font-weight: 600;
}
}
.deck-divider {
height: 2rpx;
margin: 20rpx 8rpx 18rpx;
background: linear-gradient(90deg, transparent, $card-border, transparent);
}
.presets-block {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.presets-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4rpx;
}
.presets-title {
font-size: 32rpx;
font-weight: 800;
color: $text-1;
}
.presets-hint {
font-size: 20rpx;
color: $text-3;
}
.presets {
display: flex;
gap: 12rpx;
}
.preset {
flex: 1;
background: #f8fafc;
border: 3rpx solid transparent;
border-radius: 24rpx;
padding: 32rpx 8rpx 28rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
transition: all 0.18s;
.preset-name {
font-size: 40rpx;
font-weight: 800;
color: $text-1;
}
.preset-bpm {
font-size: 26rpx;
color: $text-2;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
&:active {
transform: scale(0.97);
}
&.disabled {
opacity: 0.45;
pointer-events: none;
}
&.active {
background: linear-gradient(160deg, $brand-soft 0%, #ecfeff 100%);
border-color: $brand;
box-shadow: 0 6rpx 16rpx rgba(139, 92, 246, 0.15);
.preset-name,
.preset-bpm {
color: $brand-deep;
}
.preset-desc {
color: $brand;
}
}
}
/* ============================================================
* 训练目标 chips
* ============================================================ */
.target-block {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.target-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12rpx 6rpx;
border-radius: 16rpx;
&:active {
background: #f8fafc;
}
}
.target-title {
font-size: 32rpx;
font-weight: 800;
color: $text-1;
}
.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;
}
.target-chips {
display: flex;
gap: 12rpx;
}
.target-chip {
flex: 1;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
border: 2rpx solid transparent;
border-radius: 16rpx;
transition: all 0.18s;
.target-chip-text {
font-size: 30rpx;
font-weight: 700;
color: $text-2;
}
&:active {
transform: scale(0.97);
}
&.disabled {
opacity: 0.45;
pointer-events: none;
}
&.active {
background: $brand-soft;
border-color: $brand;
.target-chip-text {
color: $brand-deep;
}
}
}
/* ============================================================
* 倒计时浮标
* ============================================================ */
.countdown-badge {
position: absolute;
top: 24rpx;
left: 28rpx;
display: flex;
flex-direction: column;
align-items: center;
padding: 8rpx 20rpx 10rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.92);
box-shadow:
0 4rpx 16rpx rgba(15, 23, 42, 0.08),
0 0 0 2rpx rgba(139, 92, 246, 0.18);
z-index: 20;
}
.countdown-label {
font-size: 18rpx;
color: $text-3;
line-height: 1.1;
}
.countdown-value {
font-size: 32rpx;
font-weight: 800;
color: $brand-deep;
font-variant-numeric: tabular-nums;
line-height: 1.1;
margin-top: 2rpx;
}
/* ============================================================
* 强度预估消耗
* ============================================================ */
.intensity-estimate {
margin-top: 14rpx;
padding: 16rpx 20rpx;
display: flex;
align-items: center;
gap: 10rpx;
background: $brand-soft;
border-radius: 14rpx;
}
.estimate-icon {
font-size: 30rpx;
line-height: 1;
flex-shrink: 0;
}
.estimate-text {
font-size: 24rpx;
font-weight: 600;
color: $brand-deep;
letter-spacing: 0.5rpx;
}
/* ============================================================
* 训练功效(可折叠)
* ============================================================ */
.benefit-block {
display: flex;
flex-direction: column;
}
.benefit-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4rpx;
&:active {
opacity: 0.7;
}
}
.benefit-title {
font-size: 26rpx;
font-weight: 700;
color: $text-1;
}
.benefit-toggle {
font-size: 22rpx;
color: $brand-deep;
font-weight: 600;
}
.benefit-content {
margin-top: 14rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 20rpx;
background: #f8fafc;
border-radius: 16rpx;
}
.benefit-row {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.benefit-label {
font-size: 22rpx;
font-weight: 700;
color: $text-2;
}
.muscle-tags {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.muscle-tag {
padding: 6rpx 18rpx;
font-size: 22rpx;
font-weight: 600;
color: $brand-deep;
background: $brand-soft;
border-radius: 999rpx;
}
.benefit-effect {
font-size: 24rpx;
line-height: 1.7;
color: $text-2;
}
/* ============================================================
* 暂停态大按钮(老年友好,醒目易点)
* ============================================================ */
.pause-actions {
margin-top: 20rpx;
display: flex;
gap: 16rpx;
}
.big-btn {
flex: 1;
height: 104rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
border-radius: 20rpx;
border: none;
padding: 0;
line-height: 1;
&::after {
border: none;
}
&:active {
transform: scale(0.97);
}
}
.big-btn-icon {
font-size: 30rpx;
line-height: 1;
}
.big-btn-text {
font-size: 34rpx;
font-weight: 800;
letter-spacing: 2rpx;
}
.big-btn-resume {
background: $brand;
color: #fff;
box-shadow: 0 8rpx 20rpx rgba(139, 92, 246, 0.32);
}
.big-btn-end {
background: #fef2f2;
color: #dc2626;
border: 2rpx solid rgba(220, 38, 38, 0.3);
.big-btn-icon {
font-size: 26rpx;
}
}
/* ============================================================
* 完成浮层
* ============================================================ */
.summary-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(6rpx);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 900;
padding: 0 24rpx calc(32rpx + env(safe-area-inset-bottom));
animation: fade-in 0.2s ease;
}
/* ============================================================
* 奖励弹窗
* ============================================================ */
.reward-popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fade-in 0.2s ease;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.reward-popup {
width: 560rpx;
background: linear-gradient(135deg, #fff 0%, #f5f3ff 100%);
border-radius: 32rpx;
padding: 48rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.3);
animation: popup-bounce 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes popup-bounce {
0% {
transform: scale(0.8) translateY(40rpx);
opacity: 0;
}
100% {
transform: scale(1) translateY(0);
opacity: 1;
}
}
.reward-emoji {
font-size: 120rpx;
line-height: 1;
animation: emoji-pop 0.6s ease;
}
@keyframes emoji-pop {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.2);
}
}
.reward-title {
font-size: 36rpx;
font-weight: 700;
color: $brand-deep;
letter-spacing: 2rpx;
}
.reward-desc {
font-size: 28rpx;
color: $text-2;
margin-bottom: 8rpx;
}
.reward-count {
font-size: 24rpx;
color: $text-3;
margin-bottom: 12rpx;
}
.reward-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
text-align: center;
background: $brand;
color: #fff;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 600;
box-shadow: 0 8rpx 20rpx rgba(139, 92, 246, 0.3);
transition: all 0.2s;
&:active {
transform: scale(0.97);
box-shadow: 0 4rpx 12rpx rgba(139, 92, 246, 0.2);
}
}
/* ============================================================
* 训练完成卡片
* ============================================================ */
.completion-card {
width: 100%;
max-width: 700rpx;
background: linear-gradient(135deg, #f5f3ff, #ede9fe);
border-radius: 32rpx 32rpx 28rpx 28rpx;
padding: 40rpx 28rpx 36rpx;
display: flex;
flex-direction: column;
gap: 28rpx;
align-items: center;
box-shadow:
0 -8rpx 48rpx rgba(15, 23, 42, 0.12),
0 8rpx 32rpx rgba(139, 92, 246, 0.08);
border: 2rpx solid rgba(139, 92, 246, 0.1);
animation: sheet-up 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes sheet-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.completion-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.completion-emoji {
font-size: 80rpx;
line-height: 1;
margin-bottom: 8rpx;
animation: emoji-bounce 1.5s ease-in-out infinite;
}
@keyframes emoji-bounce {
0%, 100% {
transform: scale(1) rotate(0deg);
}
25% {
transform: scale(1.1) rotate(-5deg);
}
75% {
transform: scale(1.1) rotate(5deg);
}
}
.completion-title {
font-size: 36rpx;
color: $text-1;
font-weight: 800;
letter-spacing: 2rpx;
}
.completion-subtitle {
font-size: 24rpx;
color: $text-3;
margin-top: 4rpx;
}
.completion-content {
width: 100%;
display: flex;
flex-direction: column;
gap: 24rpx;
}
/* 核心统计网格 */
.stats-grid {
display: flex;
gap: 16rpx;
width: 100%;
}
.stat-item {
flex: 1;
background: rgba(255, 255, 255, 0.85);
border-radius: 20rpx;
padding: 24rpx 12rpx 20rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
box-shadow:
0 4rpx 12rpx rgba(15, 23, 42, 0.04),
inset 0 1rpx 0 rgba(255, 255, 255, 0.8);
backdrop-filter: blur(8rpx);
}
.stat-icon {
font-size: 40rpx;
line-height: 1;
margin-bottom: 4rpx;
}
.stat-label {
font-size: 22rpx;
color: $text-3;
font-weight: 500;
}
.stat-value {
font-size: 36rpx;
font-weight: 800;
color: $text-1;
font-variant-numeric: tabular-nums;
letter-spacing: 0;
}
/* ============================================================
* 食物对照卡片
* ============================================================ */
.food-comparison-card {
background: linear-gradient(135deg, #fff7ed 0%, #fffbeb 100%);
border-radius: 20rpx;
padding: 28rpx 24rpx;
width: 100%;
box-shadow:
0 4rpx 16rpx rgba(251, 191, 36, 0.1),
inset 0 1rpx 0 rgba(255, 255, 255, 0.8);
border: 2rpx solid rgba(251, 191, 36, 0.15);
}
.food-comparison-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
margin-bottom: 24rpx;
}
.food-comparison-title {
font-size: 26rpx;
color: #92400e;
font-weight: 700;
letter-spacing: 1rpx;
}
.food-comparison-hint {
font-size: 22rpx;
color: #b45309;
opacity: 0.8;
}
.food-list {
display: flex;
justify-content: space-around;
align-items: center;
gap: 12rpx;
}
.food-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
padding: 16rpx 8rpx;
background: rgba(255, 255, 255, 0.7);
border-radius: 16rpx;
transition: transform 0.3s ease;
}
.food-item:active {
transform: scale(0.95);
}
.food-emoji {
font-size: 56rpx;
line-height: 1;
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.1));
}
.food-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rpx;
}
.food-count {
font-size: 32rpx;
font-weight: 800;
color: #92400e;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.food-name {
font-size: 20rpx;
color: #b45309;
font-weight: 500;
letter-spacing: 1rpx;
}
.action-row {
display: flex;
gap: 16rpx;
justify-content: center;
width: 100%;
}
.btn-primary,
.btn-secondary,
.btn-share {
flex: 1;
border-radius: 999rpx;
font-size: 28rpx;
height: 88rpx;
line-height: 88rpx;
border: none;
padding: 0 16rpx;
&::after {
border: none;
}
}
.btn-primary {
background: $brand;
color: #fff;
}
.btn-secondary {
background: #fff;
color: $brand-deep;
border: 2rpx solid rgba(139, 92, 246, 0.35);
}
.btn-share {
flex: 0 0 auto;
width: 88rpx;
padding: 0;
background: #f5f3ff;
color: $brand-deep;
border: 2rpx solid rgba(139, 92, 246, 0.25);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6rpx;
font-size: 24rpx;
.share-icon {
font-size: 30rpx;
line-height: 1;
}
}
</style>