保存数据

This commit is contained in:
Ls
2026-04-11 17:47:28 +08:00
parent f682c0316b
commit c9aeba0949
35 changed files with 5658 additions and 3544 deletions

View File

@@ -0,0 +1,693 @@
<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>