Files
zyt/TUICallKit-Vue3/training/pages/components/grip-ring-canvas.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

275 lines
6.2 KiB
Vue

<template>
<view class="grip-ring-canvas">
<!-- 捏碎特效画布 (最底层) -->
<CrushCanvas ref="crushCanvasRef" />
<!-- 握力环容器 -->
<view class="ring-container" :class="{ playing: isPlaying }" @click="onButtonClick">
<!-- 握力环主体 -->
<view class="grip-ring">
<!-- 外圈 -->
<view class="ring-outer-circle" />
<!-- 内圈 -->
<view class="ring-inner-circle" />
<!-- 纹理层 -->
<view class="ring-texture" />
<!-- 顶部握点 -->
<view class="grip-dot grip-dot-top" />
<!-- 底部握点 -->
<view class="grip-dot grip-dot-bottom" />
</view>
<!-- 中心提示 (仅未开始时显示) -->
<view v-if="!isPlaying" class="center-hint">
<view class="hint-icon"></view>
<text class="hint-text">点击开始</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import CrushCanvas from './crush-canvas.vue'
interface Props {
bpm: number
isPlaying: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
togglePlay: []
}>()
const crushCanvasRef = ref()
const onButtonClick = () => {
emit('togglePlay')
}
// 暴露捏碎特效触发方法
const triggerCrushEffect = (itemType: 'egg' | 'walnut' | 'can' | 'balloon') => {
crushCanvasRef.value?.triggerCrush(itemType)
}
defineExpose({
triggerCrushEffect
})
</script>
<style lang="scss" scoped>
.grip-ring-canvas {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
}
/* ============================================================
* 握力环容器
* ============================================================ */
.ring-container {
position: relative;
width: 400rpx;
height: 400rpx;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1;
}
/* ============================================================
* 握力环主体 - 模拟真实握力环
* ============================================================ */
.grip-ring {
position: relative;
width: 320rpx;
height: 320rpx;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.ring-container:active .grip-ring {
transform: scale(0.95);
}
/* 外圈 - 主体圆环 */
.ring-outer-circle {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background: linear-gradient(135deg, #34d399 0%, #10b981 50%, #059669 100%);
box-shadow:
0 12rpx 40rpx rgba(16, 185, 129, 0.4),
inset 0 -8rpx 16rpx rgba(0, 0, 0, 0.2),
inset 0 8rpx 16rpx rgba(255, 255, 255, 0.3);
}
/* 内圈 - 中空部分 */
.ring-inner-circle {
position: absolute;
width: 180rpx;
height: 180rpx;
border-radius: 50%;
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
box-shadow:
inset 0 4rpx 12rpx rgba(16, 185, 129, 0.3),
inset 0 -4rpx 12rpx rgba(0, 0, 0, 0.1);
}
/* 纹理层 - 模拟握力环表面纹理 */
.ring-texture {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background-image:
repeating-conic-gradient(
from 0deg,
transparent 0deg,
transparent 2deg,
rgba(255, 255, 255, 0.08) 2deg,
rgba(255, 255, 255, 0.08) 4deg
);
pointer-events: none;
}
/* 握点 - 顶部和底部的凸起 */
.grip-dot {
position: absolute;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: linear-gradient(135deg, #10b981, #047857);
box-shadow:
0 6rpx 20rpx rgba(16, 185, 129, 0.5),
inset 0 2rpx 6rpx rgba(255, 255, 255, 0.4),
inset 0 -2rpx 6rpx rgba(0, 0, 0, 0.3);
z-index: 2;
}
.grip-dot-top {
top: -12rpx;
left: 50%;
transform: translateX(-50%);
}
.grip-dot-bottom {
bottom: -12rpx;
left: 50%;
transform: translateX(-50%);
}
/* 播放时的挤压动画 */
.ring-container.playing .grip-ring {
animation: squeeze-ring 1s ease-in-out infinite;
}
.ring-container.playing .grip-dot {
animation: dot-pulse 1s ease-in-out infinite;
}
@keyframes squeeze-ring {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(0.92);
}
}
@keyframes dot-pulse {
0%, 100% {
transform: translateX(-50%) scale(1);
}
50% {
transform: translateX(-50%) scale(1.15);
}
}
/* 底部握点特殊处理 */
.grip-dot-bottom {
animation: dot-pulse-bottom 1s ease-in-out infinite;
}
@keyframes dot-pulse-bottom {
0%, 100% {
transform: translateX(-50%) translateY(0) scale(1);
}
50% {
transform: translateX(-50%) translateY(0) scale(1.15);
}
}
/* ============================================================
* 中心提示 (仅未开始时显示)
* ============================================================ */
.center-hint {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
pointer-events: none;
animation: hint-fade-in 0.3s ease;
}
@keyframes hint-fade-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.hint-icon {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 44rpx;
color: #10b981;
background: rgba(255, 255, 255, 0.98);
border-radius: 50%;
box-shadow:
0 8rpx 24rpx rgba(16, 185, 129, 0.3),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.8);
animation: hint-bounce 2s ease-in-out infinite;
}
@keyframes hint-bounce {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}
.hint-text {
font-size: 26rpx;
color: #059669;
font-weight: 600;
letter-spacing: 2rpx;
text-shadow: 0 2rpx 4rpx rgba(255, 255, 255, 0.8);
}
</style>