Files
zyt/TUICallKit-Vue3/training/pages/grip-ring.vue
T
longandClaude Opus 4.7 0225e9f971 feat(training): 握力环页面简化重构
## 主要改动

### 1. 连续训练模式
- 移除组数/休息逻辑,只统计总次数和时长
- 训练持续进行直到用户手动暂停
- 实时显示次数、时长、消耗糖分

### 2. 递进式奖励系统
- 鸡蛋(8-12次) → 核桃(15-20次) → 易拉罐(25-30次) → 气球(40-50次)
- 每个奖励在区间内随机触发
- 奖励按顺序递进,不会跳级
- 捏碎粒子特效 + 奖励弹窗

### 3. 糖分计算与食物对比
- 公式: (MET × 体重 × 时长) / 4
- MET = 3.5, 体重 = 60kg
- 食物对比: 苹果/香蕉/米饭/巧克力/可乐

### 4. 视觉设计优化
- 真实握力环造型(完整圆环 + 中空 + 握点)
- 立体渐变和纹理效果
- 挤压动画(训练时整体缩放)
- 简洁交互(整个环可点击,无中心按钮)

### 5. 问题修复
- 修复进入页面时音效预热响声(静音预热)
- 暂时禁用音效URL(避免404错误)
- 修复捏碎特效层级问题

## 技术实现

- 新增: grip-ring.vue (主页面)
- 新增: grip-ring-canvas.vue (画布组件)
- 修改: useMetronome.ts (静音预热)
- 修改: exercises.ts (移除握力环)
- 修改: index.vue (添加头部链接)
- 修改: dev-training-entry/index.vue (添加菜单入口)
- 修改: pages.json (添加路由)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:26:45 +08:00

810 lines
20 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"
/>
</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">
<text class="completion-title">训练完成 🎉</text>
<view class="completion-content">
<view class="stats-grid">
<view class="stat-item">
<text class="stat-label">总次数</text>
<text class="stat-value">{{ totalReps }}</text>
</view>
<view class="stat-item">
<text class="stat-label">总时长</text>
<text class="stat-value">{{ formatTime(elapsedSeconds) }}</text>
</view>
<view class="stat-item">
<text class="stat-label">消耗糖分</text>
<text class="stat-value">{{ totalSugar.toFixed(1) }}g</text>
</view>
</view>
<view class="sugar-card">
<text class="sugar-label">相当于消耗了</text>
<text class="sugar-comparison">{{ sugarComparison }}</text>
</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: '', // 音效文件暂未上传
minReps: 8,
maxReps: 12
},
{
emoji: '🥜',
title: '捏碎核桃',
desc: '力量惊人!',
itemType: 'walnut',
sound: '', // 音效文件暂未上传
minReps: 15,
maxReps: 20
},
{
emoji: '🥫',
title: '捏扁易拉罐',
desc: '太强了!',
itemType: 'can',
sound: '', // 音效文件暂未上传
minReps: 25,
maxReps: 30
},
{
emoji: '🎈',
title: '捏爆气球',
desc: '完美!',
itemType: 'balloon',
sound: '', // 音效文件暂未上传
minReps: 40,
maxReps: 50
},
]
// 食物糖分对照表 (每100g含糖量)
const FOOD_SUGAR_TABLE = [
{ name: '苹果', sugar: 10.3, unit: '个', weight: 200 }, // 1个苹果约200g
{ name: '香蕉', sugar: 12.2, unit: '根', weight: 120 }, // 1根香蕉约120g
{ name: '米饭', sugar: 25.9, unit: '碗', weight: 150 }, // 1碗米饭约150g
{ name: '巧克力', sugar: 51.5, unit: '块', weight: 50 }, // 1块巧克力约50g
{ 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 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,
onBeat: onBeat,
})
const { isPlaying } = metronome
const intervalMs = computed(() => 60000 / currentBpm.value)
const rootStyle = computed(() => ({
'--beat-duration': `${intervalMs.value}ms`,
}))
// 糖分对比
const sugarComparison = computed(() => {
if (totalSugar.value === 0) return ''
for (const food of FOOD_SUGAR_TABLE) {
const foodSugar = (food.sugar * food.weight) / 100
const count = totalSugar.value / foodSugar
if (count >= 0.3 && count <= 10) {
return `${count.toFixed(1)} ${food.unit}${food.name}的糖分`
}
}
return `${totalSugar.value.toFixed(1)}g 糖分`
})
// 节拍回调
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)
// 显示奖励弹窗
showRewardPopup.value = true
// 递进到下一个奖励等级
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))
}
}
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 onPresetTap(id: PresetId) {
if (isPlaying.value) return
currentPreset.value = id
metronome.setBpm(preset.value.bpm)
}
// 播放/暂停切换
function onTogglePlay() {
if (isPlaying.value) {
// 暂停 - 显示总结
metronome.stop()
stopTimer()
uni.setKeepScreenOn({ keepScreenOn: false })
showSummary.value = true
} else {
// 开始/继续
if (showSummary.value) {
// 从总结页继续 - 不重置数据
showSummary.value = false
} else if (totalReps.value === 0) {
// 首次开始 - 重置数据
resetStats()
}
metronome.start()
startTimer()
uni.setKeepScreenOn({ keepScreenOn: true })
}
}
// 重新开始
function onRestart() {
showSummary.value = false
resetStats()
metronome.start()
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()
stopTimer()
uni.setKeepScreenOn({ keepScreenOn: false })
}
if (crushAudioCtx.value) {
try {
crushAudioCtx.value.destroy()
} catch (_) {}
}
})
</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: #22c55e;
$brand-soft: #d1fae5;
$brand-deep: #047857;
$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;
flex-shrink: 0;
height: 600rpx;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 24rpx;
position: relative;
}
/* ============================================================
* 进度显示
* ============================================================ */
.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: 20rpx 24rpx;
box-shadow: $shadow-sm;
display: flex;
justify-content: space-around;
align-items: center;
gap: 16rpx;
}
.stat-item-inline {
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
}
.stat-label-inline {
font-size: 22rpx;
color: $text-3;
font-weight: 500;
}
.stat-value-inline {
font-size: 28rpx;
color: $brand-deep;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
/* ============================================================
* 三档位推荐
* ============================================================ */
.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(16, 185, 129, 0.18),
inset 0 0 0 2rpx rgba(16, 185, 129, 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%, #f0fdf4 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(34, 197, 94, 0.3);
transition: all 0.2s;
&:active {
transform: scale(0.97);
box-shadow: 0 4rpx 12rpx rgba(34, 197, 94, 0.2);
}
}
/* ============================================================
* 训练完成卡片
* ============================================================ */
.completion-card {
width: 100%;
background: linear-gradient(135deg, #dbeafe, #ede9fe);
border-radius: $radius-card;
padding: 32rpx 24rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
align-items: center;
box-shadow: $shadow-sm;
}
.completion-title {
font-size: 30rpx;
color: $text-1;
font-weight: 700;
}
.completion-content {
width: 100%;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.stats-grid {
display: flex;
gap: 16rpx;
width: 100%;
}
.stat-item {
flex: 1;
background: rgba(255, 255, 255, 0.8);
border-radius: 16rpx;
padding: 20rpx 12rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stat-label {
font-size: 24rpx;
color: #64748b;
}
.stat-value {
font-size: 32rpx;
font-weight: 700;
color: $text-1;
}
.sugar-card {
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
border-radius: 16rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
width: 100%;
}
.sugar-label {
font-size: 24rpx;
color: #065f46;
font-weight: 500;
}
.sugar-comparison {
font-size: 28rpx;
color: #047857;
font-weight: 700;
text-align: center;
}
.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>