feat(training): 握力环训练趣味化 - 捏碎特效 + 卡路里统计
## 新增功能 ### 1. Canvas 粒子捏碎特效 - 新建 crush-canvas.vue 组件(透明叠加层) - 4 种捏碎物品:鸡蛋🥚、核桃🥜、易拉罐🥫、气球🎈 - 粒子物理模拟:径向扩散 + 重力 + 空气阻力 + 生命值衰减 - 性能优化:固定 60fps 时间步长,粒子边界清理 ### 2. 随机触发逻辑 - 平均每 8-12 次随机触发捏碎特效 - 在握力环压缩峰值(scale 0.65)时触发 - 随机选择 4 种物品之一 - 资源清理:训练暂停/结束时清理所有定时器 ### 3. 卡路里统计与完成页面 - 标准 MET 公式计算卡路里(MET = 3.5,默认 60kg) - 5 种食物自动匹配(苹果、鸡蛋、巧克力、香蕉、米饭) - 完成页面显示:组数、次数、时长、卡路里、食物对比 - 样式设计:浅色清爽风格,绿色渐变卡片 ### 4. 预留接口 - useTTS.ts:预留 MiMo TTS 语音鼓励接口 ## 技术实现 - Canvas 粒子系统:20-30 个粒子,物理模拟流畅 - 类型安全:完整的 TypeScript 类型定义 - 性能优化:固定时间步长、边界清理、资源管理 - 响应式布局:适配不同屏幕尺寸 ## 相关任务 Task: .trellis/tasks/05-27-grip-ring-gamification PRD: prd.md Research: research/calorie-calculation.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* TTS 语音鼓励 Hook
|
||||||
|
* 用于在捏碎特效触发时播放语音鼓励
|
||||||
|
*
|
||||||
|
* 当前状态:预留接口,待集成 MiMo TTS
|
||||||
|
* TODO: 集成 MiMo TTS SDK
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useTTS() {
|
||||||
|
/**
|
||||||
|
* 播放语音鼓励
|
||||||
|
* 随机播放:"加油"、"太棒了"、"继续"、"很好"等
|
||||||
|
*
|
||||||
|
* 实现计划:
|
||||||
|
* 1. 引入 MiMo TTS SDK
|
||||||
|
* 2. 配置语音库(鼓励短语列表)
|
||||||
|
* 3. 随机选择短语并播放
|
||||||
|
*/
|
||||||
|
const playEncouragement = () => {
|
||||||
|
// TODO: 集成 MiMo TTS
|
||||||
|
// 示例实现:
|
||||||
|
// const phrases = ['加油', '太棒了', '继续', '很好', '坚持']
|
||||||
|
// const phrase = phrases[Math.floor(Math.random() * phrases.length)]
|
||||||
|
// mimoTTS.speak(phrase)
|
||||||
|
|
||||||
|
console.log('[TTS] 播放语音鼓励(待实现 MiMo TTS 集成)')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
playEncouragement
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { ref, computed, onUnmounted } from 'vue'
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
|
import { calculateCalories, getFoodComparison } from '../utils/calorie'
|
||||||
|
import type { FoodComparison } from '../utils/calorie'
|
||||||
|
|
||||||
export type SessionPhase = 'idle' | 'training' | 'resting' | 'done'
|
export type SessionPhase = 'idle' | 'training' | 'resting' | 'done'
|
||||||
|
|
||||||
|
export type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
|
||||||
|
|
||||||
export interface TrainingSessionConfig {
|
export interface TrainingSessionConfig {
|
||||||
sets: number
|
sets: number
|
||||||
reps: number
|
reps: number
|
||||||
@@ -13,9 +17,35 @@ export interface TrainingSessionConfig {
|
|||||||
onExitRest?: () => void
|
onExitRest?: () => void
|
||||||
onDone?: () => void
|
onDone?: () => void
|
||||||
onCountdownTick?: (remainingSec: number) => void
|
onCountdownTick?: (remainingSec: number) => void
|
||||||
|
onCrushTrigger?: (itemType: CrushItemType) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTrainingSession() {
|
export interface TrainingStats {
|
||||||
|
totalSets: number
|
||||||
|
totalReps: number
|
||||||
|
durationSeconds: number
|
||||||
|
calories: number
|
||||||
|
foodComparison: FoodComparison
|
||||||
|
}
|
||||||
|
|
||||||
|
// 捏碎特效配置
|
||||||
|
const CRUSH_TRIGGER_INTERVAL_MIN = 8 // 最小触发间隔
|
||||||
|
const CRUSH_TRIGGER_INTERVAL_MAX = 12 // 最大触发间隔
|
||||||
|
const CRUSH_ITEMS: CrushItemType[] = ['egg', 'walnut', 'can', 'balloon']
|
||||||
|
|
||||||
|
// 随机触发判断(平均每 8-12 次触发一次)
|
||||||
|
function shouldTriggerCrush(): boolean {
|
||||||
|
// 使用随机间隔的平均值:(8 + 12) / 2 = 10
|
||||||
|
const avgInterval = (CRUSH_TRIGGER_INTERVAL_MIN + CRUSH_TRIGGER_INTERVAL_MAX) / 2
|
||||||
|
return Math.random() < (1 / avgInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机选择物品
|
||||||
|
function randomCrushItem(): CrushItemType {
|
||||||
|
return CRUSH_ITEMS[Math.floor(Math.random() * CRUSH_ITEMS.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrainingSession(bpm?: number) {
|
||||||
const phase = ref<SessionPhase>('idle')
|
const phase = ref<SessionPhase>('idle')
|
||||||
const currentSet = ref<number>(0)
|
const currentSet = ref<number>(0)
|
||||||
const currentRep = ref<number>(0)
|
const currentRep = ref<number>(0)
|
||||||
@@ -24,6 +54,9 @@ export function useTrainingSession() {
|
|||||||
|
|
||||||
let config: TrainingSessionConfig | null = null
|
let config: TrainingSessionConfig | null = null
|
||||||
let restTimer: ReturnType<typeof setInterval> | null = null
|
let restTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let crushTimers: ReturnType<typeof setTimeout>[] = [] // 存储所有捏碎特效的 setTimeout
|
||||||
|
let currentBpm = bpm ?? 80
|
||||||
|
let startTime = 0 // 训练开始时间戳
|
||||||
|
|
||||||
const totalReps = computed(() => config?.reps ?? 0)
|
const totalReps = computed(() => config?.reps ?? 0)
|
||||||
const totalSets = computed(() => config?.sets ?? 0)
|
const totalSets = computed(() => config?.sets ?? 0)
|
||||||
@@ -33,6 +66,10 @@ export function useTrainingSession() {
|
|||||||
const isResting = computed(() => phase.value === 'resting')
|
const isResting = computed(() => phase.value === 'resting')
|
||||||
const isDone = computed(() => phase.value === 'done')
|
const isDone = computed(() => phase.value === 'done')
|
||||||
|
|
||||||
|
const setBpm = (newBpm: number) => {
|
||||||
|
currentBpm = newBpm
|
||||||
|
}
|
||||||
|
|
||||||
const start = (cfg: TrainingSessionConfig) => {
|
const start = (cfg: TrainingSessionConfig) => {
|
||||||
config = cfg
|
config = cfg
|
||||||
phase.value = 'training'
|
phase.value = 'training'
|
||||||
@@ -40,6 +77,27 @@ export function useTrainingSession() {
|
|||||||
currentRep.value = 0
|
currentRep.value = 0
|
||||||
beatCounter.value = 0
|
beatCounter.value = 0
|
||||||
restRemainingSec.value = 0
|
restRemainingSec.value = 0
|
||||||
|
startTime = Date.now() // 记录开始时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取训练统计数据
|
||||||
|
const getStats = (): TrainingStats => {
|
||||||
|
const durationSeconds = Math.floor((Date.now() - startTime) / 1000)
|
||||||
|
const actualTotalReps = (config?.sets ?? 0) * (config?.reps ?? 0)
|
||||||
|
|
||||||
|
const calories = calculateCalories({
|
||||||
|
totalReps: actualTotalReps,
|
||||||
|
bpm: currentBpm,
|
||||||
|
userWeight: 60, // 默认 60kg
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSets: config?.sets ?? 0,
|
||||||
|
totalReps: actualTotalReps,
|
||||||
|
durationSeconds,
|
||||||
|
calories,
|
||||||
|
foodComparison: getFoodComparison(calories),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onBeat = () => {
|
const onBeat = () => {
|
||||||
@@ -52,6 +110,17 @@ export function useTrainingSession() {
|
|||||||
currentRep.value++
|
currentRep.value++
|
||||||
config.onRepComplete?.(currentRep.value, totalReps.value)
|
config.onRepComplete?.(currentRep.value, totalReps.value)
|
||||||
|
|
||||||
|
// 随机触发捏碎特效
|
||||||
|
if (shouldTriggerCrush() && config.onCrushTrigger) {
|
||||||
|
const item = randomCrushItem()
|
||||||
|
// 延迟到动画峰值(50%)触发
|
||||||
|
const repDurationMs = (60000 / currentBpm) * beatsPerRep.value
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
config?.onCrushTrigger?.(item)
|
||||||
|
}, repDurationMs / 2)
|
||||||
|
crushTimers.push(timer)
|
||||||
|
}
|
||||||
|
|
||||||
if (currentRep.value >= totalReps.value) {
|
if (currentRep.value >= totalReps.value) {
|
||||||
config.onSetComplete?.(currentSet.value, totalSets.value)
|
config.onSetComplete?.(currentSet.value, totalSets.value)
|
||||||
|
|
||||||
@@ -104,6 +173,10 @@ export function useTrainingSession() {
|
|||||||
clearInterval(restTimer)
|
clearInterval(restTimer)
|
||||||
restTimer = null
|
restTimer = null
|
||||||
}
|
}
|
||||||
|
// 清理所有待触发的捏碎特效
|
||||||
|
crushTimers.forEach(timer => clearTimeout(timer))
|
||||||
|
crushTimers = []
|
||||||
|
|
||||||
phase.value = 'idle'
|
phase.value = 'idle'
|
||||||
currentSet.value = 0
|
currentSet.value = 0
|
||||||
currentRep.value = 0
|
currentRep.value = 0
|
||||||
@@ -113,6 +186,8 @@ export function useTrainingSession() {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (restTimer) clearInterval(restTimer)
|
if (restTimer) clearInterval(restTimer)
|
||||||
|
crushTimers.forEach(timer => clearTimeout(timer))
|
||||||
|
crushTimers = []
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -129,5 +204,7 @@ export function useTrainingSession() {
|
|||||||
stop,
|
stop,
|
||||||
skipRest,
|
skipRest,
|
||||||
onBeat,
|
onBeat,
|
||||||
|
setBpm,
|
||||||
|
getStats,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<template>
|
||||||
|
<view class="crush-canvas-container">
|
||||||
|
<canvas
|
||||||
|
canvas-id="crush"
|
||||||
|
id="crush"
|
||||||
|
class="crush-canvas"
|
||||||
|
:style="{ width: canvasSizePx + 'px', height: canvasSizePx + 'px' }"
|
||||||
|
></canvas>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
|
||||||
|
|
||||||
|
// 粒子接口
|
||||||
|
interface Particle {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
vx: number
|
||||||
|
vy: number
|
||||||
|
size: number
|
||||||
|
color: string
|
||||||
|
life: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 捏碎物品类型
|
||||||
|
type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
|
||||||
|
|
||||||
|
// 捏碎物品配置
|
||||||
|
interface CrushItemConfig {
|
||||||
|
emoji: string
|
||||||
|
color: string
|
||||||
|
particles: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRUSH_ITEMS: Record<CrushItemType, CrushItemConfig> = {
|
||||||
|
egg: { emoji: '🥚', color: '#FFF9C4', particles: 20 },
|
||||||
|
walnut: { emoji: '🥜', color: '#8D6E63', particles: 25 },
|
||||||
|
can: { emoji: '🥫', color: '#B0BEC5', particles: 30 },
|
||||||
|
balloon: { emoji: '🎈', color: '#FF5252', particles: 15 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 物理参数常量
|
||||||
|
const PHYSICS = {
|
||||||
|
GRAVITY: 0.3, // 重力加速度
|
||||||
|
AIR_RESISTANCE: 0.98, // 空气阻力系数
|
||||||
|
LIFE_DECAY: 0.015, // 生命值衰减速度(约 67 帧 = 1.1 秒)
|
||||||
|
SPEED_MIN: 3, // 最小初速度
|
||||||
|
SPEED_MAX: 5, // 最大初速度
|
||||||
|
SIZE_MIN: 4, // 最小粒子尺寸
|
||||||
|
SIZE_MAX: 8, // 最大粒子尺寸
|
||||||
|
FPS: 60 // 目标帧率
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas 尺寸(px)
|
||||||
|
const canvasSizePx = ref<number>(240)
|
||||||
|
const ctx = ref<any>(null)
|
||||||
|
const timer = ref<number | null>(null)
|
||||||
|
const renderRunning = ref<boolean>(false)
|
||||||
|
const particles = ref<Particle[]>([])
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initCanvas()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化 Canvas
|
||||||
|
function initCanvas() {
|
||||||
|
setTimeout(() => {
|
||||||
|
const query = uni.createSelectorQuery().in(getCurrentInstance())
|
||||||
|
query.select('.crush-canvas-container').boundingClientRect(data => {
|
||||||
|
if (data && data.width > 0) {
|
||||||
|
// 获取实际渲染尺寸(px)
|
||||||
|
canvasSizePx.value = data.width
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.value) {
|
||||||
|
ctx.value = uni.createCanvasContext('crush', getCurrentInstance())
|
||||||
|
}
|
||||||
|
}).exec()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发捏碎特效(供外部调用)
|
||||||
|
function triggerCrush(itemType: CrushItemType) {
|
||||||
|
if (!ctx.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = CRUSH_ITEMS[itemType]
|
||||||
|
if (!item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成粒子
|
||||||
|
const centerX = canvasSizePx.value / 2
|
||||||
|
const centerY = canvasSizePx.value / 2
|
||||||
|
const particleCount = item.particles
|
||||||
|
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
// 随机角度和速度(径向扩散)
|
||||||
|
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5
|
||||||
|
const speed = PHYSICS.SPEED_MIN + Math.random() * (PHYSICS.SPEED_MAX - PHYSICS.SPEED_MIN)
|
||||||
|
|
||||||
|
particles.value.push({
|
||||||
|
x: centerX,
|
||||||
|
y: centerY,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
size: PHYSICS.SIZE_MIN + Math.random() * (PHYSICS.SIZE_MAX - PHYSICS.SIZE_MIN),
|
||||||
|
color: item.color,
|
||||||
|
life: 1.0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureRenderLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保渲染循环运行
|
||||||
|
function ensureRenderLoop() {
|
||||||
|
if (renderRunning.value) return
|
||||||
|
renderRunning.value = true
|
||||||
|
renderLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染循环
|
||||||
|
function renderLoop() {
|
||||||
|
// 更新粒子物理
|
||||||
|
for (let i = particles.value.length - 1; i >= 0; i--) {
|
||||||
|
const p = particles.value[i]
|
||||||
|
|
||||||
|
// 重力
|
||||||
|
p.vy += PHYSICS.GRAVITY
|
||||||
|
|
||||||
|
// 空气阻力
|
||||||
|
p.vx *= PHYSICS.AIR_RESISTANCE
|
||||||
|
p.vy *= PHYSICS.AIR_RESISTANCE
|
||||||
|
|
||||||
|
// 位置更新
|
||||||
|
p.x += p.vx
|
||||||
|
p.y += p.vy
|
||||||
|
|
||||||
|
// 生命值衰减
|
||||||
|
p.life -= PHYSICS.LIFE_DECAY
|
||||||
|
|
||||||
|
// 移除死亡粒子或飞出画布的粒子
|
||||||
|
const outOfBounds = p.x < -50 || p.x > canvasSizePx.value + 50 ||
|
||||||
|
p.y < -50 || p.y > canvasSizePx.value + 50
|
||||||
|
if (p.life <= 0 || outOfBounds) {
|
||||||
|
particles.value.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制
|
||||||
|
draw()
|
||||||
|
ctx.value.draw(false)
|
||||||
|
|
||||||
|
// 决定是否继续渲染
|
||||||
|
if (particles.value.length > 0) {
|
||||||
|
timer.value = setTimeout(() => {
|
||||||
|
renderLoop()
|
||||||
|
}, 1000 / PHYSICS.FPS) as unknown as number
|
||||||
|
} else {
|
||||||
|
timer.value = null
|
||||||
|
renderRunning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制粒子
|
||||||
|
function draw() {
|
||||||
|
const c = ctx.value
|
||||||
|
const size = canvasSizePx.value
|
||||||
|
|
||||||
|
// 清空画布(透明)
|
||||||
|
c.clearRect(0, 0, size, size)
|
||||||
|
|
||||||
|
// 绘制所有粒子
|
||||||
|
particles.value.forEach(p => {
|
||||||
|
const alpha = p.life
|
||||||
|
const currentSize = p.size * (0.5 + p.life * 0.5)
|
||||||
|
|
||||||
|
c.beginPath()
|
||||||
|
c.arc(p.x, p.y, currentSize, 0, Math.PI * 2)
|
||||||
|
c.fillStyle = hexToRgba(p.color, alpha)
|
||||||
|
c.fill()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 颜色转换工具
|
||||||
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16)
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16)
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16)
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理资源
|
||||||
|
function cleanup() {
|
||||||
|
if (timer.value) {
|
||||||
|
clearTimeout(timer.value)
|
||||||
|
timer.value = null
|
||||||
|
}
|
||||||
|
renderRunning.value = false
|
||||||
|
particles.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法供父组件调用
|
||||||
|
defineExpose({
|
||||||
|
triggerCrush
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.crush-canvas-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none; /* 不阻挡点击事件 */
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crush-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -24,9 +24,12 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 握力环:缩放 -->
|
<!-- 握力环:缩放 + 粒子特效 -->
|
||||||
<view v-else-if="animationType === 'grip'" class="grip-ring">
|
<view v-else-if="animationType === 'grip'" class="grip-container">
|
||||||
<view class="grip-ring-inner">{{ icon }}</view>
|
<view class="grip-ring">
|
||||||
|
<view class="grip-ring-inner">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
<CrushCanvas ref="crushCanvasRef" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 卷腹:身体折叠 -->
|
<!-- 卷腹:身体折叠 -->
|
||||||
@@ -38,8 +41,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { AnimationType } from '../exercises'
|
import type { AnimationType } from '../exercises'
|
||||||
|
import CrushCanvas from './crush-canvas.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
animationType: AnimationType
|
animationType: AnimationType
|
||||||
@@ -62,6 +66,17 @@ const cssVars = computed(() => {
|
|||||||
'--anim-state': props.isPlaying ? 'running' : 'paused',
|
'--anim-state': props.isPlaying ? 'running' : 'paused',
|
||||||
} as Record<string, string>
|
} as Record<string, string>
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const crushCanvasRef = ref()
|
||||||
|
|
||||||
|
// 暴露捏碎特效触发方法(供外部测试)
|
||||||
|
const triggerCrushEffect = (itemType: 'egg' | 'walnut' | 'can' | 'balloon') => {
|
||||||
|
crushCanvasRef.value?.triggerCrush(itemType)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
triggerCrushEffect
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -208,6 +223,12 @@ const cssVars = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 握力环 ===== */
|
/* ===== 握力环 ===== */
|
||||||
|
.grip-container {
|
||||||
|
position: relative;
|
||||||
|
width: 240rpx;
|
||||||
|
height: 240rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.grip-ring {
|
.grip-ring {
|
||||||
width: 240rpx;
|
width: 240rpx;
|
||||||
height: 240rpx;
|
height: 240rpx;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
|
|
||||||
<view v-if="selected" class="exercise-detail">
|
<view v-if="selected" class="exercise-detail">
|
||||||
<exercise-anim
|
<exercise-anim
|
||||||
|
ref="exerciseAnimRef"
|
||||||
:animation-type="selected.animationType"
|
:animation-type="selected.animationType"
|
||||||
:bpm="bpm"
|
:bpm="bpm"
|
||||||
:beats-per-rep="selected.beatsPerRep"
|
:beats-per-rep="selected.beatsPerRep"
|
||||||
@@ -126,7 +127,32 @@
|
|||||||
|
|
||||||
<view v-if="isDone" class="status-card done">
|
<view v-if="isDone" class="status-card done">
|
||||||
<text class="status-title">训练完成 🎉</text>
|
<text class="status-title">训练完成 🎉</text>
|
||||||
<text class="status-line">共 {{ totalSets }} 组 × {{ totalReps }} 次</text>
|
|
||||||
|
<view v-if="trainingStats" class="completion-content">
|
||||||
|
<view class="stats-grid">
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-label">组数</text>
|
||||||
|
<text class="stat-value">{{ trainingStats.totalSets }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-label">次数</text>
|
||||||
|
<text class="stat-value">{{ trainingStats.totalReps }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-label">时长</text>
|
||||||
|
<text class="stat-value">{{ formatDuration(trainingStats.durationSeconds) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="calorie-card">
|
||||||
|
<text class="calorie-label">消耗卡路里</text>
|
||||||
|
<text class="calorie-value">{{ trainingStats.calories }} kcal</text>
|
||||||
|
<text class="food-comparison">
|
||||||
|
{{ trainingStats.foodComparison.message }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="action-row">
|
<view class="action-row">
|
||||||
<button class="btn-primary" @click="onStart">再来一次</button>
|
<button class="btn-primary" @click="onStart">再来一次</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -153,8 +179,11 @@ import { EXERCISE_PRESETS, getPresetById } from './exercises'
|
|||||||
import type { ExercisePreset } from './exercises'
|
import type { ExercisePreset } from './exercises'
|
||||||
import { useMetronome } from '../hooks/useMetronome'
|
import { useMetronome } from '../hooks/useMetronome'
|
||||||
import { useTrainingSession } from '../hooks/useTrainingSession'
|
import { useTrainingSession } from '../hooks/useTrainingSession'
|
||||||
|
import type { CrushItemType, TrainingStats } from '../hooks/useTrainingSession'
|
||||||
import { useVoiceCoach } from '../hooks/useVoiceCoach'
|
import { useVoiceCoach } from '../hooks/useVoiceCoach'
|
||||||
import { useTrainingBgm } from '../hooks/useTrainingBgm'
|
import { useTrainingBgm } from '../hooks/useTrainingBgm'
|
||||||
|
import { useTTS } from '../hooks/useTTS'
|
||||||
|
import { formatDuration } from '../utils/calorie'
|
||||||
|
|
||||||
const equipmentList = ['哑铃', '握力环', '健身环', '徒手'] as const
|
const equipmentList = ['哑铃', '握力环', '健身环', '徒手'] as const
|
||||||
type Equipment = (typeof equipmentList)[number]
|
type Equipment = (typeof equipmentList)[number]
|
||||||
@@ -189,8 +218,11 @@ watch(
|
|||||||
|
|
||||||
const voice = useVoiceCoach()
|
const voice = useVoiceCoach()
|
||||||
const bgm = useTrainingBgm()
|
const bgm = useTrainingBgm()
|
||||||
|
const tts = useTTS()
|
||||||
|
|
||||||
const session = useTrainingSession()
|
const exerciseAnimRef = ref()
|
||||||
|
|
||||||
|
const session = useTrainingSession(bpm.value)
|
||||||
const {
|
const {
|
||||||
currentSet,
|
currentSet,
|
||||||
currentRep,
|
currentRep,
|
||||||
@@ -200,15 +232,21 @@ const {
|
|||||||
isTraining,
|
isTraining,
|
||||||
isResting,
|
isResting,
|
||||||
isDone,
|
isDone,
|
||||||
|
getStats,
|
||||||
} = session
|
} = session
|
||||||
|
|
||||||
|
const trainingStats = ref<TrainingStats | null>(null)
|
||||||
|
|
||||||
const metronome = useMetronome({
|
const metronome = useMetronome({
|
||||||
initialBpm: bpm.value,
|
initialBpm: bpm.value,
|
||||||
onBeat: () => session.onBeat(),
|
onBeat: () => session.onBeat(),
|
||||||
})
|
})
|
||||||
const { beatIndex, isPlaying: metronomeIsPlaying } = metronome
|
const { beatIndex, isPlaying: metronomeIsPlaying } = metronome
|
||||||
|
|
||||||
watch(bpm, (v) => metronome.setBpm(v))
|
watch(bpm, (v) => {
|
||||||
|
metronome.setBpm(v)
|
||||||
|
session.setBpm(v)
|
||||||
|
})
|
||||||
|
|
||||||
const onSwitchEquipment = (eq: Equipment) => {
|
const onSwitchEquipment = (eq: Equipment) => {
|
||||||
if (isTraining.value || isResting.value) return
|
if (isTraining.value || isResting.value) return
|
||||||
@@ -234,6 +272,14 @@ const adjust = (key: 'sets' | 'reps' | 'rest', delta: number) => {
|
|||||||
if (key === 'rest') restSec.value = Math.max(0, Math.min(300, restSec.value + delta))
|
if (key === 'rest') restSec.value = Math.max(0, Math.min(300, restSec.value + delta))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onCrushTrigger = (item: CrushItemType) => {
|
||||||
|
// 触发粒子特效
|
||||||
|
exerciseAnimRef.value?.triggerCrushEffect(item)
|
||||||
|
|
||||||
|
// 播放语音鼓励
|
||||||
|
tts.playEncouragement()
|
||||||
|
}
|
||||||
|
|
||||||
const onStart = () => {
|
const onStart = () => {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
@@ -267,7 +313,10 @@ const onStart = () => {
|
|||||||
bgm.switchTo('none')
|
bgm.switchTo('none')
|
||||||
voice.speakPrompt('good-job')
|
voice.speakPrompt('good-job')
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
// 获取训练统计数据
|
||||||
|
trainingStats.value = getStats()
|
||||||
},
|
},
|
||||||
|
onCrushTrigger: onCrushTrigger,
|
||||||
})
|
})
|
||||||
|
|
||||||
bgm.switchTo('training')
|
bgm.switchTo('training')
|
||||||
@@ -563,6 +612,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
&.done {
|
&.done {
|
||||||
background: linear-gradient(135deg, #dbeafe, #ede9fe);
|
background: linear-gradient(135deg, #dbeafe, #ede9fe);
|
||||||
|
gap: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-title {
|
.status-title {
|
||||||
@@ -581,6 +631,71 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.completion-content {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
flex: 1;
|
||||||
|
background: #f8fafc;
|
||||||
|
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: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calorie-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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calorie-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #065f46;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calorie-value {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #047857;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-comparison {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #065f46;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.tips-card {
|
.tips-card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* 卡路里计算工具
|
||||||
|
* 基于标准 MET (Metabolic Equivalent of Task) 公式
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FoodItem {
|
||||||
|
name: string
|
||||||
|
emoji: string
|
||||||
|
calories: number
|
||||||
|
unit: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FoodComparison {
|
||||||
|
food: FoodItem
|
||||||
|
ratio: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalorieCalculationConfig {
|
||||||
|
totalReps: number
|
||||||
|
bpm: number
|
||||||
|
userWeight?: number // 默认 60kg
|
||||||
|
}
|
||||||
|
|
||||||
|
// 食物卡路里数据库
|
||||||
|
export const FOOD_CALORIES: Record<string, FoodItem> = {
|
||||||
|
apple: { name: '苹果', emoji: '🍎', calories: 52, unit: '个(100g)' },
|
||||||
|
egg: { name: '鸡蛋', emoji: '🥚', calories: 70, unit: '个' },
|
||||||
|
chocolate: { name: '巧克力', emoji: '🍫', calories: 54, unit: '块(10g)' },
|
||||||
|
banana: { name: '香蕉', emoji: '🍌', calories: 89, unit: '根' },
|
||||||
|
rice: { name: '米饭', emoji: '🍚', calories: 116, unit: '碗(100g)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算卡路里消耗
|
||||||
|
* 公式:卡路里 = MET × 体重(kg) × 时长(小时)
|
||||||
|
*
|
||||||
|
* @param config 计算配置
|
||||||
|
* @returns 卡路里消耗(kcal,保留一位小数)
|
||||||
|
*/
|
||||||
|
export function calculateCalories(config: CalorieCalculationConfig): number {
|
||||||
|
const MET = 3.5 // 握力环训练的标准 MET 值
|
||||||
|
const weight = config.userWeight || 60 // 默认 60kg
|
||||||
|
|
||||||
|
// 边界情况:无效输入
|
||||||
|
if (config.totalReps <= 0 || config.bpm <= 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondsPerRep = 60 / config.bpm
|
||||||
|
const activeTimeHours = (config.totalReps * secondsPerRep) / 3600
|
||||||
|
|
||||||
|
const calories = MET * weight * activeTimeHours
|
||||||
|
return Math.round(calories * 10) / 10 // 保留一位小数
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动匹配最接近的食物对比
|
||||||
|
*
|
||||||
|
* @param burnedCalories 消耗的卡路里
|
||||||
|
* @returns 食物对比信息
|
||||||
|
*/
|
||||||
|
export function getFoodComparison(burnedCalories: number): FoodComparison {
|
||||||
|
const foods = Object.values(FOOD_CALORIES)
|
||||||
|
|
||||||
|
// 边界情况:食物数据库为空
|
||||||
|
if (foods.length === 0) {
|
||||||
|
throw new Error('食物数据库为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 边界情况:卡路里为 0 或负数
|
||||||
|
if (burnedCalories <= 0) {
|
||||||
|
return {
|
||||||
|
food: foods[0],
|
||||||
|
ratio: 0,
|
||||||
|
message: `暂无消耗`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到卡路里最接近的食物
|
||||||
|
const closest = foods.reduce((prev, curr) => {
|
||||||
|
const prevDiff = Math.abs(prev.calories - burnedCalories)
|
||||||
|
const currDiff = Math.abs(curr.calories - burnedCalories)
|
||||||
|
return currDiff < prevDiff ? curr : prev
|
||||||
|
})
|
||||||
|
|
||||||
|
const ratio = burnedCalories / closest.calories
|
||||||
|
const ratioRounded = Math.round(ratio * 100) / 100 // 保留两位小数
|
||||||
|
|
||||||
|
// 格式化显示:ratio < 1 时显示分数形式更直观
|
||||||
|
let displayText: string
|
||||||
|
if (ratioRounded < 1) {
|
||||||
|
displayText = `相当于 ${ratioRounded.toFixed(2)} 个${closest.name} ${closest.emoji}`
|
||||||
|
} else {
|
||||||
|
displayText = `相当于 ${ratioRounded.toFixed(2)} ${closest.unit}${closest.name} ${closest.emoji}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
food: closest,
|
||||||
|
ratio: ratioRounded,
|
||||||
|
message: displayText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时长(秒 → 分:秒)
|
||||||
|
*
|
||||||
|
* @param seconds 秒数
|
||||||
|
* @returns 格式化字符串(如 "3:45")
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user