Files
zyt/TUICallKit-Vue3/training/pages/grip-ring.vue
T
2026-05-29 18:17:44 +08:00

1953 lines
49 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page" :style="rootStyle">
<!-- ===== 页头 ===== -->
<view class="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>
<!-- ===== 握力环画布区域(直接内联,避免原生 video 嵌自定义组件的 mp 兼容问题) ===== -->
<view class="stage-canvas-wrapper">
<!-- 环境氛围:中心放射光晕 -->
<view class="ambient-glow" />
<!-- 捏碎特效画布(prop 信号驱动) -->
<CrushCanvas :crush-signal="crushSignal" />
<!-- 握力环容器 -->
<view class="ring-container" :class="{ playing: isPlaying }">
<!-- 节拍光环 - 跟随 BPM 精确缩放 -->
<view class="beat-halo" />
<!-- 视频示范主体(圆形) -->
<view class="video-ring">
<video
id="gripDemoVideo"
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 GRIP_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 { onShow, onHide, onUnload, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { useMetronome } from '../hooks/useMetronome'
import CrushCanvas from './components/crush-canvas.vue'
type PresetId = 'light' | 'medium' | 'heavy'
interface GripPreset {
id: PresetId
bpm: number
met: number // 运动强度系数(用于估算糖分消耗)
label: string
desc: string
}
const GRIP_PRESETS: readonly GripPreset[] = [
{ id: 'light', bpm: 60, met: 2.5, label: '轻度', desc: '热身 · 恢复' },
{ id: 'medium', bpm: 80, met: 3.5, label: '中度', desc: '日常 · 锻炼' },
{ id: 'heavy', bpm: 100, met: 4.5, label: '重度', desc: '强化 · 挑战' },
]
// 画布指导视频(跟随训练状态播放)
const TUTORIAL_VIDEO_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260528/20260528173911bf6f93386.mp4'
// 视频封面(未开始/暂停时显示的静态首帧)
const TUTORIAL_POSTER_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260529/202605291030231aed12802.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 RewardConfig {
emoji: string
title: string
desc: string
itemType: 'egg' | 'walnut' | 'can' | 'balloon'
sound: string
minReps: number // 最小触发次数
maxReps: number // 最大触发次数
}
const REWARDS: RewardConfig[] = [
{
emoji: '🥚',
title: '捏碎鸡蛋',
desc: '握力不错!',
itemType: 'egg',
sound: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260528/202605280937077bad76716.mp3',
minReps: 8,
maxReps: 12
},
{
emoji: '🌰',
title: '捏碎核桃',
desc: '力量惊人!',
itemType: 'walnut',
sound: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260528/20260528093707b021f5794.mp3',
minReps: 15,
maxReps: 20
},
{
emoji: '🥫',
title: '捏扁易拉罐',
desc: '太强了!',
itemType: 'can',
sound: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260528/20260528093707ebd4d5733.mp3',
minReps: 25,
maxReps: 30
},
{
emoji: '🎈',
title: '捏爆气球',
desc: '完美!',
itemType: 'balloon',
sound: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260528/20260528093707016f07427.mp3',
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) // 训练目标(0=自由)
// 捏碎特效信号(prop 驱动, 替代跨组件 ref 调用; nonce 保证连续同类型也能触发 watch)
const crushSignal = ref<{ type: 'egg' | 'walnut' | 'can' | 'balloon'; nonce: number } | null>(null)
const showRewardPopup = ref(false)
const showSummary = ref(false)
const currentReward = ref<RewardConfig>(REWARDS[0])
const crushAudioCtx = ref<UniApp.InnerAudioContext | null>(null)
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(() => GRIP_PRESETS.find(p => p.id === currentPreset.value) || GRIP_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: '增强握力与前臂肌肉耐力,促进手部血液循环,缓解手指与腕部僵硬,适合日常碎片化锻炼及手部功能康复。',
}
const metronome = useMetronome({
initialBpm: currentBpm.value,
accentEvery: 2,
silent: true, // 移除木鱼节奏音,仅保留节拍驱动(视觉缩放/次数统计)
onBeat: onBeat,
})
const { isPlaying } = metronome
// 示范视频跟随训练状态: 开始训练才播放, 暂停/未开始则暂停
// 视频已内联在页面主体(非自定义组件内), page 级 createVideoContext 即可定位
watch(isPlaying, (playing) => {
const videoCtx = uni.createVideoContext('gripDemoVideo')
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 < REWARDS.length) {
triggerReward()
}
}
}
// 计算消耗糖分(随训练强度变化)
// 消耗卡路里 = MET × 体重(kg) × 时长(小时); 1g 糖分 ≈ 4 kcal
function calculateSugar(seconds: number): number {
const hours = seconds / 3600
const weight = 60 // 默认体重60kg
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 = REWARDS[rewardIndex.value]
currentReward.value = reward
// 立即触发粒子特效 + 音效 + 震动
crushSignal.value = { type: reward.itemType, nonce: Date.now() }
playCrushSound(reward.sound)
triggerHaptic()
// 递进到下一个奖励等级
rewardIndex.value++
// 计算下次奖励触发次数(如果还有下一级)
if (rewardIndex.value < REWARDS.length) {
const nextReward = REWARDS[rewardIndex.value]
const range = nextReward.maxReps - nextReward.minReps
nextRewardAt.value = totalReps.value + nextReward.minReps + Math.floor(Math.random() * (range + 1))
}
// 延迟 1200ms 显示弹窗,让粒子动画先完整播放
setTimeout(() => {
showRewardPopup.value = true
}, 1200)
}
function closeRewardPopup() {
showRewardPopup.value = false
}
// 奖励震动反馈: "砰" + 余震两段,配合爆裂特效
function triggerHaptic() {
const buzz = (type: 'heavy' | 'medium' | 'light') => {
// type 仅微信高版本支持,fail 时降级为无参短震
uni.vibrateShort({
type,
fail: () => {
try { uni.vibrateShort({}) } catch (_) {}
},
} as any)
}
buzz('heavy')
setTimeout(() => buzz('medium'), 120)
}
function playCrushSound(sound: string) {
// 如果没有音效URL,跳过播放
if (!sound) return
if (crushAudioCtx.value) {
try {
crushAudioCtx.value.destroy()
} catch (_) {}
}
crushAudioCtx.value = uni.createInnerAudioContext()
crushAudioCtx.value.src = sound
crushAudioCtx.value.obeyMuteSwitch = false
crushAudioCtx.value.volume = 0.6
try {
crushAudioCtx.value.play()
} catch (_) {}
}
// 启动背景音乐
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
metronome.setBpm(preset.value.bpm)
}
// 训练目标切换 - 训练中禁用
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() {
metronome.start()
startBgm()
startTimer()
uni.setKeepScreenOn({ keepScreenOn: true })
}
function pauseTraining() {
metronome.stop()
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 = REWARDS[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
}
}
onShow(() => {
metronome.preload()
})
// 分享给好友(微信小程序)
onShareAppMessage(() => ({
title: shareTitle.value,
path: '/training/pages/grip-ring',
imageUrl: TUTORIAL_POSTER_URL,
}))
// 分享到朋友圈
onShareTimeline(() => ({
title: shareTitle.value,
imageUrl: TUTORIAL_POSTER_URL,
}))
// 切出/卸载页面时强制释放所有媒体(含 BGM + 节拍 + 示范视频)
function cleanupAllMedia() {
try { metronome.stop() } catch (_) {}
stopTimer()
uni.setKeepScreenOn({ keepScreenOn: false })
try {
uni.createVideoContext('gripDemoVideo')?.pause()
} catch (_) {}
if (bgmAudioCtx.value) {
try { bgmAudioCtx.value.stop() } catch (_) {}
try { bgmAudioCtx.value.destroy() } catch (_) {}
bgmAudioCtx.value = null
}
if (crushAudioCtx.value) {
try { crushAudioCtx.value.stop() } catch (_) {}
try { crushAudioCtx.value.destroy() } catch (_) {}
crushAudioCtx.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: #14b8a6; /* 主品牌色:青绿(健康主题) */
$brand-soft: #ccfbf1; /* 浅青绿背景 */
$brand-deep: #0f766e; /* 深青绿文字 */
$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, #f0fdfa 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(20, 184, 166, 0.35);
.status-dot {
background: $brand;
box-shadow: 0 0 0 4rpx rgba(20, 184, 166, 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, #f0fdfa 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(204, 251, 241, 0.9) 0%,
rgba(240, 253, 250, 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(20, 184, 166, 0.45) 0%,
rgba(45, 212, 191, 0.18) 38%,
rgba(20, 184, 166, 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(20, 184, 166, 0.35),
0 10rpx 28rpx rgba(45, 212, 191, 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: #e2f5f1;
}
.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(15, 118, 110, 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(15, 118, 110, 0.78);
box-shadow: 0 6rpx 18rpx rgba(15, 118, 110, 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(20, 184, 166, 0.2);
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
transition: all 0.2s ease;
&:active {
transform: scale(0.92);
background: #f0fdfa;
}
}
.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(20, 184, 166, 0.12),
0 0 0 2rpx rgba(20, 184, 166, 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(20, 184, 166, 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(20, 184, 166, 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(20, 184, 166, 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%, #f0fdfa 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(20, 184, 166, 0.3);
transition: all 0.2s;
&:active {
transform: scale(0.97);
box-shadow: 0 4rpx 12rpx rgba(20, 184, 166, 0.2);
}
}
/* ============================================================
* 训练完成卡片
* ============================================================ */
.completion-card {
width: 100%;
max-width: 700rpx;
background: linear-gradient(135deg, #f0fdfa, #ecfeff);
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(20, 184, 166, 0.08);
border: 2rpx solid rgba(20, 184, 166, 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(20, 184, 166, 0.35);
}
.btn-share {
flex: 0 0 auto;
width: 88rpx;
padding: 0;
background: #f0fdfa;
color: $brand-deep;
border: 2rpx solid rgba(20, 184, 166, 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>