Files
swimmingUni/src/pages/userFunc/segmentation.vue
2026-05-26 08:41:00 +08:00

863 lines
20 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="segmentation-container">
<!-- 总计时器区域 -->
<view class="total-time-section" style="position: relative;">
<view class="timer-bar">
<view class="timer-time">{{ formatTime(currentTime) }}</view>
</view>
<view class="config-info">
<view class="info-text">总距离: {{ totalDistance }} ({{ segmentCount }} × {{ segmentDistance }})
{{ startMode==0?'一起出发':'间隔出发' }} {{ startMode==0?'':' · '+intervalTime+'s' }} </view>
</view>
<view class="" style="position: absolute; top: 20rpx; right: 20rpx; ">
<u-icon @click="Service.GoPage('/pages/userFunc/setCourse?id='+planId+'&type=2')" name="setting"
size="24" color="#1890ff"></u-icon>
</view>
</view>
<!-- 学生列表 -->
<view class="students-section" style="margin-top: 20rpx;">
<!-- <view class="section-header">
<text class="section-title">学生列表</text>
<text class="student-count">{{ students.length }}位学生</text>
</view> -->
<view class="students-list">
<view v-for="(student, index) in students" :key="student.id" class="student-item"
:class="{ 'not-started': !student.hasStarted }">
<view class="student-header">
<view class="student-number" @click="showStudentRecords(student)"
:class="{ 'started': student.hasStarted }">{{ student.number }}
</view>
<view class="student-info">
<text class="student-name">{{ student.name }}</text>
<!-- <view class="start-status" v-if="startMode === 1 && !student.hasStarted">
<text class="status-text">等待</text>
</view> -->
<text class="total-time-text"
v-if="student.segments.length > 0">{{ formatSimpleTime(getLastSegmentTime(student)) }}</text>
<view class="record-badge"
:class="{ 'completed': student.segments.length >= Number(segmentCount) }">
<text class="badge-text">{{ student.segments.length }}/{{ segmentCount }}</text>
</view>
</view>
<view class="student-buttons">
<button class="record-btn" :disabled="!student.hasStarted"
@click="recordStudentSegment(student)">
<text class="btn-text">记录</text>
</button>
<view @click="resetStudent(student)" class="">
<u-icon name="reload" :bold="true" size="24" color="#1890ff"></u-icon>
</view>
<!-- <button class="reset-btn" @click="resetStudent(student)">
<text class="btn-text">重置</text>
</button> -->
</view>
</view>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="bottom-actions">
<view class="action-btn reset-action" @click="resetAll">
<u-icon name="reload" size="24" color="#fff"></u-icon>
<text class="action-text">重置</text>
</view>
<view class="action-btn timer-action" :class="{ 'running': isRunning }" @click="toggleTimer">
<u-icon :name="isRunning ? 'pause-circle' : 'play-circle'" size="28" color="#fff"></u-icon>
<text class="action-text">{{ isRunning ? '暂停' : '开始' }}</text>
</view>
<view class="action-btn save-action" @click="saveData">
<u-icon name="checkmark" size="24" color="#fff"></u-icon>
<text class="action-text">保存</text>
</view>
</view>
<!-- 记录弹窗 -->
<u-popup :show="showRecordPopup" mode="bottom" :round="20" @close="closeRecordPopup">
<view class="record-popup">
<view class="popup-header">
<text class="popup-title">{{ currentStudent?.name }} - 分段记录</text>
<view class="close-btn" @click="closeRecordPopup">
<u-icon name="close" size="24" color="#999"></u-icon>
</view>
</view>
<view class="popup-summary">
<view class="summary-item">
<text class="summary-label">总距离</text>
<text class="summary-value">{{ totalDistance }}</text>
</view>
<view class="summary-item">
<text class="summary-label">已记录</text>
<text
class="summary-value">{{ currentStudent?.segments?.length || 0 }}/{{ segmentCount }}</text>
</view>
</view>
<view class="record-list"
v-if="currentStudent && currentStudent.segments && currentStudent.segments.length > 0">
<view class="record-header">
<text class="record-cell header-cell">分段</text>
<text class="record-cell header-cell">距离</text>
<text class="record-cell header-cell">累计时间</text>
<text class="record-cell header-cell">分段用时</text>
</view>
<view v-for="(segment, index) in currentStudent.segments" :key="index" class="record-item">
<text class="record-cell">{{ index + 1 }}</text>
<text class="record-cell">{{ segmentDistance }}</text>
<text class="record-cell time-cell">{{ formatTime(segment.time) }}</text>
<text class="record-cell time-cell">{{ formatTimeDiff(index, segment.time) }}</text>
</view>
</view>
<view class="empty-record" v-else>
<u-icon name="info-circle" size="48" color="#ccc"></u-icon>
<text class="empty-text">暂无记录数据</text>
</view>
<view class="popup-footer">
<button class="popup-btn close-popup" @click="closeRecordPopup">关闭</button>
</view>
</view>
</u-popup>
</view>
</template>
<script setup lang="ts">
import { ref, onUnmounted, computed } from 'vue'
import { Service } from '@/Service/Service'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { PlanService } from '@/Service/swimming/PlanService'
// 分段配置
const segmentDistance = ref(50)
const segmentCount = ref(4)
// 出发模式: 0-一起出发, 1-间隔出发
const startMode = ref(0)
// 间隔时间(秒)
const intervalTime = ref(10)
// 计算总距离
const totalDistance = computed(() => {
return Number(segmentDistance.value) * Number(segmentCount.value)
})
// 学生列表
const students = ref<Array<any>>([])
// 弹窗状态
const showRecordPopup = ref(false)
const currentStudent = ref<any>(null)
// 计时器状态
const isRunning = ref(false)
const currentTime = ref(0)
let timerInterval : number | null = null
let startTime : number = 0
let intervalStartTimer : number | null = null
let planId = ref('')
onLoad((data : any) => {
planId.value = data.id
})
onShow(() => {
getPlanInfo()
})
// 获取计划详情
const getPlanInfo = () => {
PlanService.GetPlanInfo(planId.value).then(res => {
if (res.code == 0) {
// planName.value = res.data.plan.name
segmentDistance.value = res.data.plan.subsectionDistance / res.data.plan.subsectionInt
segmentCount.value = res.data.plan.subsectionInt
// 将计划数据转换为选手数据
// athletes.value = res.data.plan.users.
students.value = res.data.plan.users.map((item : any, index : any) => {
return {
id: item.studentId,
number: index + 1,
name: item.name,
segments: [],
hasStarted: res.data.plan.departType == '间隔出发' ? false : true,
startTime: 0
}
})
startMode.value = res.data.plan.departType == '间隔出发' ? 1 : 0
intervalTime.value = res.data.plan.interval
} else {
Service.Msg(res.msg)
}
})
}
// 格式化时间显示(分:秒.毫秒)
const formatTime = (seconds : number) : string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
const millis = Math.floor((seconds % 1) * 100)
return `${mins.toString().padStart(2, '0')}${secs.toString().padStart(2, '0')}${millis.toString().padStart(2, '0')}`
}
// 格式化持续时间
const formatDuration = (seconds : number) : string => {
const hours = Math.floor(seconds / 3600)
const mins = Math.floor((seconds % 3600) / 60)
let result = ''
if (hours > 0) {
result += `${hours}小时`
}
if (mins > 0) {
result += `${mins}分钟`
}
if (hours === 0 && mins === 0) {
result = '0分钟'
}
return result
}
// 格式化时间差(详情弹窗用)
// 第一段显示累计时间,后续段显示分段用时
const formatTimeDiff = (index : number, currentTime : number) : string => {
if (index === 0) {
// 第一段显示累计时间
return formatTime(currentTime)
}
if (!currentStudent.value || !currentStudent.value.segments[index - 1]) {
return '00:00:00'
}
const prevTime = currentStudent.value.segments[index - 1].time
const diff = currentTime - prevTime
return formatTime(diff)
}
// 获取学生最后一次记录的分段用时
// 第一段返回累计时间,后续段返回当前段用时(当前累计 - 上一次累计)
const getLastSegmentTime = (student : any) : number => {
if (!student.segments || student.segments.length === 0) {
return 0
}
const lastIndex = student.segments.length - 1
if (lastIndex === 0) {
// 第一段,返回累计时间
return student.segments[0].time
}
// 后续段,返回分段用时
return student.segments[lastIndex].time - student.segments[lastIndex - 1].time
}
// 简化时间格式化(分:秒.毫秒)
const formatSimpleTime = (seconds : number) : string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
const millis = Math.floor((seconds % 1) * 100)
return `${mins.toString().padStart(2, '0')}${secs.toString().padStart(2, '0')}${millis.toString().padStart(2, '0')}`
}
// 开始计时
const startTimer = () => {
if (isRunning.value) return
isRunning.value = true
startTime = Date.now() - currentTime.value * 1000
timerInterval = setInterval(() => {
const elapsed = (Date.now() - startTime) / 1000
currentTime.value = elapsed
}, 1)
// 间隔出发模式
if (startMode.value === 1) {
// 第一个学生立即出发
if (students.value.length > 0) {
students.value[0].hasStarted = true
students.value[0].startTime = 0
}
// 设置间隔出发定时器
const interval = intervalTime.value * 1000
let startedCount = 1
intervalStartTimer = setInterval(() => {
if (startedCount < students.value.length) {
const student = students.value[startedCount]
student.hasStarted = true
student.startTime = (Date.now() - startTime) / 1000
startedCount++
Service.Msg(`${student.name} 已出发`)
} else {
clearInterval(intervalStartTimer as number)
intervalStartTimer = null
}
}, interval)
}
}
// 停止计时
const stopTimer = () => {
if (!isRunning.value) return
isRunning.value = false
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
if (intervalStartTimer) {
clearInterval(intervalStartTimer)
intervalStartTimer = null
}
}
// 切换计时器状态
const toggleTimer = () => {
if (isRunning.value) {
stopTimer()
} else {
startTimer()
}
}
// 记录学生分段
const recordStudentSegment = (student : any) => {
if (!isRunning.value) {
Service.Msg('请先开始计时')
return
}
if (!student.hasStarted) {
Service.Msg('该学生尚未出发')
return
}
const maxSeg = Number(segmentCount.value)
if (student.segments.length >= maxSeg) {
Service.Msg(`已达到最大分段数(${maxSeg}段)`)
return
}
let recordTime = currentTime.value
// 间隔出发模式下,使用学生自己的开始时间
if (startMode.value === 1) {
recordTime = currentTime.value - student.startTime
}
student.segments.push({
time: Number(recordTime).toFixed(3)
})
Service.Msg(`${student.name}${student.segments.length}段已记录`)
// 所有学生分段记录完成后自动停止计时
const allCompleted = students.value.every(s => s.segments.length >= maxSeg)
if (allCompleted) {
stopTimer()
Service.Msg('所有学生记录完成')
}
}
// 重置学生
const resetStudent = (student : any) => {
student.segments = []
student.hasStarted = false
student.startTime = 0
Service.Msg(`${student.name} 已重置`)
}
// 重置全部
const resetAll = () => {
stopTimer()
currentTime.value = 0
students.value.forEach(student => {
student.segments = []
student.hasStarted = startMode.value == 1 ? false : true,
student.startTime = 0
})
Service.Msg('已全部重置')
}
// 保存数据
const saveData = () => {
// 检查是否有记录的数据
const hasData = students.value.some(s => s.segments.length > 0)
let isSave = students.value.every(s => s.segments.length >= segmentCount.value)
if (!hasData) {
Service.Msg('暂无数据可保存!')
return
}
let data = [
{
studentId: "",
studentName: "",
data: [{ circle: 0, time: "" }]
}
]
data = []
students.value.map((item : any) => {
let record = item.segments.map((content : any, index : any) => {
return {
circle: (index + 1) * segmentDistance.value,
time: content.time
}
})
data.push({
studentId: item.id,
studentName: item.name,
data: record
})
})
uni.showModal({
title: '提示', // 对话框标题
content: '请确定提交数据!', // 显示的内容
showCancel: true, // 是否显示取消按钮
cancelText: '取消', // 取消按钮的文字
confirmText: '确定', // 确认按钮的文字
success: function (res) {
if (res.confirm) {
PlanService.AddPlanLog(planId.value, '分段项目', '', JSON.stringify(data), '').then(res => {
if (res.code == 0) {
Service.Msg('提交成功!')
} else {
Service.Msg(res.msg)
}
})
} else if (res.cancel) {
console.log('用户点击取消');
}
}
});
}
// 显示学生记录弹窗
const showStudentRecords = (student : any) => {
currentStudent.value = student
showRecordPopup.value = true
}
// 关闭记录弹窗
const closeRecordPopup = () => {
showRecordPopup.value = false
currentStudent.value = null
}
// 页面卸载时清理计时器
onUnmounted(() => {
if (timerInterval) {
clearInterval(timerInterval)
}
if (intervalStartTimer) {
clearInterval(intervalStartTimer)
}
})
</script>
<style lang="scss" scoped>
.segmentation-container {
min-height: 100vh;
background-color: #f6f6f6;
padding: 20rpx 20rpx;
padding-bottom: 180rpx;
}
/* 总计时器区域 */
.total-time-section {
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
.config-info {
margin-top: 10rpx;
background-color: #e6f7ff;
border-radius: 8rpx;
padding: 16rpx 20rpx;
text-align: center;
.info-text {
font-size: 26rpx;
color: #1890ff;
font-weight: 500;
}
}
}
.timer-bar {
display: flex;
align-items: center;
justify-content: center;
.timer-time {
font-size: 80rpx;
font-weight: bold;
color: #ff4d4f;
font-family: 'DIN Alternate', monospace;
}
}
/* 学生列表区域 */
.students-section {
margin-bottom: 20rpx;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.student-count {
font-size: 24rpx;
color: #999;
background-color: #f5f5f5;
padding: 6rpx 16rpx;
border-radius: 16rpx;
}
}
.students-list {
.student-item {
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
&.not-started {
opacity: 0.6;
}
.student-header {
display: flex;
align-items: center;
.student-number {
width: 56rpx;
height: 56rpx;
background-color: #e6f7ff;
color: #1890ff;
font-size: 28rpx;
font-weight: 600;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
&.started {
background-color: #52c41a;
color: #fff;
}
}
.student-info {
flex: 1;
display: flex;
align-items: center;
gap: 12rpx;
.student-name {
width: 120rpx;
font-size: 30rpx;
font-weight: 500;
color: #333;
}
.start-status {
background-color: #faad14;
border-radius: 8rpx;
padding: 4rpx 12rpx;
.status-text {
font-size: 22rpx;
color: #fff;
font-weight: 500;
}
}
.total-time-text {
font-size: 30rpx;
color: #999;
font-family: 'DIN Alternate', monospace;
font-weight: 500;
}
.record-badge {
background-color: #f0f0f0;
border-radius: 12rpx;
padding: 4rpx 12rpx;
.badge-text {
font-size: 22rpx;
color: #999;
font-weight: 500;
}
&.completed {
background-color: #52c41a;
.badge-text {
color: #fff;
}
}
}
}
.student-buttons {
display: flex;
gap: 12rpx;
.record-btn,
.view-btn,
.reset-btn {
height: 56rpx;
padding: 0 35rpx;
border-radius: 8rpx;
border: none;
font-size: 24rpx;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 6rpx;
.btn-text {
font-size: 24rpx;
color: #fff;
}
}
.record-btn {
background-color: #52c41a;
&:disabled {
background-color: #d9d9d9;
opacity: 0.6;
}
}
.view-btn {
background-color: #1890ff;
}
.reset-btn {
background-color: #faad14;
}
}
}
}
}
}
/* 记录弹窗 */
.record-popup {
background-color: #fff;
border-radius: 20rpx 20rpx 0 0;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
.popup-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.close-btn {
padding: 10rpx;
cursor: pointer;
}
}
.popup-summary {
display: flex;
gap: 20rpx;
padding: 20rpx 30rpx;
background-color: #f6f6f6;
.summary-item {
flex: 1;
background-color: #fff;
border-radius: 12rpx;
padding: 20rpx;
text-align: center;
.summary-label {
font-size: 24rpx;
color: #999;
display: block;
margin-bottom: 8rpx;
}
.summary-value {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
}
}
.record-list {
flex: 1;
overflow-y: auto;
padding: 0 30rpx 20rpx;
.record-header {
display: flex;
background-color: #f5f5f5;
border-radius: 8rpx;
margin-bottom: 16rpx;
margin-top: 20rpx;
.record-cell {
flex: 1;
text-align: center;
font-size: 26rpx;
padding: 16rpx 8rpx;
color: #666;
}
.header-cell {
font-weight: 600;
color: #333;
}
}
.record-item {
display: flex;
background-color: #fff;
border-radius: 8rpx;
margin-bottom: 12rpx;
border: 1rpx solid #f0f0f0;
.record-cell {
flex: 1;
text-align: center;
font-size: 26rpx;
padding: 20rpx 8rpx;
color: #333;
&.time-cell {
font-family: 'DIN Alternate', monospace;
font-weight: 600;
color: #1890ff;
}
}
}
}
.empty-record {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 0;
.empty-text {
font-size: 28rpx;
color: #999;
margin-top: 20rpx;
}
}
.popup-footer {
padding: 20rpx 30rpx 40rpx;
border-top: 1rpx solid #f0f0f0;
.popup-btn {
height: 80rpx;
border-radius: 12rpx;
border: none;
font-size: 30rpx;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
}
.close-popup {
background-color: #f5f5f5;
color: #666;
}
}
}
/* 底部操作栏 */
.bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
padding: 20rpx 30rpx 40rpx;
display: flex;
gap: 20rpx;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
z-index: 100;
.action-btn {
flex: 1;
height: 80rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
cursor: pointer;
transition: all 0.2s ease;
.action-text {
font-size: 28rpx;
color: #fff;
font-weight: 500;
}
&:active {
transform: scale(0.98);
}
}
.reset-action {
background-color: #faad14;
}
.timer-action {
background-color: #52c41a;
flex: 1.5;
&.running {
background-color: #ff4d4f;
}
}
.save-action {
background-color: #1890ff;
}
}
</style>