Files
zyt/TUICallKit-Vue3/training/pages/grip-ring.vue
T
longandClaude Opus 4.7 c3e4dfa479 feat(training): 握力环训练全面优化 - 音效集成 + 沉浸式画布 + Canvas 2D
## 主要改动

### 1. 音效集成
- 集成 4 个捏碎音效(鸡蛋/核桃/易拉罐/气球)
- 集成背景音乐 BGM + 右上角开关按钮
- 修复音效 URL 空值时的崩溃问题
- useMetronome 新增 volume 参数(默认 1.0)
- 节拍音量调小至 0.35,避免木鱼声过大

### 2. 画布升级到 Canvas 2D 接口
- 解决微信小程序 canvas-id 接口的警告
- 支持同层渲染,可与其他元素正常叠加
- 使用 requestAnimationFrame 替代 setTimeout
- DPR 自适应,Retina 屏幕高清渲染

### 3. 粒子特效差异化升级
- 4 种物品各自独立配色和形状
  - 🥚 鸡蛋: 蛋黄黄+白色,圆形,中速
  - 🌰 核桃: 4 种棕色,方形/三角/长方形,重粒
  - 🥫 易拉罐: 5 种银色,长方形,快速
  - 🎈 气球: 8 色彩虹,小颗粒,飘浮(低重力)
- 新增双层冲击波效果
- 粒子旋转动画(方形/三角形/长方形)
- 多形状粒子绘制(circle/square/triangle/rect)

### 4. 沉浸式画布重新设计
- 画布占屏幕 60vh(min 720rpx, max 900rpx)
- 背景与页面无缝融合(#f8fafc)
- 中心 1400rpx 大放射光晕,营造空间感
- 握力环放大至 520rpx,内圈 280rpx
- 完全移除浮动气泡(性能优化)

### 5. 视觉与体验优化
- 主题色改回青绿色(#14b8a6),握力环保持粉色突出
- 训练完成卡片大改造:
  - 大号 emoji 跳动动画
  - 统计项加入图标(💪⏱️🔥)
  - 食物对照可视化(emoji + 数量,最多 3 个)
  - 食物库扩展到 7 种(糖果/饼干/巧克力/苹果/香蕉/米饭/可乐)
- BPM 数字大号显示(80rpx 深粉色)
- 实时统计栏增加分隔线和顶部彩色高光

### 6. 性能优化(低配机器友好)
- 静态背景渐变(0 性能消耗)
- 单层中心光晕(无动画)
- 外发光仅在播放时启用
- GPU 加速(will-change + transform/opacity)
- 移除 backdrop-filter(高消耗)

### 7. Bug 修复
- crush-canvas 的 \$scope of null 错误
- 进入页面时音效预热不再发声(静音预热)
- 修复 walnut emoji 错用花生(🥜🌰)
- 弹窗遮挡粒子动画(延迟 1200ms 显示弹窗)

### 8. 文档
- 新增音效采集清单(grip-ring-audio-shopping-list.md)

## 修改文件
- grip-ring.vue: 主页面,音效集成、UI 重设计、食物对照
- grip-ring-canvas.vue: 沉浸式画布、握力环主体
- crush-canvas.vue: Canvas 2D 升级、差异化粒子系统
- useMetronome.ts: 音量参数支持
- dev-training-entry/index.vue: 入口配置微调

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:35:43 +08:00

1086 lines
28 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="stage-canvas-wrapper">
<GripRingCanvas
ref="gripCanvasRef"
:bpm="currentBpm"
:is-playing="isPlaying"
@toggle-play="onTogglePlay"
/>
<!-- BGM 开关按钮 -->
<view class="bgm-toggle" @click="toggleBgm">
<text class="bgm-icon">{{ bgmEnabled ? '🎵' : '🔇' }}</text>
</view>
</view>
<!-- ===== 实时统计 ===== -->
<view v-if="isPlaying || totalReps > 0" class="stats-bar">
<view class="stat-item-inline">
<text class="stat-label-inline">次数</text>
<text class="stat-value-inline">{{ totalReps }}</text>
</view>
<view class="stat-item-inline">
<text class="stat-label-inline">时长</text>
<text class="stat-value-inline">{{ formatTime(elapsedSeconds) }}</text>
</view>
<view class="stat-item-inline">
<text class="stat-label-inline">消耗糖分</text>
<text class="stat-value-inline">{{ totalSugar.toFixed(1) }}g</text>
</view>
</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>
<text class="preset-desc">{{ p.desc }}</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 v-if="showSummary" class="completion-card">
<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-primary" @click="onRestart">再来一次</button>
</view>
</view>
<!-- ===== 动作要领 ===== -->
<view class="tips-card">
<text class="tips-title">动作要领</text>
<text class="tips-item"> 完全握紧停顿 1 </text>
<text class="tips-item"> 完全张开放松</text>
<text class="tips-item"> 左右手交替训练</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { onShow, onHide } 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: '强化 · 挑战' },
]
// 奖励配置 - 按难度递增
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 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 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 onTogglePlay() {
if (isPlaying.value) {
// 暂停 - 显示总结
metronome.stop()
stopBgm()
stopTimer()
uni.setKeepScreenOn({ keepScreenOn: false })
showSummary.value = true
} else {
// 开始/继续
if (showSummary.value) {
// 从总结页继续 - 不重置数据
showSummary.value = false
} else if (totalReps.value === 0) {
// 首次开始 - 重置数据
resetStats()
}
metronome.start()
startBgm()
startTimer()
uni.setKeepScreenOn({ keepScreenOn: true })
}
}
// 重新开始
function onRestart() {
showSummary.value = false
resetStats()
metronome.start()
startBgm()
startTimer()
uni.setKeepScreenOn({ keepScreenOn: true })
}
// 重置统计
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)
}, 1000) as unknown as number
}
// 停止计时器
function stopTimer() {
if (timerInterval.value) {
clearInterval(timerInterval.value)
timerInterval.value = null
}
}
onShow(() => {
metronome.preload()
})
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: 30rpx 28rpx 50rpx;
background: $bg-color;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
color: $text-1;
}
/* ============================================================
* 画布区域
* ============================================================ */
.stage-canvas-wrapper {
width: 100vw;
margin-left: -28rpx;
margin-right: -28rpx;
margin-top: -30rpx; /* 向上拉,贴近导航栏 */
flex-shrink: 0;
/* 占主屏 60%,确保下方统计栏和档位可见 */
height: 60vh;
min-height: 720rpx;
max-height: 900rpx;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 24rpx;
position: relative;
}
/* ============================================================
* BGM 开关按钮
* ============================================================ */
.bgm-toggle {
position: absolute;
top: 32rpx;
right: 32rpx;
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;
}
/* ============================================================
* 实时统计栏
* ============================================================ */
.stats-bar {
width: 100%;
background: $card-bg;
border-radius: $radius-card;
padding: 24rpx 28rpx;
box-shadow:
0 4rpx 16rpx rgba(15, 23, 42, 0.04),
0 1rpx 2rpx rgba(15, 23, 42, 0.04);
display: flex;
justify-content: space-around;
align-items: center;
gap: 16rpx;
border: 2rpx solid rgba(20, 184, 166, 0.08);
position: relative;
overflow: hidden;
}
.stats-bar::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3rpx;
background: linear-gradient(90deg, transparent, $brand, transparent);
}
.stat-item-inline {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
flex: 1;
position: relative;
}
.stat-item-inline:not(:last-child)::after {
content: '';
position: absolute;
right: -8rpx;
top: 20%;
bottom: 20%;
width: 2rpx;
background: linear-gradient(to bottom, transparent, $card-border, transparent);
}
.stat-label-inline {
font-size: 22rpx;
color: $text-3;
font-weight: 500;
letter-spacing: 1rpx;
}
.stat-value-inline {
font-size: 32rpx;
color: $brand-deep;
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: 0;
}
/* ============================================================
* 三档位推荐
* ============================================================ */
.presets {
width: 100%;
display: flex;
gap: 14rpx;
}
.preset {
flex: 1;
background: $card-bg;
border: 2rpx solid $card-border;
border-radius: $radius-card;
padding: 26rpx 8rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
box-shadow: $shadow-sm;
transition: all 0.18s;
.preset-name {
font-size: 28rpx;
font-weight: 700;
color: $text-1;
letter-spacing: 2rpx;
}
.preset-bpm {
font-size: 22rpx;
color: $text-2;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.preset-desc {
font-size: 18rpx;
color: $text-3;
letter-spacing: 1rpx;
margin-top: 2rpx;
}
&:active {
transform: scale(0.97);
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&.active {
background: $brand-soft;
border-color: $brand;
box-shadow:
0 8rpx 20rpx rgba(20, 184, 166, 0.18),
inset 0 0 0 2rpx rgba(20, 184, 166, 0.4);
.preset-name {
color: $brand-deep;
}
.preset-bpm {
color: $brand-deep;
}
.preset-desc {
color: $brand;
}
}
}
/* ============================================================
* 奖励弹窗
* ============================================================ */
.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%;
background: linear-gradient(135deg, #f0fdfa, #ecfeff);
border-radius: $radius-card;
padding: 40rpx 28rpx 32rpx;
display: flex;
flex-direction: column;
gap: 28rpx;
align-items: center;
box-shadow:
0 8rpx 32rpx rgba(20, 184, 166, 0.08),
0 2rpx 8rpx rgba(15, 23, 42, 0.04);
border: 2rpx solid rgba(20, 184, 166, 0.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 {
background: $brand;
color: #fff;
border-radius: 999rpx;
font-size: 30rpx;
height: 88rpx;
line-height: 88rpx;
border: none;
padding: 0 48rpx;
}
/* ============================================================
* 动作要领
* ============================================================ */
.tips-card {
width: 100%;
background: $card-bg;
border-radius: $radius-card;
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
box-shadow: $shadow-sm;
.tips-title {
font-size: 28rpx;
font-weight: 600;
color: $text-1;
margin-bottom: 8rpx;
}
.tips-item {
font-size: 26rpx;
color: $text-2;
line-height: 1.6;
}
}
</style>