Files
swimmingUni/src/pages/userFunc/hunyang.vue
2026-04-11 17:47:28 +08:00

693 lines
16 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="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>