693 lines
16 KiB
Vue
693 lines
16 KiB
Vue
<template>
|
||
<view class="hunyang-container">
|
||
<!--
|
||
混氧训练页面主容器
|
||
包含:头部信息、倒计时装置、底部控制按钮
|
||
-->
|
||
|
||
<!-- 项目信息头部 -->
|
||
<view class="card-section header-section" style="position: relative;">
|
||
<view class="header-content">
|
||
<!-- 训练项目名称 -->
|
||
<view class="project-name">{{ name }}</view>
|
||
<!-- 总训练时长显示 -->
|
||
<view class="total-duration">
|
||
<text class="duration-label">计划总时长:</text>
|
||
<!-- 格式化显示总时长 -->
|
||
<text class="duration-value">{{ formatTotalDuration(totalDuration) }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="" style="position: absolute; top: 20rpx; right: 30rpx; ">
|
||
<up-icon @click="Service.GoPage('/pages/userFunc/addHunyang?id='+planId)" name="setting"
|
||
size="22"></up-icon>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 多个倒计时装置区域 -->
|
||
<view class="card-section timer-section">
|
||
<!-- 区域标题 -->
|
||
<view class="section-title">倒计时装置</view>
|
||
|
||
<view class="multi-timers-wrapper">
|
||
<!--
|
||
使用v-for循环渲染每个训练计划
|
||
:active类用于高亮当前正在执行的计划
|
||
-->
|
||
<view v-for="(plan, index) in plans" :key="plan.id" class="timer-item"
|
||
:class="{ active: currentPlanIndex === index }">
|
||
<view class="timer-horizontal">
|
||
<!--
|
||
训练状态显示区域
|
||
根据不同状态显示:休息中/已完成/计划X
|
||
-->
|
||
<view class="timer-status" :class="{ resting: timerStates[index].isResting }">
|
||
<text v-if="timerStates[index].isResting" class="status-label">休息中</text>
|
||
<text v-else-if="timerStates[index].isCompleted" class="status-label">已完成</text>
|
||
<text v-else class="status-label">计划{{ index + 1 }}</text>
|
||
</view>
|
||
<!--
|
||
倒计时显示区域
|
||
根据状态显示训练时间或休息时间
|
||
-->
|
||
<view class="timer-display" :class="{ resting: timerStates[index].isResting }">
|
||
<text v-if="timerStates[index].isResting"
|
||
class="display-time">{{ formatRestCountdown(timerStates[index].restCountdown) }}</text>
|
||
<text v-else class="display-time">{{ formatCountdown(timerStates[index].countdown) }}</text>
|
||
</view>
|
||
<!--
|
||
完成圈数显示
|
||
显示已完成的圈数/总圈数
|
||
-->
|
||
<view class="timer-laps">
|
||
<text class="laps-text">{{ timerStates[index].completedLaps }}/{{ plan.circle }}圈</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 计划详细信息 -->
|
||
<view class="plan-info-bar">
|
||
<view class="info-item">
|
||
<text class="info-label">目标:</text>
|
||
<text class="info-value">{{ plan.target }}秒</text>
|
||
</view>
|
||
<view class="info-item">
|
||
<text class="info-label">休息:</text>
|
||
<text class="info-value">{{ plan.rest }}秒</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 空状态提示 -->
|
||
<view v-if="plans.length === 0" class="empty-timer">
|
||
<text class="empty-text">暂无训练计划,请添加</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 底部控制按钮区域 -->
|
||
<view class="bottom-controls">
|
||
<view class="controls-inner">
|
||
<!--
|
||
开始按钮 - 仅在未运行时显示
|
||
点击开始所有训练计划
|
||
-->
|
||
<view v-if="!isRunning" class="control-btn start-btn" @click="startAll">
|
||
<u-icon name="play-circle" size="32" color="#fff"></u-icon>
|
||
<text class="btn-text">开始</text>
|
||
</view>
|
||
<!--
|
||
暂停按钮 - 仅在运行时显示
|
||
点击暂停所有训练
|
||
-->
|
||
<view v-else class="control-btn pause-btn" @click="pauseAll">
|
||
<u-icon name="pause-circle" size="32" color="#fff"></u-icon>
|
||
<text class="btn-text">暂停</text>
|
||
</view>
|
||
<!--
|
||
重置按钮
|
||
点击重置所有训练状态
|
||
-->
|
||
<view class="control-btn reset-btn" @click="resetAll">
|
||
<u-icon name="reload" size="32" color="#fff"></u-icon>
|
||
<text class="btn-text">重置</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 保存悬浮按钮 -->
|
||
<view class="save-float-btn" @click="saveData">
|
||
<text class="btn-text">保存</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
// 导入必要的Vue组合式API和项目服务
|
||
import { ref, computed, onUnmounted } from 'vue'
|
||
import { Service } from '@/Service/Service'
|
||
import { PlanService } from '@/Service/swimming/PlanService'
|
||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||
|
||
// 定义训练计划的数据结构接口
|
||
interface PlanItem {
|
||
id : string // 计划唯一标识
|
||
targetTime : number // 目标训练时间(秒)
|
||
restTime : number // 休息时间(秒)
|
||
lapCount : number // 训练圈数
|
||
}
|
||
|
||
// 定义计时器状态的数据结构接口
|
||
interface TimerState {
|
||
isRunning : boolean // 是否正在运行
|
||
isResting : boolean // 是否处于休息状态
|
||
isCompleted : boolean // 是否已完成
|
||
countdown : number // 倒计时(秒)
|
||
restCountdown : number // 休息倒计时(秒)
|
||
completedLaps : number // 已完成圈数
|
||
}
|
||
|
||
// 训练计划列表,使用ref实现响应式
|
||
const plans = ref<Array<any>>([
|
||
])
|
||
|
||
// 每个计划的计时器状态,与plans一一对应
|
||
const timerStates = ref<TimerState[]>([])
|
||
// 当前正在执行的训练计划索引
|
||
const currentPlanIndex = ref(0)
|
||
// 训练是否正在运行的状态
|
||
const isRunning = ref(false)
|
||
// 主计时器间隔ID,用于控制全局计时
|
||
const mainInterval = ref<number | null>(null)
|
||
|
||
// 训练计划ID,从页面参数获取
|
||
let planId = ref('')
|
||
|
||
let name = ref('')
|
||
|
||
// 页面加载时触发,获取训练计划信息
|
||
onLoad((data : any) => {
|
||
planId.value = data.id
|
||
|
||
})
|
||
onShow(() => {
|
||
getPlanInfo()
|
||
})
|
||
|
||
// 获取计划详情
|
||
// 通过PlanService获取指定ID的训练计划,并初始化计时器状态
|
||
const getPlanInfo = () => {
|
||
PlanService.GetPlanInfo(planId.value).then(res => {
|
||
if (res.code == 0) {
|
||
// 解析计划数据并初始化计时器状态
|
||
name.value = res.data.plan.name
|
||
plans.value = JSON.parse(res.data.plan.project)
|
||
initTimerStates()
|
||
} else {
|
||
// 显示错误信息
|
||
Service.Msg(res.msg)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 初始化计时器状态
|
||
// 为每个计划创建对应的计时器状态对象,重置所有状态
|
||
const initTimerStates = () => {
|
||
timerStates.value = plans.value.map(() => ({
|
||
isRunning: false,
|
||
isResting: false,
|
||
isCompleted: false,
|
||
countdown: 0,
|
||
restCountdown: 0,
|
||
completedLaps: 0
|
||
}))
|
||
}
|
||
|
||
// 开始所有训练计划
|
||
// 检查是否有计划,如果没有则提示用户
|
||
// 设置运行状态并启动第一个计划
|
||
const startAll = () => {
|
||
if (plans.value.length === 0) {
|
||
Service.Msg('请先添加训练计划')
|
||
return
|
||
}
|
||
|
||
isRunning.value = true
|
||
|
||
// 启动指定索引的计划
|
||
const startPlan = (index : number) => {
|
||
// 如果索引超出范围,停止所有训练并显示完成消息
|
||
if (index >= plans.value.length) {
|
||
stopAll()
|
||
Service.Msg('所有计划训练完成!', 'success')
|
||
return
|
||
}
|
||
|
||
// 设置当前计划索引并获取对应的状态
|
||
currentPlanIndex.value = index
|
||
const plan = plans.value[index]
|
||
const state = timerStates.value[index]
|
||
|
||
// 设置计划为运行状态,并初始化倒计时
|
||
state.isRunning = true
|
||
if (state.countdown === 0 && !state.isResting) {
|
||
state.countdown = plan.target
|
||
}
|
||
}
|
||
|
||
// 启动当前计划
|
||
startPlan(currentPlanIndex.value)
|
||
|
||
// 设置主计时器,每10毫秒更新一次
|
||
mainInterval.value = setInterval(() => {
|
||
const index = currentPlanIndex.value
|
||
// 如果索引超出范围,停止所有训练
|
||
if (index >= plans.value.length) {
|
||
stopAll()
|
||
return
|
||
}
|
||
|
||
const plan = plans.value[index]
|
||
const state = timerStates.value[index]
|
||
|
||
// 如果处于休息状态,更新休息倒计时
|
||
if (state.isResting) {
|
||
state.restCountdown -= 0.01
|
||
if (state.restCountdown <= 0) {
|
||
// 休息结束,重置休息状态并开始训练
|
||
state.isResting = false
|
||
state.restCountdown = 0
|
||
state.countdown = plan.target
|
||
}
|
||
} else {
|
||
// 训练状态,更新训练倒计时
|
||
state.countdown -= 0.01
|
||
if (state.countdown <= 0) {
|
||
// 训练完成一圈,增加完成圈数
|
||
state.completedLaps += 1
|
||
|
||
// 如果达到总圈数,标记为已完成并启动下一个计划
|
||
if (state.completedLaps >= plan.circle) {
|
||
state.isRunning = false
|
||
state.isCompleted = true
|
||
currentPlanIndex.value += 1
|
||
startPlan(currentPlanIndex.value)
|
||
} else {
|
||
// 否则进入休息状态
|
||
state.isResting = true
|
||
state.restCountdown = plan.rest
|
||
}
|
||
}
|
||
}
|
||
}, 10)
|
||
}
|
||
|
||
// 暂停所有训练
|
||
// 调用stopAll方法暂停训练
|
||
const pauseAll = () => {
|
||
stopAll()
|
||
}
|
||
|
||
// 停止所有训练
|
||
// 重置运行状态,清除主计时器
|
||
const stopAll = () => {
|
||
isRunning.value = false
|
||
// 重置所有计划的运行状态
|
||
timerStates.value.forEach(state => {
|
||
state.isRunning = false
|
||
})
|
||
// 清除主计时器
|
||
if (mainInterval.value) {
|
||
clearInterval(mainInterval.value)
|
||
mainInterval.value = null
|
||
}
|
||
}
|
||
|
||
// 重置所有训练状态
|
||
// 停止训练,重置当前计划索引,重置所有计时器状态
|
||
const resetAll = () => {
|
||
stopAll()
|
||
currentPlanIndex.value = 0
|
||
// 重置所有计时器状态
|
||
timerStates.value.forEach(state => {
|
||
state.isResting = false
|
||
state.isCompleted = false
|
||
state.countdown = 0
|
||
state.restCountdown = 0
|
||
state.completedLaps = 0
|
||
})
|
||
}
|
||
|
||
// 格式化倒计时显示
|
||
// 将秒数格式化为"分:秒"格式
|
||
const formatCountdown = (seconds : number) : string => {
|
||
const totalSeconds = Math.max(0, Math.floor(seconds))
|
||
const minutes = Math.floor(totalSeconds / 60)
|
||
const secs = totalSeconds % 60
|
||
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||
}
|
||
|
||
// 格式化休息倒计时显示
|
||
// 将秒数格式化为"分:秒"格式
|
||
const formatRestCountdown = (seconds : number) : string => {
|
||
const totalSeconds = Math.max(0, Math.floor(seconds))
|
||
const minutes = Math.floor(totalSeconds / 60)
|
||
const secs = totalSeconds % 60
|
||
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||
}
|
||
|
||
// 格式化总时长显示
|
||
// 将秒数格式化为"分:秒"格式
|
||
const formatTotalDuration = (seconds : number) : string => {
|
||
if (seconds === 0) return '0分0秒'
|
||
const minutes = Math.floor(seconds / 60)
|
||
const secs = Math.floor(seconds % 60)
|
||
return `${minutes}分${secs}秒`
|
||
}
|
||
|
||
|
||
|
||
// 计算总训练时长
|
||
// 根据所有计划的训练时间和休息时间计算总时长
|
||
const totalDuration = computed(() => {
|
||
return plans.value.reduce((total, plan) => {
|
||
const planTime = plan.target * plan.circle
|
||
const restTime = plan.rest * (plan.circle - 1)
|
||
return total + planTime + restTime
|
||
}, 0)
|
||
})
|
||
|
||
|
||
|
||
// 保存数据
|
||
const saveData = () => {
|
||
if (plans.value.length === 0) {
|
||
Service.Msg('没有可保存的训练计划')
|
||
return
|
||
}
|
||
|
||
// 构建要保存的数据
|
||
const saveData = {
|
||
name: name.value,
|
||
project: JSON.stringify(plans.value),
|
||
totalDuration: totalDuration.value
|
||
}
|
||
|
||
|
||
}
|
||
|
||
|
||
// 页面卸载时触发
|
||
// 确保停止所有训练,避免内存泄漏
|
||
onUnmounted(() => {
|
||
stopAll()
|
||
})
|
||
|
||
// 格式化总时长显示
|
||
// 将秒数格式化为"小时:分:秒"或"分:秒"或"秒"格式
|
||
|
||
|
||
// 页面卸载时触发
|
||
// 确保停止所有训练,避免内存泄漏
|
||
onUnmounted(() => {
|
||
stopAll()
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.hunyang-container {
|
||
min-height: 100vh;
|
||
background-color: #f8f9fa;
|
||
padding: 20rpx;
|
||
padding-bottom: 240rpx;
|
||
}
|
||
|
||
.card-section {
|
||
background-color: #ffffff;
|
||
border-radius: 12rpx;
|
||
padding: 24rpx;
|
||
margin-bottom: 20rpx;
|
||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
||
}
|
||
|
||
.header-section {
|
||
background: #ffffff;
|
||
color: #333333;
|
||
}
|
||
|
||
.header-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.project-name {
|
||
font-size: 36rpx;
|
||
font-weight: 600;
|
||
letter-spacing: 2rpx;
|
||
}
|
||
|
||
.total-duration {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
background: #f0f0f0;
|
||
padding: 12rpx 20rpx;
|
||
border-radius: 20rpx;
|
||
}
|
||
|
||
.duration-label {
|
||
font-size: 24rpx;
|
||
color: #666666;
|
||
}
|
||
|
||
.duration-value {
|
||
font-size: 28rpx;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 28rpx;
|
||
font-weight: 500;
|
||
color: #333333;
|
||
margin-bottom: 20rpx;
|
||
position: relative;
|
||
padding-left: 16rpx;
|
||
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 4rpx;
|
||
height: 20rpx;
|
||
background: #4a90e2;
|
||
border-radius: 2rpx;
|
||
}
|
||
}
|
||
|
||
.empty-timer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 40rpx 20rpx;
|
||
|
||
.empty-text {
|
||
font-size: 24rpx;
|
||
color: #999999;
|
||
}
|
||
}
|
||
|
||
.timer-section {
|
||
background: #ffffff;
|
||
}
|
||
|
||
.multi-timers-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.timer-item {
|
||
background: #ffffff;
|
||
border-radius: 12rpx;
|
||
padding: 20rpx;
|
||
border: 1rpx solid #e0e0e0;
|
||
transition: all 0.2s ease;
|
||
|
||
&.active {
|
||
border-color: #4a90e2;
|
||
box-shadow: 0 0 0 2rpx rgba(74, 144, 226, 0.1);
|
||
}
|
||
|
||
&:active {
|
||
transform: scale(0.99);
|
||
}
|
||
}
|
||
|
||
.timer-horizontal {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
width: 100%;
|
||
margin-bottom: 12rpx;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.timer-status {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 8rpx 16rpx;
|
||
border-radius: 8rpx;
|
||
background: #e6f0ff;
|
||
border: 1rpx solid #d0e3ff;
|
||
min-width: 100rpx;
|
||
|
||
&.resting {
|
||
background: #fff8e6;
|
||
border-color: #ffe0b2;
|
||
}
|
||
|
||
.status-label {
|
||
font-size: 22rpx;
|
||
font-weight: 500;
|
||
color: #4a90e2;
|
||
}
|
||
|
||
&.resting .status-label {
|
||
color: #ff9800;
|
||
}
|
||
}
|
||
|
||
.timer-display {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
.display-time {
|
||
font-size: 56rpx;
|
||
font-weight: bold;
|
||
font-family: 'Helvetica Neue', monospace;
|
||
color: #4a90e2;
|
||
letter-spacing: 1rpx;
|
||
}
|
||
|
||
&.resting .display-time {
|
||
color: #ff9800;
|
||
animation: countdownFlash 0.5s ease-in-out infinite;
|
||
}
|
||
}
|
||
|
||
.timer-laps {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 8rpx 16rpx;
|
||
background: #f5f5f5;
|
||
border-radius: 8rpx;
|
||
min-width: 100rpx;
|
||
|
||
.laps-text {
|
||
font-size: 22rpx;
|
||
color: #666666;
|
||
font-weight: 400;
|
||
}
|
||
}
|
||
|
||
.bottom-controls {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: #ffffff;
|
||
padding: 16rpx 24rpx 24rpx;
|
||
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||
z-index: 100;
|
||
border-top: 1rpx solid #e0e0e0;
|
||
}
|
||
|
||
.controls-inner {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 20rpx;
|
||
max-width: 750rpx;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.control-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8rpx;
|
||
padding: 20rpx 16rpx;
|
||
border-radius: 8rpx;
|
||
transition: all 0.2s ease;
|
||
flex: 1;
|
||
}
|
||
|
||
.control-btn:active {
|
||
transform: scale(0.98);
|
||
}
|
||
|
||
.btn-text {
|
||
font-size: 22rpx;
|
||
font-weight: 500;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.start-btn {
|
||
background-color: #4caf50;
|
||
}
|
||
|
||
.pause-btn {
|
||
background-color: #ff9800;
|
||
}
|
||
|
||
.reset-btn {
|
||
background-color: #2196f3;
|
||
}
|
||
|
||
.plan-info-bar {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 30rpx;
|
||
padding-top: 10rpx;
|
||
border-top: 1rpx dashed #e0e0e0;
|
||
|
||
.info-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6rpx;
|
||
}
|
||
|
||
.info-label {
|
||
font-size: 20rpx;
|
||
color: #666666;
|
||
}
|
||
|
||
.info-value {
|
||
font-size: 20rpx;
|
||
color: #333333;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
|
||
@keyframes countdownFlash {
|
||
|
||
0%,
|
||
100% {
|
||
opacity: 1;
|
||
}
|
||
|
||
50% {
|
||
opacity: 0.7;
|
||
}
|
||
}
|
||
|
||
/* 保存悬浮按钮样式 */
|
||
.save-float-btn {
|
||
position: fixed;
|
||
bottom: 240rpx;
|
||
right: 30rpx;
|
||
background-color: #2196f3;
|
||
padding: 40rpx;
|
||
border-radius: 50%;
|
||
box-shadow: 0 4rpx 12rpx rgba(33, 150, 243, 0.3);
|
||
z-index: 101;
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.save-float-btn:active {
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
.save-float-btn .btn-text {
|
||
font-size: 20rpx;
|
||
}
|
||
</style> |