Files
zyt/TUICallKit-Vue3/training/pages/grip-ring.vue
T
2026-05-28 15:10:06 +08:00

1544 lines
39 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>
<!-- ===== 握力环画布区域 ===== -->
<view class="stage-canvas-wrapper">
<GripRingCanvas
ref="gripCanvasRef"
:bpm="currentBpm"
:is-playing="isPlaying"
:hint-text="centerHintText"
@toggle-play="onTogglePlay"
/>
<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="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 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-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>
<view class="deck-divider" />
<view class="target-block">
<view class="target-header">
<text class="target-title">训练目标</text>
<text class="target-hint">到时间自动结束</text>
</view>
<view 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 v-if="isPaused" class="action-bar">
<text class="paused-hint-text">已暂停 · 点击握力环继续</text>
<text class="end-link" @click="onEndTraining">结束训练 </text>
</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 class="tips-strip">
<view v-for="(tip, i) in TIPS" :key="i" class="tip-chip">
<text class="tip-text">{{ tip }}</text>
</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="onContinue">继续训练</button>
<button class="btn-primary" @click="onRestart">再来一次</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { onShow, onHide, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { useMetronome } from '../hooks/useMetronome'
import GripRingCanvas from './components/grip-ring-canvas.vue'
type PresetId = 'light' | 'medium' | 'heavy'
interface GripPreset {
id: PresetId
bpm: number
label: string
desc: string
}
const GRIP_PRESETS: readonly GripPreset[] = [
{ id: 'light', bpm: 60, label: '轻度', desc: '热身 · 恢复' },
{ id: 'medium', bpm: 80, label: '中度', desc: '日常 · 锻炼' },
{ id: 'heavy', bpm: 100, label: '重度', desc: '强化 · 挑战' },
]
const PRESET_ICONS: Record<PresetId, string> = {
light: '🌱',
medium: '💪',
heavy: '🔥',
}
const TIPS = ['握紧停 1 秒', '完全放松', '左右手交替'] as const
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=自由)
const gripCanvasRef = ref<any>(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 metronome = useMetronome({
initialBpm: currentBpm.value,
accentEvery: 2,
volume: 0.35, // 节拍音音量调小,避免木鱼声太大
onBeat: onBeat,
})
const { isPlaying } = metronome
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 ≈ 3.5, 消耗卡路里 = MET × 体重(kg) × 时长(小时)
// 1g 糖分 ≈ 4 kcal, 所以 糖分(g) = 卡路里 / 4
function calculateSugar(seconds: number): number {
const hours = seconds / 3600
const weight = 60 // 默认体重60kg
const met = 3.5
const calories = met * 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
// 立即触发粒子特效和音效
gripCanvasRef.value?.triggerCrushEffect(reward.itemType)
playCrushSound(reward.sound)
// 递进到下一个奖励等级
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 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
}
function onContinue() {
showSummary.value = false
startTrainingInternal()
}
// 再来一次
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',
}))
// 分享到朋友圈
onShareTimeline(() => ({
title: shareTitle.value,
}))
onHide(() => {
if (isPlaying.value) {
metronome.stop()
stopBgm()
stopTimer()
uni.setKeepScreenOn({ keepScreenOn: false })
}
if (crushAudioCtx.value) {
try {
crushAudioCtx.value.destroy()
} catch (_) {}
}
if (bgmAudioCtx.value) {
try {
bgmAudioCtx.value.destroy()
} catch (_) {}
bgmAudioCtx.value = null
}
})
</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: 520rpx;
display: flex;
justify-content: center;
align-items: center;
position: relative;
border-radius: 0 0 40rpx 40rpx;
overflow: hidden;
box-sizing: border-box;
}
/* ============================================================
* 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: 26rpx;
font-weight: 700;
color: $text-1;
}
.presets-hint {
font-size: 20rpx;
color: $text-3;
}
.presets {
display: flex;
gap: 12rpx;
}
.preset {
flex: 1;
background: #f8fafc;
border: 2rpx solid transparent;
border-radius: 20rpx;
padding: 20rpx 6rpx 18rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
transition: all 0.18s;
.preset-icon {
font-size: 32rpx;
line-height: 1;
margin-bottom: 4rpx;
}
.preset-name {
font-size: 26rpx;
font-weight: 700;
color: $text-1;
}
.preset-bpm {
font-size: 22rpx;
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);
}
&.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: 0 4rpx;
}
.target-title {
font-size: 26rpx;
font-weight: 700;
color: $text-1;
}
.target-hint {
font-size: 20rpx;
color: $text-3;
}
.target-chips {
display: flex;
gap: 12rpx;
}
.target-chip {
flex: 1;
height: 72rpx;
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: 24rpx;
font-weight: 600;
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;
}
/* ============================================================
* 控制面板底部操作(仅暂停时显示)
* ============================================================ */
.action-bar {
margin-top: 18rpx;
padding: 14rpx 4rpx 2rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
border-top: 2rpx dashed rgba(15, 23, 42, 0.06);
}
.paused-hint-text {
flex: 1;
font-size: 22rpx;
color: $text-3;
letter-spacing: 1rpx;
}
.end-link {
flex-shrink: 0;
padding: 8rpx 4rpx;
font-size: 24rpx;
color: $text-2;
font-weight: 500;
letter-spacing: 1rpx;
&:active {
color: $text-1;
}
}
/* ============================================================
* 动作要领条
* ============================================================ */
.tips-strip {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
justify-content: center;
}
.tip-chip {
padding: 10rpx 20rpx;
background: rgba(255, 255, 255, 0.7);
border-radius: 999rpx;
border: 2rpx solid $card-border;
}
.tip-text {
font-size: 22rpx;
color: $text-2;
line-height: 1.3;
}
/* ============================================================
* 完成浮层
* ============================================================ */
.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>