This commit is contained in:
Ls
2026-04-23 10:07:44 +08:00
parent 7dba9711a9
commit 11486220aa
16 changed files with 2908 additions and 2836 deletions

View File

@@ -1,11 +1,12 @@
export class BaseConfig {
protected static servesUrl: string = "http://192.168.0.142:5298";
protected static imgUrl: string = "http://192.168.0.142:5298";
protected static mediaUrl: string = "http://192.168.0.142:5298/";
// protected static servesUrl: string = "http://192.168.0.142:5298";
// protected static imgUrl: string = "http://192.168.0.142:5298";
// protected static mediaUrl: string = "http://192.168.0.142:5298/";
// protected static servesUrl: string = "http://vp.xypays.cn";
// protected static imgUrl: string = "http://vp.cloud.xypays.cn";
// protected static mediaUrl: string = "http://byc1.xypays.cn/";
protected static servesUrl: string = "https://swimming.api.xypays.cn";
protected static imgUrl: string = "https://swimming.api.xypays.cn";
protected static mediaUrl: string = "https://swimming.api.xypays.cn/";
protected static uploadUrl: string = "/TencentCos/GetUpLoadInfo";
protected static payuploadUrl: string = "http://192.168.0.142:5298";
protected static payuploadUrl: string = "https://swimming.api.xypays.cn";
}

View File

@@ -1,75 +1,65 @@
<template>
<view class="curve-container">
<!-- 页面标题区域 -->
<view class="header-section">
<view class="header-title">
<text class="title">曲线走势图</text>
<text class="subtitle">学员成绩趋势变化分析</text>
<!-- 项目选择卡片 -->
<view class="filter-card ">
<view class="card-title">
<u-icon name="grid" size="18" color="#1890ff"></u-icon>
<text>选择项目</text>
</view>
<view class="picker-row" @click="showProject = true">
<text class="picker-text" :class="{ placeholder: !selectProcet }">{{ selectProcet || '请选择项目' }}</text>
<u-icon name="arrow-right" size="16" color="#bbb"></u-icon>
</view>
</view>
<up-picker v-model:show="showProject" keyName="name" valueName="planId" @confirm="selectProcetFunc" :columns="projectOptions"></up-picker>
<!-- 日期选择卡片 -->
<view class="filter-card " v-if="selectProcet">
<view class="card-title">
<u-icon name="calendar" size="18" color="#13c2c2"></u-icon>
<text>日期范围</text>
</view>
<view class="date-filter" @click="openCalendar">
<view class="date-item">
<text class="date-label">开始日期</text>
<text class="date-value" :class="{ placeholder: !begin }">{{ begin || '请选择' }}</text>
</view>
<view class="date-divider"></view>
<view class="date-item">
<text class="date-label">结束日期</text>
<text class="date-value" :class="{ placeholder: !end }">{{ end || '请选择' }}</text>
</view>
<u-icon name="arrow-right" size="16" color="#bbb" class="date-arrow"></u-icon>
</view>
</view>
<!-- 项目选择区域 -->
<view class="select-section">
<view class="select-label">
<view class="label-text" style="margin-bottom: 20rpx;">选择项目</view>
<view class="picker-wrapper" @click="showProject=true">
<text class="picker-text">{{ selectProcet || '请选择项目' }}</text>
<u-icon name="arrow-down" size="18" color="#999"></u-icon>
</view>
</view>
</view>
<up-picker v-model:show="showProject" keyName="name" valueName="planId" @confirm="selectProcetFunc"
:columns="projectOptions"></up-picker>
<!-- 日期选择 -->
<view class="date-filter" style="margin: 20rpx 20rpx 0;" v-if="selectProcet">
<view class="date-picker" @click="openCalendar()">
<up-icon name="calendar" color="#3B82F6" size="25"></up-icon>
<view class="date-text">
<text class="label">开始日期</text>
<text class="value">{{ begin || '请选择' }}</text>
</view>
</view>
<view class="date-picker" @click="openCalendar()">
<up-icon name="calendar" color="#3B82F6" size="25"></up-icon>
<view class="date-text">
<text class="label">结束日期</text>
<text class="value">{{ end || '请选择' }}</text>
</view>
</view>
</view>
<!-- ==================== 学生选择区域 ==================== -->
<!-- 仅在选择项目和时间后显示 -->
<view class="student-select-section" v-if="selectProcet && begin && projectStudents.length > 0">
<!-- 学生选择卡片 -->
<view class="filter-card " v-if="selectProcet && begin && projectStudents.length > 0">
<view class="section-header">
<text class="section-title">选择学员最多3人</text>
<text class="select-count">已选{{ selectedStudentIds.length }}/3</text>
<view class="card-title">
<u-icon name="account" size="18" color="#52c41a"></u-icon>
<text>选择学员</text>
</view>
<text class="select-count">已选 {{ selectedStudentIds.length }}/3</text>
</view>
<!-- 学生列表 -->
<view class="student-list">
<view v-for="student in projectStudents" :key="student.studentId"
:class="['student-item', { 'selected': selectedStudentIds.includes(student.studentId) }]"
:class="['student-item', { selected: selectedStudentIds.includes(student.studentId) }]"
@click="toggleStudent(student.studentId)">
<!-- 学生头像使用首字母作为头像 -->
<view class="student-avatar">
<text class="avatar-text">{{ student.name.charAt(0) }}</text>
</view>
<!-- 学生姓名 -->
<text class="student-name">{{ student.name }}</text>
<!-- 选择状态图标 -->
<view class="check-icon" v-if="selectedStudentIds.includes(student.studentId)">
<u-icon name="checkmark" size="16" color="#fff"></u-icon>
<u-icon name="checkmark" size="14" color="#fff"></u-icon>
</view>
</view>
</view>
</view>
<!-- ==================== 图表展示区域 ==================== -->
<!-- 仅在选择了至少一名学生后显示 -->
<!-- 图表区域 -->
<view class="chart-section" v-if="selectedStudentIds.length > 0">
<!-- 数据统计卡片 -->
<!-- 统计概览 -->
<view class="stats-card">
<view class="stat-item">
<text class="stat-label">选中项目</text>
@@ -78,112 +68,77 @@
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">对比学员</text>
<text class="stat-value">{{ selectedStudentIds.length }}</text>
<text class="stat-value">{{ selectedStudentIds.length }} </text>
</view>
</view>
<!-- 折线图卡片 -->
<!-- 折线图 -->
<view class="chart-card">
<view class="chart-header">
<text class="chart-title">成绩趋势对比</text>
<text class="chart-desc">历史训练成绩变化曲线</text>
</view>
<!-- 折线图容器 -->
<scroll-view class="chart-scroll" scroll-x :show-scrollbar="false">
<view class="chart-box" :style="{ width: chartWidth + 'rpx' }">
<qiun-data-charts type="line" :opts="lineOpts" :chartData="lineChartData" :ontouch="true" />
</view>
</scroll-view>
</view>
</view>
<!-- 日历弹窗 -->
<up-calendar :show="showCalendar" mode="date" minDate='1776240407000' @confirm="calendarConfirm"
@close="calendarClose">
</up-calendar>
<up-calendar :show="showCalendar" mode="date" minDate="1776240407000" @confirm="calendarConfirm" @close="calendarClose"></up-calendar>
</view>
</template>
<script setup lang="ts">
import { onShow, onLoad } from "@dcloudio/uni-app"
import { Service } from '@/Service/Service'
import { ref, computed, watch } from 'vue'
import { ref, computed } from 'vue'
import { PlanService } from '@/Service/swimming/PlanService'
// ==================== 常量定义 ====================
// 最大选择学生数量限制
const MAX_SELECTED_STUDENTS = 3
// 图表颜色配置最多支持3种颜色
const chartColors = ['#52c41a', '#1890ff', '#faad14']
// ==================== 响应式数据 - 项目相关 ====================
// 项目选择
let showProject = ref(false)
let selectProcet = ref('')
let selectId = ref('')
// 日期选择
const begin = ref<string>('')
const end = ref<string>('')
const showCalendar = ref(false)
const projectOptions = ref<Array<any>>([[]])
// ==================== TypeScript 接口定义 ====================
/**
* 学生接口
* 定义学生的基本信息
*/
interface Student {
studentId : string
name : string
studentId: string
name: string
}
/**
* 学生训练记录接口
* 定义学生某次训练的详细记录
*/
interface StudentTrainingRecord {
studentId : string
studentName : string
time : number
recordDate : string
recordFullDate : string
round : number
studentId: string
studentName: string
time: number
recordDate: string
recordFullDate: string
round: number
}
// ==================== 响应式数据 - 学生相关 ====================
// 当前项目的学生列表
const projectStudents = ref<Student[]>([])
// 已选中的学生 ID 列表
const selectedStudentIds = ref<string[]>([])
// 已选中的学生详细信息列表((计算属性)
const selectedStudents = computed(() => {
return projectStudents.value.filter(student =>
selectedStudentIds.value.includes(student.studentId)
)
})
// ==================== 响应式数据 - 图表相关 ====================
// 计算图表宽度(根据数据点数量动态调整)
const chartWidth = computed(() => {
const categoryCount = lineChartData.value.categories.length
if (categoryCount <= 6) return 700
// 每个数据点分配 100rpx最小 700rpx
return Math.max(700, categoryCount * 100)
})
// 折线图配置选项
const lineOpts = ref({
color: chartColors,
padding: [15, 10, 0, 15],
@@ -201,7 +156,7 @@
yAxis: {
data: [{
min: 0,
format: (val : number) => val.toFixed(1) + 's'
format: (val: number) => val.toFixed(1) + 's'
}]
},
extra: {
@@ -215,25 +170,16 @@
}
})
// 折线图数据
const lineChartData = ref({
categories: [] as string[],
series: [] as any[]
})
onLoad(() => {
getProjectData()
})
onShow(() => {
})
onShow(() => {})
const getProjectData = () => {
PlanService.GetPlanListNoPage('计时项目').then(res => {
@@ -245,10 +191,15 @@
})
}
const selectProcetFunc = (e : any) => {
const selectProcetFunc = (e: any) => {
selectProcet.value = e.value[0].name
selectId.value = e.value[0].planId
selectedStudentIds.value = []
begin.value = ''
end.value = ''
projectStudents.value = []
lineChartData.value.categories = []
lineChartData.value.series = []
loadProjectStudents(selectId.value)
}
@@ -256,7 +207,7 @@
showCalendar.value = true
}
const calendarConfirm = (e : any) => {
const calendarConfirm = (e: any) => {
begin.value = e[0]
end.value = e[e.length - 1]
showCalendar.value = false
@@ -266,7 +217,7 @@
showCalendar.value = false
}
const loadProjectStudents = (projectId : string) => {
const loadProjectStudents = (projectId: string) => {
PlanService.GetPlanInfo(projectId).then(res => {
if (res.code == 0) {
projectStudents.value = res.data.plan.users
@@ -276,7 +227,7 @@
})
}
const toggleStudent = (studentId : string) => {
const toggleStudent = (studentId: string) => {
const index = selectedStudentIds.value.indexOf(studentId)
if (index !== -1) {
selectedStudentIds.value.splice(index, 1)
@@ -287,9 +238,7 @@
}
selectedStudentIds.value.push(studentId)
}
let studentIdList = selectedStudentIds.value.map((item) => {
return item
})
let studentIdList = selectedStudentIds.value.map((item) => item)
let data = {
planId: selectId.value,
studentId: JSON.stringify(studentIdList),
@@ -297,11 +246,10 @@
eTime: end.value
}
interface DataItem {
name : string
data : any[]
name: string
data: any[]
}
const xData = ref<DataItem[]>(
Array.from({ length: studentIdList.length }, () => ({
name: '',
@@ -310,344 +258,295 @@
)
PlanService.GetQuxianLog(data).then(res => {
if (res.code == 0) {
res.data.list.map((item : any) => {
lineChartData.value.categories = []
res.data.list.map((item: any) => {
lineChartData.value.categories.push(item.dayTime)
item.data.map((content : any, index : any) => {
item.data.map((content: any, index: any) => {
xData.value[index].name = content.studentName
xData.value[index].data.push(content.time)
})
})
console.log(xData.value);
lineChartData.value.series = xData.value
} else {
Service.Msg(res.msg)
}
})
}
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
background-color: #e8ecf3;
}
.curve-container {
min-height: 100vh;
padding: 20rpx;
padding-bottom: 40rpx;
}
.header-section {
background-color: #fff;
padding: 32rpx 28rpx 24rpx;
/* 通用卡片 */
.filter-card {
background: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
// border-left: 8rpx solid #999;
.header-title {
.title {
font-size: 36rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 8rpx;
}
}
.subtitle {
font-size: 24rpx;
.card-title {
display: flex;
align-items: center;
gap: 10rpx;
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
/* 项目选择 */
.picker-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22rpx 24rpx;
background: #f5f7fa;
border-radius: 12rpx;
border: 1rpx solid #e4e8ee;
transition: all 0.2s;
&:active {
background: #eef1f6;
}
.picker-text {
font-size: 28rpx;
color: #333;
font-weight: 500;
&.placeholder {
color: #999;
font-weight: 400;
}
}
}
/* 日期选择 */
.date-filter {
display: flex;
align-items: center;
background: #f0fafa;
border-radius: 12rpx;
padding: 22rpx 24rpx;
border: 1rpx solid #d6f0f0;
transition: all 0.2s;
&:active {
background: #e6f5f5;
}
}
.date-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
}
.date-label {
font-size: 24rpx;
color: #666;
font-weight: 500;
}
.date-value {
font-size: 30rpx;
color: #333;
font-weight: 600;
&.placeholder {
color: #999;
font-weight: 400;
}
}
.date-divider {
width: 2rpx;
height: 50rpx;
background: #b2dfdb;
margin: 0 20rpx;
}
.date-arrow {
margin-left: 12rpx;
}
/* 学生选择区域 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.select-count {
font-size: 24rpx;
color: #52c41a;
font-weight: 600;
background: #f0f9eb;
padding: 6rpx 16rpx;
border-radius: 20rpx;
}
}
.date-filter {
.student-list {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
}
.student-item {
display: flex;
gap: 24rpx;
.date-picker {
flex: 1;
display: flex;
align-items: center;
gap: 16rpx;
background-color: #fff;
border-radius: 16rpx;
padding: 20rpx;
.date-text {
.label {
display: block;
font-size: 26rpx;
color: #999;
}
.value {
display: block;
font-size: 30rpx;
color: #333;
margin-top: 4rpx;
font-weight: 600;
}
}
}
}
.select-section {
background-color: #fff;
margin: 0 20rpx 20rpx;
border-radius: 20rpx;
padding: 28rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
flex-direction: column;
align-items: center;
padding: 20rpx 10rpx;
background: #f5f7fa;
border-radius: 12rpx;
border: 2rpx solid transparent;
transition: all 0.2s;
position: relative;
overflow: hidden;
.select-label {
margin-bottom: 16rpx;
.label-text {
font-size: 26rpx;
font-weight: 600;
color: #666;
}
&:active {
transform: scale(0.96);
}
.picker-wrapper {
&.selected {
background: #f0f9eb;
border-color: #52c41a;
}
.student-avatar {
width: 60rpx;
height: 60rpx;
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
background-color: #f8f8f8;
border-radius: 12rpx;
border: 1rpx solid #e8e8e8;
transition: all 0.3s ease;
justify-content: center;
margin-bottom: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(82, 196, 26, 0.3);
&:active {
background-color: #f0f0f0;
border-color: #faad14;
transform: scale(0.98);
.avatar-text {
font-size: 24rpx;
font-weight: 700;
color: #fff;
}
}
.picker-text {
font-size: 28rpx;
color: #333;
}
.student-name {
font-size: 24rpx;
color: #333;
text-align: center;
font-weight: 500;
}
.check-icon {
position: absolute;
top: 6rpx;
right: 6rpx;
width: 28rpx;
height: 28rpx;
background: #52c41a;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
}
.student-select-section {
background-color: #fff;
margin: 0 20rpx 20rpx;
border-radius: 16rpx;
padding: 28rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.section-header {
margin-bottom: 24rpx;
}
.student-list {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
.student-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 10rpx;
background-color: #f8f8f8;
border-radius: 12rpx;
border: 2rpx solid transparent;
transition: all 0.3s ease;
position: relative;
&:active {
transform: scale(0.95);
}
&.selected {
background-color: #f0f9eb;
border-color: #52c41a;
}
.student-avatar {
width: 60rpx;
height: 60rpx;
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(82, 196, 26, 0.3);
.avatar-text {
font-size: 24rpx;
font-weight: 700;
color: #fff;
}
}
.student-name {
font-size: 24rpx;
color: #333;
text-align: center;
}
.check-icon {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 28rpx;
height: 28rpx;
background-color: #52c41a;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
/* 图表区域 */
.chart-section {
margin: 0 20rpx;
margin-top: 4rpx;
}
.stats-card {
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
justify-content: space-around;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.stats-card {
background: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
justify-content: space-around;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
// border-left: 8rpx solid #faad14;
.stat-item {
text-align: center;
.stat-item {
text-align: center;
.stat-label {
font-size: 24rpx;
color: #999;
display: block;
margin-bottom: 8rpx;
}
.stat-value {
font-size: 32rpx;
font-weight: 700;
color: #52c41a;
}
.stat-label {
font-size: 24rpx;
color: #666;
display: block;
margin-bottom: 8rpx;
font-weight: 500;
}
.stat-divider {
width: 1rpx;
height: 50rpx;
background-color: #eee;
.stat-value {
font-size: 32rpx;
font-weight: 700;
color: #faad14;
}
}
.chart-card {
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.chart-header {
margin-bottom: 20rpx;
padding-left: 12rpx;
border-left: 6rpx solid #52c41a;
.chart-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 6rpx;
}
.chart-desc {
font-size: 24rpx;
color: #999;
}
}
.chart-scroll {
width: 100%;
height: 500rpx;
background-color: #fafafa;
border-radius: 12rpx;
overflow: hidden;
white-space: nowrap;
.chart-box {
height: 100%;
display: inline-block;
}
}
.stat-divider {
width: 1rpx;
height: 50rpx;
background-color: #f0f0f0;
}
}
.legend-section {
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.chart-card {
background: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
// border-left: 8rpx solid #52c41a;
.legend-title {
font-size: 28rpx;
.chart-header {
margin-bottom: 20rpx;
padding-left: 12rpx;
border-left: 6rpx solid #52c41a;
.chart-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
display: block;
margin-bottom: 6rpx;
}
.legend-list {
display: flex;
flex-direction: column;
gap: 16rpx;
.chart-desc {
font-size: 24rpx;
color: #999;
}
}
.legend-item {
display: flex;
align-items: center;
gap: 12rpx;
.chart-scroll {
width: 100%;
height: 500rpx;
background-color: #fafafa;
border-radius: 12rpx;
overflow: hidden;
white-space: nowrap;
border: 1rpx solid #f0f0f0;
.legend-color {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
flex-shrink: 0;
}
.legend-name {
font-size: 26rpx;
color: #333;
font-weight: 600;
}
.legend-desc {
font-size: 24rpx;
color: #999;
}
}
.chart-box {
height: 100%;
display: inline-block;
}
}
}
</style>
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,21 @@
<template>
<view class="grades-container">
<!-- 页面标题区域 -->
<view class="header-section">
<view class="header-title">
<text class="title">成绩排名</text>
<text class="subtitle">学生最佳成绩排名</text>
<!-- 项目选择卡片 -->
<view class="filter-card ">
<view class="card-title">
<u-icon name="grid" size="18" color="#1890ff"></u-icon>
<text>选择项目</text>
</view>
<view class="picker-row" @click="showProject = true">
<text class="picker-text" :class="{ placeholder: !selectProcet }">{{ selectProcet || '请选择项目' }}</text>
<u-icon name="arrow-right" size="16" color="#bbb"></u-icon>
</view>
</view>
<up-picker v-model:show="showProject" keyName="name" valueName="planId" @confirm="selectProcetFunc" :columns="projectOptions"></up-picker>
<!-- 项目选择区域 -->
<view class="select-section">
<view class="select-label">
<view class="label-text" style="margin-bottom: 20rpx;">选择项目</view>
<view class="picker-wrapper" @click="showProject=true">
<text class="picker-text">{{ selectProcet || '请选择项目' }}</text>
<u-icon name="arrow-down" size="18" color="#999"></u-icon>
</view>
</view>
</view>
<up-picker v-model:show="showProject" keyName="name" valueName="planId" @confirm="selectProcetFunc"
:columns="projectOptions"></up-picker>
<!-- ==================== 排名列表区域 ==================== -->
<!-- 仅在选择了项目且有数据时显示 -->
<!-- 排名列表区域 -->
<view class="ranking-section" v-if="selectProcet && gradeList.length > 0">
<!-- 排名统计卡片 -->
<!-- 统计卡片 -->
<view class="stats-card">
<view class="stat-item">
<text class="stat-label">对比项目</text>
@@ -34,7 +24,7 @@
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">参与人数</text>
<text class="stat-value">{{ gradeList.length }}</text>
<text class="stat-value">{{ gradeList.length }} </text>
</view>
</view>
@@ -42,24 +32,27 @@
<view class="ranking-list">
<view v-for="(item, index) in gradeList" :key="item.id"
:class="['ranking-item', { 'top-three': index < 3 }]" @click="handleRankingClick(item)">
<!-- 排名徽章 -->
<view class="rank-badge" :class="`rank-${index + 1}`">
<text class="rank-number">{{ index + 1 }}</text>
</view>
<!-- 学生信息 -->
<view class="student-info">
<text class="student-name">{{ item.studentName }}</text>
<text class="student-speed">最快用时{{ item.quicklyTime }}s</text>
</view>
<!-- 成绩详情 -->
<view class="speed-detail">
<text class="detail-text">日期{{ Service.formatDate(item.addTime,2)}}</text>
<text class="detail-text">{{ Service.formatDate(item.addTime,2) }}</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty-card" v-if="selectProcet && gradeList.length === 0">
<u-icon name="file-text" size="64" color="#d9d9d9"></u-icon>
<text class="empty-text">暂无排名数据</text>
</view>
</view>
</template>
@@ -69,8 +62,6 @@
import { ref, computed } from 'vue'
import { PlanService } from '@/Service/swimming/PlanService'
// ==================== 响应式数据 - 项目相关 ====================
// 项目选择
let showProject = ref(false)
let selectProcet = ref('')
@@ -78,49 +69,26 @@
const projectOptions = ref<Array<any>>([[]])
// ==================== TypeScript 接口定义 ====================
/**
* 学生成绩接口
* 定义学生的成绩和排名信息
*/
interface StudentGrade {
id : string // 学生 ID
name : string // 学生姓名
bestSpeed : number // 最快速度m/s
bestTime : number // 最快用时(秒)
recordDate : string // 记录日期
studentId : string // 学生 ID用于数据关联
id: string
name: string
bestSpeed: number
bestTime: number
recordDate: string
studentId: string
}
// ==================== 响应式数据 - 排名相关 ====================
// 排名列表数据
const gradeList = ref<StudentGrade[]>([])
// 最高速度(计算属性)
const maxSpeed = computed(() => {
if (gradeList.value.length === 0) return 0
return Math.max(...gradeList.value.map(item => item.bestSpeed)).toFixed(2)
})
// ==================== 生命周期钩子 ====================
/**
* 页面加载时触发
* 在页面初始化时执行,只执行一次
*/
onLoad(() => {
// 初始化数据
getProjectData()
})
// ==================== 业务逻辑方法 - 项目相关 ====================
const getProjectData = () => {
PlanService.GetPlanListNoPage('计时项目').then(res => {
if (res.code == 0) {
@@ -131,314 +99,244 @@
})
}
const selectProcetFunc = (e : any) => {
const selectProcetFunc = (e: any) => {
selectProcet.value = e.value[0].name
selectId.value = e.value[0].planId
loadProjectGrades(selectId.value)
}
/**
* 加载项目排名数据
* 根据项目 ID 获取该项目的学生成绩排名列表
* @param projectId 项目 ID
*/
const loadProjectGrades = (projectId : string) => {
const loadProjectGrades = (projectId: string) => {
PlanService.GetRankData(projectId).then(res => {
if (res.code == 0) {
gradeList.value=res.data.list
gradeList.value = res.data.list
} else {
Service.Msg(res.msg)
}
})
}
// ==================== 业务逻辑方法 - 排名相关 ====================
/**
* 处理排名项点击事件
* 点击某排名项查看详情
* @param item 排名项数据
*/
const handleRankingClick = (item : StudentGrade) => {
const handleRankingClick = (item: StudentGrade) => {
console.log('点击了排名项:', item)
// TODO: 跳转到学生详情页面
// Service.GoPage('/pages/student/detail', { studentId: item.studentId })
}
</script>
<style lang="scss" scoped>
// 页面背景色设置
page {
background-color: #f5f5f5;
background-color: #e8ecf3;
}
// 页面容器
.grades-container {
min-height: 100vh;
padding: 20rpx;
padding-bottom: 40rpx;
}
/* ==================== 页面标题区域 ==================== */
.header-section {
background-color: #fff;
padding: 32rpx 28rpx 24rpx;
/* 通用卡片 */
.filter-card {
background: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
}
.header-title {
.title {
font-size: 36rpx;
font-weight: 700;
color: #333;
.card-title {
display: flex;
align-items: center;
gap: 10rpx;
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
.picker-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22rpx 24rpx;
background: #f5f7fa;
border-radius: 12rpx;
border: 1rpx solid #e4e8ee;
transition: all 0.2s;
&:active {
background: #eef1f6;
}
.picker-text {
font-size: 28rpx;
color: #333;
font-weight: 500;
&.placeholder {
color: #999;
font-weight: 400;
}
}
}
/* 排名区域 */
.ranking-section {
margin-top: 4rpx;
}
.stats-card {
background: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
justify-content: space-around;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
.stat-item {
text-align: center;
.stat-label {
font-size: 24rpx;
color: #666;
display: block;
margin-bottom: 8rpx;
font-weight: 500;
}
.subtitle {
font-size: 24rpx;
color: #999;
.stat-value {
font-size: 32rpx;
font-weight: 700;
color: #faad14;
}
}
.stat-divider {
width: 1rpx;
height: 50rpx;
background-color: #f0f0f0;
}
}
/* ==================== 项目选择区域 ==================== */
.select-section {
background-color: #fff;
margin: 0 20rpx 20rpx;
border-radius: 20rpx;
padding: 28rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
.select-label {
margin-bottom: 16rpx;
.label-text {
font-size: 26rpx;
font-weight: 600;
color: #666;
}
}
.picker-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
background-color: #f8f8f8;
border-radius: 12rpx;
border: 1rpx solid #e8e8e8;
transition: all 0.3s ease;
&:active {
background-color: #f0f0f0;
border-color: #faad14;
transform: scale(0.98);
}
.picker-text {
font-size: 28rpx;
color: #333;
}
}
}
/* ==================== 排名列表区域 ==================== */
.ranking-section {
margin: 0 20rpx;
// 统计卡片
.stats-card {
.ranking-list {
.ranking-item {
background-color: #fff;
border-radius: 20rpx;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
margin-bottom: 16rpx;
display: flex;
align-items: center;
justify-content: space-around;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
gap: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
transition: all 0.2s;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
// background: linear-gradient(180deg, #1890ff 0%, #096dd9 100%);
border-radius: 20rpx 0 0 20rpx;
&:active {
transform: scale(0.99);
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
}
.stat-item {
text-align: center;
.stat-label {
font-size: 24rpx;
color: #999;
display: block;
margin-bottom: 8rpx;
}
.stat-value {
font-size: 32rpx;
font-weight: 700;
color: #1890ff;
}
&.top-three {
border-left-color: #ffd700;
}
.stat-divider {
width: 1rpx;
height: 50rpx;
background-color: #eee;
}
}
// 排名列表容器
.ranking-list {
// 排名项
.ranking-item {
background-color: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 16rpx;
.rank-badge {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
display: flex;
align-items: center;
gap: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
justify-content: center;
flex-shrink: 0;
background-color: #f0f0f0;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
// background: linear-gradient(180deg, #1890ff 0%, #096dd9 100%);
opacity: 0;
transition: opacity 0.3s ease;
border-radius: 20rpx 0 0 20rpx;
.rank-number {
font-size: 24rpx;
font-weight: 700;
color: #999;
}
&:active {
transform: scale(0.98);
box-shadow: 0 4rpx 16rpx rgba(235, 47, 150, 0.15);
&::before {
opacity: 1;
}
}
// 排名徽章
.rank-badge {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background-color: #f0f0f0;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
&.rank-1 {
background: linear-gradient(135deg, #ffd700 0%, #ffec3d 100%);
box-shadow: 0 2rpx 8rpx rgba(255, 215, 0, 0.4);
.rank-number {
font-size: 24rpx;
font-weight: 700;
color: #999;
}
// 前三名特殊颜色
&.rank-1 {
background: linear-gradient(135deg, #ffd700 0%, #ffec3d 100%);
box-shadow: 0 2rpx 8.6rpx rgba(255, 215, 0, 0.4);
.rank-number {
color: #fff;
font-size: 28rpx;
}
}
&.rank-2 {
background: linear-gradient(135deg, #c0c0c0 0%, #d9d9d9 100%);
box-shadow: 0 2rpx 8rpx rgba(192, 192, 192, 0.4);
.rank-number {
color: #fff;
}
}
&.rank-3 {
background: linear-gradient(135deg, #cd7f32 0%, #e6963d 100%);
box-shadow: 0 2rpx 8rpx rgba(205, 127, 50, 0.4);
.rank-number {
color: #fff;
}
color: #fff;
font-size: 28rpx;
}
}
// 学生头像
.student-avatar {
width: 70rpx;
height: 70rpx;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2rpx 8rpx rgba(235, 47, 150, 0.3);
&.rank-2 {
background: linear-gradient(135deg, #c0c0c0 0%, #d9d9d9 100%);
box-shadow: 0 2rpx 8rpx rgba(192, 192, 192, 0.4);
.avatar-text {
font-size: 28rpx;
font-weight: 700;
.rank-number {
color: #fff;
}
}
// 学生信息
.student-info {
flex: 1;
min-width: 0;
&.rank-3 {
background: linear-gradient(135deg, #cd7f32 0%, #e6963d 100%);
box-shadow: 0 2rpx 8rpx rgba(205, 127, 50, 0.4);
.student-name {
font-size: 32rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.student-speed {
font-size: 26rpx;
color: #1890ff;
font-weight: 500;
.rank-number {
color: #fff;
}
}
}
// 成绩详情
.speed-detail {
display: flex;
align-items: center;
gap: 16rpx;
flex-shrink: 0;
.student-info {
flex: 1;
min-width: 0;
.detail-text {
font-size: 24rpx;
color: #999;
background-color: #f5f5f5;
padding: 6rpx 12rpx;
border-radius: 6rpx;
}
.student-name {
font-size: 32rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.student-speed {
font-size: 26rpx;
color: #1890ff;
font-weight: 500;
}
}
.speed-detail {
display: flex;
align-items: center;
gap: 16rpx;
flex-shrink: 0;
.detail-text {
font-size: 24rpx;
color: #999;
background-color: #f5f7fa;
padding: 6rpx 12rpx;
border-radius: 6rpx;
}
}
}
}
</style>
/* 空状态 */
.empty-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
background: #ffffff;
border-radius: 16rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
margin-top: 4rpx;
.empty-text {
margin-top: 24rpx;
font-size: 28rpx;
color: #999;
}
}
</style>

View File

@@ -1,54 +1,70 @@
<template>
<view class="paragraph-container">
<view class="header-section">
<view class="header-title">
<text class="title">分段数据</text>
<text class="subtitle">学生分段训练成绩分析</text>
<!-- 项目选择卡片 -->
<view class="filter-card ">
<view class="card-title">
<u-icon name="grid" size="18" color="#1890ff"></u-icon>
<text>选择项目</text>
</view>
<view class="picker-row" @click="showProject = true">
<text class="picker-text" :class="{ placeholder: !selectProcet }">{{ selectProcet || '请选择项目' }}</text>
<u-icon name="arrow-right" size="16" color="#bbb"></u-icon>
</view>
</view>
<up-picker v-model:show="showProject" keyName="name" valueName="planId" @confirm="selectProcetFunc" :columns="projectOptions"></up-picker>
<!-- 日期选择卡片 -->
<view class="filter-card " v-if="selectProcet">
<view class="card-title">
<u-icon name="calendar" size="18" color="#13c2c2"></u-icon>
<text>日期范围</text>
</view>
<view class="date-filter" @click="openCalendar">
<view class="date-item">
<text class="date-label">开始日期</text>
<text class="date-value" :class="{ placeholder: !begin }">{{ begin || '请选择' }}</text>
</view>
<view class="date-divider"></view>
<view class="date-item">
<text class="date-label">结束日期</text>
<text class="date-value" :class="{ placeholder: !end }">{{ end || '请选择' }}</text>
</view>
<u-icon name="arrow-right" size="16" color="#bbb" class="date-arrow"></u-icon>
</view>
</view>
<!-- 项目选择区域 -->
<view class="select-section">
<view class="select-label">
<view class="label-text" style="margin-bottom: 20rpx;">选择项目</view>
<view class="picker-wrapper" @click="showProject=true">
<text class="picker-text">{{ selectProcet || '请选择项目' }}</text>
<u-icon name="arrow-down" size="18" color="#999"></u-icon>
</view>
<!-- 学生选择卡片 -->
<view class="filter-card " v-if="selectProcet && begin">
<view class="card-title">
<u-icon name="account" size="18" color="#52c41a"></u-icon>
<text>选择学生</text>
</view>
</view>
<up-picker v-model:show="showProject" keyName="name" valueName="planId" @confirm="selectProcetFunc"
:columns="projectOptions"></up-picker>
<!-- 日期选择 -->
<view class="date-filter" style="margin: 20rpx 20rpx 0;" v-if="selectProcet">
<view class="date-picker" @click="openCalendar()">
<up-icon name="calendar" color="#3B82F6" size="25"></up-icon>
<view class="date-text">
<text class="label">开始日期</text>
<text class="value">{{ begin || '请选择' }}</text>
</view>
</view>
<view class="date-picker" @click="openCalendar()">
<up-icon name="calendar" color="#3B82F6" size="25"></up-icon>
<view class="date-text">
<text class="label">结束日期</text>
<text class="value">{{ end || '请选择' }}</text>
</view>
<view class="picker-row" @click="showStudentPicker = true">
<text class="picker-text" :class="{ placeholder: selectedStudentIndexes.length === 0 }">{{ selectedStudentDisplay }}</text>
<u-icon name="arrow-right" size="16" color="#bbb"></u-icon>
</view>
</view>
<!-- 学生选择区域 -->
<view class="select-section" v-if="selectProcet && begin ">
<view class="select-label">
<text class="label-text">选择学生</text>
<!-- 数据表格 -->
<view v-if="tableData.length > 0" class="data-card">
<view class="card-title">
<u-icon name="list" size="18" color="#faad14"></u-icon>
<text>分段数据</text>
</view>
<view class="picker-wrapper" @click="showStudentPicker = true">
<text class="picker-text">{{ selectedStudentDisplay }}</text>
<u-icon name="arrow-down" size="18" color="#999"></u-icon>
<view class="table-wrap">
<next-table :show-header="true" :columns="columns" :stripe="true" :fit="false" :show-summary="false" :data="tableData" :showPaging="true" :pageIndex="page" @pageChange="pageChange" @cellClick="cellClick" :pageTotal="pageTotal"></next-table>
</view>
</view>
<!-- 空状态 -->
<view class="empty-card" v-if="begin && selectedStudentIndexes.length > 0 && tableData.length === 0">
<u-icon name="file-text" size="64" color="#d9d9d9"></u-icon>
<text class="empty-text">该日期暂无分段数据</text>
</view>
<!-- 日历弹窗 -->
<up-calendar :show="showCalendar" mode="date" minDate="1776240407000" @confirm="calendarConfirm" @close="calendarClose"></up-calendar>
<!-- 学生多选选择器 -->
<view class="modal-overlay" v-if="showStudentPicker" @click="closeStudentPicker"></view>
<view class="student-picker-modal" v-if="showStudentPicker">
@@ -60,45 +76,19 @@
</view>
</view>
<scroll-view class="student-list" scroll-y>
<view v-for="(student, index) in studentList" :key="student.studentId" class="student-item"
:class="{ 'selected': selectedStudentIndexes.includes(index) }"
@click="toggleStudentSelection(index)">
<view v-for="(student, index) in studentList" :key="student.studentId" class="student-item" :class="{ selected: selectedStudentIndexes.includes(index) }" @click="toggleStudentSelection(index)">
<view class="item-checkbox">
<view class="checkbox-inner" :class="{ 'checked': selectedStudentIndexes.includes(index) }">
<u-icon v-if="selectedStudentIndexes.includes(index)" name="checkmark" size="14"
color="#fff"></u-icon>
<view class="checkbox-inner" :class="{ checked: selectedStudentIndexes.includes(index) }">
<u-icon v-if="selectedStudentIndexes.includes(index)" name="checkmark" size="12" color="#fff"></u-icon>
</view>
</view>
<view class="item-avatar">
<view class="avatar-circle male">
<text class="avatar-text">{{ student.name.charAt(0) }}</text>
</view>
</view>
<view class="item-info">
<text class="item-name">{{ student.name }}</text>
<text class="avatar-text">{{ student.name.charAt(0) }}</text>
</view>
<text class="item-name">{{ student.name }}</text>
</view>
</scroll-view>
</view>
<view v-if="tableData.length>0" class="table-section">
<next-table :show-header="true" :columns="columns" :stripe="true" :fit="false" :show-summary='false'
:data="tableData" :showPaging='true' :pageIndex="page" @pageChange="pageChange" @cellClick="cellClick"
:pageTotal="pageTotal"></next-table>
</view>
<view class="empty-container" v-if="begin && selectedStudentIndexes.length > 0 && tableData.length === 0">
<view class="empty-icon">
<u-icon name="file-text" size="80" color="#ccc"></u-icon>
</view>
<text class="empty-text">该日期暂无分段数据</text>
</view>
<!-- 日历弹窗 -->
<up-calendar :show="showCalendar" mode="date" minDate='1776240407000' @confirm="calendarConfirm"
@close="calendarClose">
</up-calendar>
<!-- 分段详情弹窗 -->
<up-popup :show="showSegmentPopup" @close="closeSegmentPopup" @open="openSegmentPopup" mode="center" round="16" bgColor="#fff">
@@ -151,9 +141,9 @@
segment120 : string
segment170 : string
}
let row=ref('')
let row = ref('')
// 项目选择
let showProject = ref(false)
let selectProcet = ref('')
@@ -206,11 +196,7 @@
const tableData = ref<TableDataItem[]>([])
onLoad(() => {
onLoad(() => {
getProjectData()
})
@@ -323,7 +309,7 @@
page.value = e
getRecordList()
}
const showSegmentPopup = ref(false)
interface SegmentItem {
@@ -343,442 +329,322 @@
}
const cellClick = (rows: any) => {
row.value=rows.subsection
row.value = rows.subsection
openSegmentPopup()
}
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
background-color: #e8ecf3;
}
.paragraph-container {
min-height: 100vh;
padding: 20rpx;
padding-bottom: 40rpx;
}
/* ==================== 页面标题区域) ==================== */
.header-section {
background-color: #fff;
padding: 32rpx 28rpx 24rpx;
/* 通用卡片 */
.filter-card {
background: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
}
.header-title {
.title {
font-size: 36rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.card-title {
display: flex;
align-items: center;
gap: 10rpx;
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
.subtitle {
font-size: 24rpx;
/* 选择行 */
.picker-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22rpx 24rpx;
background: #f5f7fa;
border-radius: 12rpx;
border: 1rpx solid #e4e8ee;
transition: all 0.2s;
&:active {
background: #eef1f6;
}
.picker-text {
font-size: 28rpx;
color: #333;
font-weight: 500;
&.placeholder {
color: #999;
font-weight: 400;
}
}
}
/* 日期选择 */
.date-filter {
display: flex;
gap: 24rpx;
align-items: center;
background: #f0fafa;
border-radius: 12rpx;
padding: 22rpx 24rpx;
border: 1rpx solid #d6f0f0;
transition: all 0.2s;
.date-picker {
flex: 1;
display: flex;
align-items: center;
gap: 16rpx;
background-color: #fff;
border-radius: 16rpx;
padding: 20rpx;
.date-text {
.label {
display: block;
font-size: 26rpx;
color: #999;
}
.value {
display: block;
font-size: 30rpx;
color: #333;
margin-top: 4rpx;
font-weight: 600;
}
}
&:active {
background: #e6f5f5;
}
}
/* ==================== 选择区域通用样式 ==================== */
.select-section {
background-color: #fff;
margin: 0 20rpx 20rpx;
border-radius: 20rpx;
padding: 28rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
transition: all 0.3s ease;
.select-label {
margin-bottom: 16rpx;
.label-text {
font-size: 26rpx;
font-weight: 600;
color: #666;
}
}
.picker-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
background-color: #f8f8f8;
border-radius: 12rpx;
border: 1rpx solid #e8e8e8;
transition: all 0.3s ease;
&:active {
background-color: #f0f0f0;
border-color: #faad14;
transform: scale(0.98);
}
.picker-text {
font-size: 28rpx;
color: #333;
}
}
}
.student-picker-modal {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 70vh;
background: linear-gradient(180deg, #fafbfc 0%, #fff 100%);
border-radius: 32rpx 32rpx 0 0;
z-index: 999;
animation: slideUp 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: 0 -8rpx 40rpx rgba(0, 0, 0, 0.12);
.date-item {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
align-items: center;
gap: 6rpx;
}
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 36rpx 32rpx 28rpx;
background: linear-gradient(180deg, #fafbfc 0%, #fff 100%);
border-bottom: 1rpx solid rgba(0, 0, 0, 0.06);
flex-shrink: 0;
.date-label {
font-size: 24rpx;
color: #666;
font-weight: 500;
}
.header-title {
font-size: 36rpx;
font-weight: 700;
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.date-value {
font-size: 30rpx;
color: #333;
font-weight: 600;
.header-actions {
display: flex;
align-items: center;
gap: 16rpx;
.action-btn {
font-size: 26rpx;
color: #666;
padding: 14rpx 28rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #f5f5f5 0%, #f0f0f0 100%);
font-weight: 600;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
&.primary {
color: #fff;
background: linear-gradient(135deg, #faad14 0%, #ffc53d 50%, #d48806 100%);
box-shadow: 0 4rpx 16rpx rgba(250, 173, 20, 0.3);
}
&:active {
transform: scale(0.92);
}
}
}
}
.student-list {
flex: 1;
overflow-y: auto;
padding: 0 20rpx 40rpx;
.student-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx;
background: linear-gradient(135deg, #fff 0%, #fafbfc 100%);
border-radius: 20rpx;
margin-top: 16rpx;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06), 0 0 0 1rpx rgba(0, 0, 0, 0.04) inset;
position: relative;
overflow: hidden;
&:active {
transform: scale(0.98);
}
.item-checkbox {
flex-shrink: 0;
position: relative;
z-index: 1;
.checkbox-inner {
width: 40rpx;
height: 40rpx;
border-radius: 10rpx;
border: 2rpx solid #d9d9d9;
background-color: #fff;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.checked {
border-color: #faad14;
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
box-shadow: 0 2rpx 8rpx rgba(250, 173, 20, 0.3);
}
}
}
.item-avatar {
flex-shrink: 0;
position: relative;
z-index: 1;
.avatar-circle {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
transition: all 0.3s ease;
&.male {
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 50%, #096dd9 100%);
}
&.female {
background: linear-gradient(135deg, #fa8c16 0%, #ffa940 50%, #d46b08 100%);
}
&::before {
content: '';
position: absolute;
top: 8rpx;
right: 8rpx;
width: 12rpx;
height: 12rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
}
.avatar-text {
font-size: 34rpx;
color: #fff;
font-weight: 700;
letter-spacing: 2rpx;
}
}
}
.item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10rpx;
position: relative;
z-index: 1;
.item-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
letter-spacing: 1rpx;
}
.item-meta {
display: flex;
align-items: center;
gap: 12rpx;
.gender-badge {
font-size: 22rpx;
padding: 6rpx 14rpx;
border-radius: 10rpx;
font-weight: 500;
&.male {
color: #1890ff;
background: linear-gradient(135deg, rgba(24, 144, 255, 0.1) 0%, rgba(64, 169, 255, 0.05) 100%);
}
&.female {
color: #fa8c16;
background: linear-gradient(135deg, rgba(250, 140, 22, 0.1) 0%, rgba(255, 169, 64, 0.05) 100%);
}
}
.age-text {
font-size: 22rpx;
color: #999;
}
}
}
}
&.placeholder {
color: #999;
font-weight: 400;
}
}
.date-divider {
width: 2rpx;
height: 50rpx;
background: #b2dfdb;
margin: 0 20rpx;
}
.date-arrow {
margin-left: 12rpx;
}
/* 数据表格卡片 */
.data-card {
background: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
}
.table-wrap {
border-radius: 12rpx;
overflow: hidden;
border: 1rpx solid #f0f0f0;
}
/* 空状态 */
.empty-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 40rpx;
background: #ffffff;
border-radius: 16rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
.empty-text {
margin-top: 24rpx;
font-size: 28rpx;
color: #999;
}
}
/* 弹窗遮罩 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%);
backdrop-filter: blur(10rpx);
background: rgba(0, 0, 0, 0.55);
z-index: 998;
animation: fadeIn 0.3s ease;
}
/* ==================== 表格区域 ==================== */
.table-section {
margin: 0 20rpx;
background-color: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.table-wrapper-scroll {
width: 100%;
overflow-x: auto;
}
.table-header {
// background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
.header-row {
display: flex;
.header-cell {
min-width: 100px;
padding: 24rpx 16rpx;
font-size: 28rpx;
font-weight: 700;
// color: #fff;
text-align: center;
border-right: 1rpx solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
&:last-child {
border-right: none;
}
}
}
}
.table-body {
.body-row {
display: flex;
transition: all 0.3s ease;
&.even {
background-color: #fafafa;
}
&.odd {
background-color: #fff;
}
&:active {
background-color: #e6f7ff;
}
.body-cell {
min-width: 100px;
padding: 24rpx 16rpx;
font-size: 26rpx;
color: #333;
text-align: center;
border-right: 1rpx solid #f0f0f0;
flex-shrink: 0;
&:last-child {
border-right: none;
}
}
}
}
}
/* ==================== 空状态容器 ==================== */
.empty-container {
/* 学生选择弹窗 */
.student-picker-modal {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 60vh;
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
z-index: 999;
display: flex;
flex-direction: column;
animation: slideUp 0.25s ease;
box-shadow: 0 -8rpx 32rpx rgba(0, 0, 0, 0.15);
}
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 28rpx 24rpx;
border-bottom: 1rpx solid #f0f0f0;
background: #fafafa;
border-radius: 24rpx 24rpx 0 0;
.header-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
align-items: center;
gap: 16rpx;
}
.action-btn {
font-size: 26rpx;
color: #666;
padding: 10rpx 24rpx;
border-radius: 28rpx;
background: #eeeeee;
font-weight: 500;
&.primary {
color: #fff;
background: #faad14;
}
&:active {
opacity: 0.8;
}
}
}
.student-list {
flex: 1;
overflow-y: auto;
padding: 16rpx 24rpx 40rpx;
}
.student-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 22rpx 20rpx;
border-radius: 12rpx;
margin-bottom: 12rpx;
background: #f8f9fa;
transition: background 0.2s;
&:active {
background: #eeeeee;
}
&.selected {
background: #fff3e0;
border: 1rpx solid #ffcc80;
}
}
.item-checkbox {
flex-shrink: 0;
.checkbox-inner {
width: 36rpx;
height: 36rpx;
border-radius: 8rpx;
border: 2rpx solid #bfbfbf;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&.checked {
border-color: #faad14;
background: #faad14;
}
}
}
.item-avatar {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #1890ff;
display: flex;
align-items: center;
justify-content: center;
margin: 0 20rpx;
background-color: #fff;
border-radius: 20rpx;
padding: 80rpx 40rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
.empty-icon {
margin-bottom: 24rpx;
}
.empty-text {
.avatar-text {
font-size: 28rpx;
color: #999;
color: #fff;
font-weight: 600;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
.item-name {
flex: 1;
font-size: 30rpx;
color: #333;
font-weight: 500;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
/* 表格深度样式 */
::v-deep .sl-table {
width: 100%;
}
::v-deep .sl-table__header {
background: #faad14 !important;
}
::v-deep .sl-table__header__cell {
color: #fff !important;
font-weight: 600;
font-size: 26rpx;
}
::v-deep .sl-table__body__cell {
font-size: 26rpx;
color: #333;
padding: 22rpx 14rpx;
}
::v-deep .sl-table__body__row:nth-child(even) {
background-color: #fafafa !important;
}
::v-deep .sl-table__body__row:nth-child(odd) {
background-color: #fff !important;
}
/* 分段详情弹窗 */
.segment-popup-content {
width: 620rpx;
max-height: 70vh;
@@ -853,4 +719,13 @@
}
}
}
</style>
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,8 @@
<view class="empty-state-container">
<!-- 标题区域 -->
<view class="title-section">
<text class="main-title">暂无项目</text>
<text class="sub-title">您还没有创建任何训练项目开始您的第一次游泳训练记录吧</text>
<!-- <text class="main-title">暂无项目</text>
<text class="sub-title">您还没有创建任何训练项目开始您的第一次游泳训练记录吧</text> -->
</view>
<!-- 创建项目模块列表 -->
@@ -150,6 +150,7 @@
})
onShow(()=>{
showTimingModal.value=false
uni.showTabBar()
})
@@ -247,6 +248,7 @@
showTimingModal.value = false
Service.GoPage('/pages/userFunc/hunyang?id=' + project.planId)
}
}
</script>

View File

@@ -23,16 +23,15 @@
<view class="stats-section">
<view class="stats-card">
<view class="stat-item">
<view class="stat-info">
<text class="stat-value">{{ userInfo.projectCount || 0 }}</text>
<view @click="Service.GoPage('/pages/userFunc/projectList')" class="stat-info">
<text class="stat-value">{{ planCount }}</text>
<text class="stat-label">我的项目</text>
</view>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<view class="stat-info">
<text class="stat-value">{{ userInfo.studentCount || 0 }}</text>
<view @click="Service.GoPage('/pages/userFunc/student')" class="stat-info">
<text class="stat-value">{{ studentCount }}</text>
<text class="stat-label">学员数</text>
</view>
</view>
@@ -114,21 +113,24 @@
import { Service } from '@/Service/Service'
import { userService } from '@/Service/swimming/userService'
let planCount=ref(0)
let studentCount=ref(0)
let userInfo = ref<any>({})
onLoad(() => {
loadUserInfo()
})
onShow(() => {
loadUserInfo()
})
const loadUserInfo = () => {
userService.GetUserInfo().then((content) => {
userService.GetUserInfo().then((content:any) => {
if (content.code == 0) {
studentCount.value=content.data.studentCount
planCount.value=content.data.planCount
userInfo.value=content.data.userInfo
} else {
Service.Msg(content.msg)
@@ -314,13 +316,7 @@
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
cursor: pointer;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
}
gap: 16rpx;
}
.stat-icon-bg {

View File

@@ -10,29 +10,44 @@
<input class="form-input" v-model="projectName" placeholder="请输入项目名称"
placeholder-class="input-placeholder" />
</view>
<!-- 计划总时长 -->
<view class="total-time-section">
<view class="total-time-label">计划总时长</view>
<view class="total-time-value">{{ formatTotalTime(totalDuration) }}</view>
</view>
</view>
<!-- 计划列表 -->
<view class="form-card">
<view class="form-title">计划列表</view>
<!-- 分组列表 -->
<view class="form-card" v-for="(group, groupIndex) in groups" :key="group.id">
<view class="group-header">
<view class="group-title-wrapper">
<text class="form-title">{{ group.name || `分组 ${groupIndex + 1}` }}</text>
<view class="delete-group-btn" @click="deleteGroup(groupIndex)">
<u-icon name="trash" size="16" color="#fff"></u-icon>
</view>
</view>
</view>
<!-- 分组名称输入 -->
<view class="form-group" style="margin-bottom: 20rpx;">
<text class="form-label">分组名称</text>
<input class="form-input" v-model="group.name" placeholder="请输入分组名称"
placeholder-class="input-placeholder" />
</view>
<!-- 计划项列表 -->
<view v-for="(item, index) in planList" :key="index" class="plan-item">
<view v-for="(item, planIndex) in group.plans" :key="planIndex" class="plan-item">
<view class="plan-item-header">
<text class="plan-item-title">计划 {{ index + 1 }}</text>
<view class="delete-plan-btn" @click="deletePlanItem(index)">
<text class="plan-item-title">计划 {{ planIndex + 1 }}</text>
<view class="delete-plan-btn" @click="deletePlanItem(groupIndex, planIndex)">
<u-icon name="trash" size="16" color="#fff"></u-icon>
</view>
</view>
<view class="plan-item-content">
<!-- 目标时间 - 分秒选择 -->
<!-- 计划名称 -->
<view class="input-group">
<text class="input-label">计划名称</text>
<input class="plan-name-input" v-model="item.name" placeholder="请输入计划名称" />
</view>
<!-- 目标时间 - 秒输入 -->
<view class="input-group">
<text class="input-label">目标时间</text>
<view class="time-selector">
@@ -43,6 +58,14 @@
</view>
</view>
</view>
<!-- 单项时长显示 -->
<!-- <view class="input-group" v-if="item.allTime > 0">
<text class="input-label">单项时长</text>
<view class="all-time-display">
<text class="all-time-value">{{ item.allTime }} </text>
</view>
</view> -->
<!-- 休息时长 - 秒选择 -->
<view class="input-group">
@@ -65,13 +88,21 @@
</view>
<!-- 添加计划按钮 -->
<view class="add-plan-btn" @click="addPlanItem">
<view class="add-plan-btn" @click="addPlanItem(groupIndex)">
<u-icon name="plus-circle" size="20" color="#1890ff"></u-icon>
<text class="add-plan-text">添加计划</text>
</view>
</view>
</view>
<!-- 添加分组按钮 -->
<view class="add-group-btn" @click="addGroup">
<u-icon name="plus-circle" size="24" color="#fff"></u-icon>
<text class="add-group-text">添加分组</text>
</view>
</view>
<view class="" style="width: 100%; height: 80rpx;" >
</view>
<!-- 底部按钮区域 -->
<view class="bottom-actions">
<view class="action-buttons">
@@ -85,50 +116,79 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Service } from '@/Service/Service'
import { studentService } from '@/Service/swimming/studentService'
import { PlanService } from '@/Service/swimming/PlanService'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { onLoad } from '@dcloudio/uni-app'
// 计划项接口
interface PlanItem {
targetMinutes : string
targetSeconds : string
restTime : string
lapCount : string
name: string
targetSeconds: string
restTime: string
lapCount: string
allTime?: number
}
// 分组接口
interface Group {
id: string
name: string
plans: PlanItem[]
allTime?: number
}
// 项目名称
const projectName = ref('')
// 计划列表
const planList = ref<PlanItem[]>([])
// 分组列表
const groups = ref<Group[]>([])
let planId = ref('')
onLoad((data : any) => {
onLoad((data: any) => {
planId.value = data.id
if(planId.value ){
if (planId.value) {
getPlanInfo()
} else {
// 新建时默认添加一个分组
addGroup()
}
})
// 获取计划详情
const getPlanInfo = () => {
PlanService.GetPlanInfo(planId.value).then(res => {
if (res.code == 0) {
projectName.value=res.data.plan.name
JSON.parse(res.data.plan.project).map((item:any)=>{
planList.value.push({
targetMinutes:'',
targetSeconds : item.target,
restTime : item.rest,
lapCount : item.circle
})
})
projectName.value = res.data.plan.name
const projectData = JSON.parse(res.data.plan.project)
// 兼容旧数据:如果 project 是数组,放入一个默认分组
if (Array.isArray(projectData)) {
groups.value = projectData.map((g: any, index: number) => ({
id: generateId(),
name: g.groupName || `分组 ${index + 1}`,
allTime: g.allTime || 0,
plans: (g.group || []).map((item: any) => ({
name: String(item.name || ''),
targetSeconds: String(item.target || ''),
restTime: String(item.rest || ''),
lapCount: String(item.circle || ''),
allTime: item.allTime || 0
}))
}))
} else if (projectData.groups && Array.isArray(projectData.groups)) {
// 新数据格式
groups.value = projectData.groups.map((g: any) => ({
id: g.id || generateId(),
name: g.name || '',
allTime: g.allTime || 0,
plans: (g.plans || []).map((item: any) => ({
name: String(item.name || ''),
targetSeconds: String(item.target || ''),
restTime: String(item.rest || ''),
lapCount: String(item.circle || ''),
allTime: item.allTime || 0
}))
}))
}
} else {
// 显示错误信息
Service.Msg(res.msg)
}
})
@@ -137,55 +197,75 @@
// 计算计划总时长(秒)
const totalDuration = computed(() => {
let total = 0
planList.value.forEach(item => {
const targetMinutes = parseInt(item.targetMinutes) || 0
const targetSeconds = parseInt(item.targetSeconds) || 0
const targetTime = targetMinutes * 60 + targetSeconds
const restTime = parseInt(item.restTime) || 0
const lapCount = parseInt(item.lapCount) || 0
// 计算公式:(目标时间 + 休息时间) * 圈数
total += (targetTime + restTime) * lapCount
groups.value.forEach(group => {
group.plans.forEach(item => {
const targetSeconds = parseInt(item.targetSeconds) || 0
const restTime = parseInt(item.restTime) || 0
const lapCount = parseInt(item.lapCount) || 0
// 计算公式:(目标时间 + 休息时间) * 圈数
total += (targetSeconds + restTime) * lapCount
})
})
return total
})
// 格式化总时长显示(时:分:秒)
const formatTotalTime = (seconds : number) : string => {
const formatTotalTime = (seconds: number): string => {
if (seconds <= 0) return '00:00:00'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 生成唯一ID
const generateId = () : string => {
const generateId = (): string => {
return Date.now().toString() + Math.random().toString(36).substr(2, 9)
}
// 添加计划项
const addPlanItem = () => {
const newItem : PlanItem = {
// 添加分组
const addGroup = () => {
const newGroup: Group = {
id: generateId(),
targetMinutes: '',
name: '',
plans: []
}
groups.value.push(newGroup)
}
// 删除分组
const deleteGroup = (groupIndex: number) => {
uni.showModal({
title: '确认删除',
content: '确定要删除该分组吗?分组内的所有计划也将被删除。',
success: (res) => {
if (res.confirm) {
groups.value.splice(groupIndex, 1)
Service.Msg('删除成功')
}
}
})
}
// 添加计划项
const addPlanItem = (groupIndex: number) => {
const newItem: PlanItem = {
name: '',
targetSeconds: '',
restTime: '',
lapCount: ''
}
planList.value.push(newItem)
groups.value[groupIndex].plans.push(newItem)
}
// 删除计划项
const deletePlanItem = (index : number) => {
const deletePlanItem = (groupIndex: number, planIndex: number) => {
uni.showModal({
title: '确认删除',
content: '确定要删除该计划吗?',
success: (res) => {
if (res.confirm) {
planList.value.splice(index, 1)
groups.value[groupIndex].plans.splice(planIndex, 1)
Service.Msg('删除成功')
}
}
@@ -193,70 +273,106 @@
}
// 验证表单
const validateForm = () : boolean => {
const validateForm = (): boolean => {
if (!projectName.value.trim()) {
Service.Msg('请输入项目名称')
return false
}
if (planList.value.length === 0) {
Service.Msg('请至少添加一个计划')
if (groups.value.length === 0) {
Service.Msg('请至少添加一个分组')
return false
}
for (let i = 0; i < planList.value.length; i++) {
const item = planList.value[i]
if (!item.targetSeconds) {
Service.Msg(`计划 ${i + 1} 请输入目标时间`)
for (let g = 0; g < groups.value.length; g++) {
const group = groups.value[g]
if (!group.name.trim()) {
Service.Msg(`分组 ${g + 1} 请输入分组名称`)
return false
}
const seconds = parseInt(item.targetSeconds) || 0
if (seconds === 0) {
Service.Msg(`计划 ${i + 1} 目标时间不能为0`)
if (group.plans.length === 0) {
Service.Msg(`分组 "${group.name}" 请至少添加一个计划`)
return false
}
if (!item.restTime || parseInt(item.restTime) < 0) {
Service.Msg(`计划 ${i + 1} 请输入有效的休息时长`)
return false
}
if (!item.lapCount || parseInt(item.lapCount) <= 0) {
Service.Msg(`计划 ${i + 1} 请输入有效的圈数`)
return false
for (let i = 0; i < group.plans.length; i++) {
const item = group.plans[i]
if (!item.name.trim()) {
Service.Msg(`分组 "${group.name}" 计划 ${i + 1} 请输入计划名称`)
return false
}
if (!item.targetSeconds) {
Service.Msg(`分组 "${group.name}" 计划 ${i + 1} 请输入目标时间`)
return false
}
const seconds = parseInt(item.targetSeconds) || 0
if (seconds === 0) {
Service.Msg(`分组 "${group.name}" 计划 ${i + 1} 目标时间不能为0`)
return false
}
if (!item.restTime || parseInt(item.restTime) < 0) {
Service.Msg(`分组 "${group.name}" 计划 ${i + 1} 请输入有效的休息时长`)
return false
}
if (!item.lapCount || parseInt(item.lapCount) <= 0) {
Service.Msg(`分组 "${group.name}" 计划 ${i + 1} 请输入有效的圈数`)
return false
}
}
}
return true
}
// 计算单项时长
const calculatePlanAllTime = (item: PlanItem): number => {
const target = parseInt(item.targetSeconds) || 0
const rest = parseInt(item.restTime) || 0
const circle = parseInt(item.lapCount) || 0
return (target + rest) * circle
}
// 计算分组总时长
const calculateGroupAllTime = (group: Group): number => {
return group.plans.reduce((sum, item) => sum + calculatePlanAllTime(item), 0)
}
// 保存
const handleSave = () => {
if (!validateForm()) {
return
}
let plan = planList.value.map(item => ({
target: (parseInt(item.targetMinutes) || 0) * 60 + (parseInt(item.targetSeconds) || 0),
rest: parseInt(item.restTime),
circle: parseInt(item.lapCount)
}))
// 整理数据
const projectData = groups.value.map(group => {
const groupAllTime = calculateGroupAllTime(group)
return {
allTime: groupAllTime,
groupName: group.name,
group: group.plans.map(item => ({
allTime: calculatePlanAllTime(item),
name: item.name,
target: parseInt(item.targetSeconds) || 0,
rest: parseInt(item.restTime) || 0,
circle: parseInt(item.lapCount) || 0
}))
}
})
console.log(projectData);
const data = {
planId: planId.value,
name: projectName.value,
planType: '混氧项目',
departType: '',
interval: '',
groupInt: '',
groupInt: String(groups.value.length),
subsectionDistance: '',
subsectionInt: '',
users: '',
project: JSON.stringify(plan),
group: '',
project: JSON.stringify(projectData),
group: ''
}
console.log('保存的数据:', data)
@@ -317,6 +433,38 @@
}
}
/* 分组头部 */
.group-header {
margin-bottom: 20rpx;
.group-title-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
.form-title {
margin-bottom: 0;
}
}
}
/* 删除分组按钮 */
.delete-group-btn {
width: 44rpx;
height: 44rpx;
background: linear-gradient(135deg, #ff4d4f 0%, #d9363e 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 8rpx rgba(255, 77, 79, 0.3);
transition: all 0.2s ease;
&:active {
transform: scale(0.95);
}
}
/* 表单输入 */
.form-group {
margin-bottom: 0;
@@ -496,6 +644,45 @@
}
}
/* 计划名称输入 */
.plan-name-input {
flex: 1;
font-size: 28rpx;
color: #333;
background-color: #fff;
border-radius: 12rpx;
padding: 0 16rpx;
height: 72rpx;
}
/* 分组总时长 */
.group-time-info {
margin-right: 16rpx;
padding: 6rpx 12rpx;
background: #e6f7ff;
border-radius: 8rpx;
}
.group-time-text {
font-size: 24rpx;
color: #1890ff;
font-weight: 500;
}
/* 单项时长显示 */
.all-time-display {
padding: 12rpx 16rpx;
background: #f6ffed;
border-radius: 8rpx;
border: 1rpx solid #b7eb8f;
}
.all-time-value {
font-size: 28rpx;
color: #52c41a;
font-weight: 600;
}
/* 添加计划按钮 */
.add-plan-btn {
display: flex;
@@ -521,6 +708,31 @@
}
}
/* 添加分组按钮 */
.add-group-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 28rpx 32rpx;
margin-top: 20rpx;
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
border-radius: 16rpx;
box-shadow: 0 4rpx 12rpx rgba(82, 196, 26, 0.3);
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
box-shadow: 0 2rpx 8rpx rgba(82, 196, 26, 0.2);
}
.add-group-text {
font-size: 30rpx;
color: #fff;
font-weight: 600;
}
}
/* 底部操作按钮 */
.bottom-actions {
position: fixed;

File diff suppressed because it is too large Load Diff

View File

@@ -9,32 +9,39 @@
</view>
<!-- 全局计时器 -->
<view v-if="selectedProject && setting.mode=='interval' " class="card-section global-timer-section">
<view class="global-timer-header">
<view v-if="selectedProject " class="card-section global-timer-section">
<!-- <view class="global-timer-header">
<view class="global-timer-label">全局计时</view>
<view class="global-setting-btn" @click="showSetting = true">
<u-icon name="setting" size="18" color="#1890ff"></u-icon>
</view>
</view> -->
<view class="global-timer-header" style="justify-content: normal;" >
<view class="project-select-text">{{ setting.mode=='interval'?'间隔出发':'一起出发' }}{{ setting.mode=='interval'?'·'+ setting.interval+'s':'' }}</view>
<view class="global-setting-btn" @click="showSetting = true">
<u-icon name="setting" size="18" color="#1890ff"></u-icon>
</view>
</view>
<view class="global-timer-display">
<!-- <view class="global-timer-display">
<view class="global-timer-value">{{ formatGlobalTimer(globalTimerTime) }}</view>
</view>
</view> -->
</view>
<!-- 分组计时器列表 -->
<view v-for="group in groups" :key="group.id" class="card-section">
<view class="section-header">
<view class="group-title-wrapper">
<text class="section-title">{{ group.name }}</text>
<text class="section-title">{{ group.name }} · 总时长 {{ group.allTime}} </text>
<view class="delete-group-btn" @click="deleteGroup(group)">
<u-icon name="trash" size="16" color="#fff"></u-icon>
</view>
</view>
<view class="group-controls">
<!-- @click="openGroupSetting(group)" -->
<!-- <view class="group-control-btn setting-btn">
<u-icon name="setting" size="18" color="#fff"></u-icon>
</view> -->
<view @click="addTimer(group)" class="group-control-btn setting-btn">
<u-icon name="plus" size="16" blod='true' color="#fff"></u-icon>
</view>
<view v-if="!isGroupAllRunning(group) && !isGroupAllCompleted(group)"
class="group-control-btn start-btn" @click="startGroup(group)">
<u-icon name="play-circle" size="18" color="#fff"></u-icon>
@@ -62,18 +69,16 @@
<view class="circle-content-wrapper" style="margin-top: 20rpx;">
<view class="circle-content" :class="{ resting: timer.isResting }">
<view class="circle-inner">
<view class="timer-target" @click.stop="openTimerDetail(timer)">目标:
{{ formatTargetTime(timer.targetTime) }}
<view v-if="timer.status === 'completed'" class="timer-status-completed">
已完成
</view>
<view v-if="timer.isResting" class="timer-rest-countdown">
<text class="rest-label">休息</text>
<text
class="rest-time">{{ formatRestCountdown(timer.restCountdown || 0) }}</text>
<view v-else-if="timer.isResting" class="timer-rest-countdown">
<text class="rest-label">休息</text>
<text class="rest-time">{{ formatRestCountdown(timer.restCountdown || 0) }}</text>
</view>
<view v-else class="timer-time">{{ formatTime(timer) }}</view>
<view class="timer-info" @click.stop="openTimerDetail(timer)">
<text
class="timer-duration">{{ timer.lapCount }}/{{ timer.totalLapCount || 4 }}</text>
<view class="timer-info">
<text class="timer-duration">{{ timer.lapCount }}/{{ timer.totalLapCount || 4 }}</text>
</view>
</view>
</view>
@@ -92,11 +97,6 @@
</view>
</view>
</view>
<view class="add-student-btn" @click="addTimer(group)">
<u-icon name="plus-circle" size="20" color="#1890ff"></u-icon>
<text class="add-student-text">点击添加学生到本组</text>
</view>
</view>
<!-- 添加分组按钮 -->
@@ -131,7 +131,7 @@
</view>
<!-- 底部弹出框 - 秒表详情 -->
<u-popup v-model:show="showTimerDetail" mode="bottom" :round="20" :closeable="true" closeOnClickOverlay>
<u-popup v-model:show="showTimerDetail" :safeAreaInsetTop='true' mode="bottom" :round="20" :closeable="true" closeOnClickOverlay>
<view class="timer-detail-popup">
<view class="mode-tabs">
<view class="mode-tab" :class="{ active: !isEditMode }" @click="isEditMode = false">
@@ -145,8 +145,8 @@
<view v-if="!isEditMode">
<view class="detail-name">{{ selectedTimer?.studentName || '计时器' }}</view>
<view class="stopwatch-display">
<text class="stopwatch-time">{{ formatStopwatchTime(selectedTimer?.currentTime || 0) }}</text>
<text class="stopwatch-millis">{{ formatMillis(selectedTimer?.currentTime || 0) }}</text>
<text class="stopwatch-time">{{ formatStopwatchTime(selectedTimer?.elapsedTime || 0) }}</text>
<text class="stopwatch-millis">{{ formatMillis(selectedTimer?.elapsedTime || 0) }}</text>
</view>
<view class="current-settings-info">
<view class="setting-info-item">
@@ -183,9 +183,9 @@
<text class="btn-label">{{ selectedTimer?.status === 'running' ? '暂停' : '开始' }}</text>
</view>
<view class="detail-btn detail-btn-record" @click="recordLap"
:class="{ disabled: selectedTimer?.status !== 'running' }">
:class="{ disabled: selectedTimer?.status !== 'running' && selectedTimer?.status !== 'resting' }">
<u-icon name="edit-pen" size="24"
:color="selectedTimer?.status === 'running' ? '#fff' : '#bfbfbf'"></u-icon>
:color="selectedTimer?.status === 'running' || selectedTimer?.status === 'resting' ? '#fff' : '#bfbfbf'"></u-icon>
<text class="btn-label">记录</text>
</view>
</view>
@@ -282,7 +282,7 @@
</u-popup>
<!-- 全局设置弹窗 -->
<u-popup v-model:show="showSetting" mode="center" :round="16" :closeable="true" closeOnClickOverlay>
<u-popup v-model:show="showSetting" mode="center" :round="16" :safeAreaInsetBottom='false' :closeable="true" >
<view class="setting-modal">
<view class="setting-title">出发设置</view>
<view class="setting-item" :class="{ active: setting.mode === 'together' }"
@@ -390,6 +390,8 @@
id : string // 唯一标识
studentId : string // 学生ID
currentTime : number // 当前累计游泳时间(秒),休息期间不累计
countdownTime : number // 当前圈目标时间倒计时(秒)
elapsedTime : number // 真实经过时间(秒),用于记录成绩,休息不暂停
status : 'idle' | 'running' | 'paused' | 'completed' | 'resting'
studentName : string // 学生姓名
quicklyTime : number // 学生最快时间(秒)
@@ -403,6 +405,8 @@
totalLapCount : number // 总圈数
restCountdown : number // 休息剩余时间(秒)
isResting : boolean // 是否正在休息
startTimestamp : number // 项目开始的真实时间戳(毫秒)
lastRecordTimestamp : number // 上次记录时的真实时间戳(毫秒)
}
/** 分组 */
@@ -414,6 +418,7 @@
targetTime : number // 分组默认pb用于弹窗回显
restTime : number // 分组默认休息(用于弹窗回显)
lapCount : number // 分组默认圈数(用于弹窗回显)
allTime : number // 计划总时长(秒)
students : string[] // 组内学生ID列表
}
@@ -440,6 +445,7 @@
groupName ?: string
gruopName ?: string // 接口历史拼写兼容
group ?: PlanStudentRaw[] | string | null
allTime ?: number | string // 计划总时长(秒)
}
/** 接口返回的原始计划结构 */
@@ -513,6 +519,8 @@
id: createTimerId(groupId, student.studentId),
studentId: student.studentId,
currentTime: 0,
countdownTime: 0,
elapsedTime: 0,
status: 'idle',
studentName: student.name,
quicklyTime,
@@ -525,7 +533,9 @@
restTime,
totalLapCount,
restCountdown: 0,
isResting: false
isResting: false,
startTimestamp: 0,
lastRecordTimestamp: 0
}
}
@@ -537,6 +547,7 @@
const buildGroup = (raw : PlanGroupRaw, index : number) : Group => {
const id = raw.groupId || createGroupId()
const timers = parsePlanList(raw.group).map(s => buildTimer(s, id))
return {
id,
name: raw.groupName || raw.gruopName || `${index + 1}`,
@@ -545,6 +556,7 @@
targetTime: timers[0]?.pb || 5,
restTime: timers[0]?.restTime || 10,
lapCount: timers[0]?.totalLapCount || 4,
allTime: toNumber(raw.allTime, 0),
students: timers.map(t => t.studentId)
}
}
@@ -575,11 +587,6 @@
*/
const applyPlan = (raw : PlanRaw) => {
planSnapshot.value = { ...raw }
// ungroupedStudents.value = parsePlanList<PlanStudentRaw>(raw.users).map(s => ({
// studentId: s.studentId,
// name: s.name,
// quicklyTime: toNumber(s.quicklyTime, 0)
// }))
groups.value = parsePlanList<PlanGroupRaw>(raw.group).map((g, i) => buildGroup(g, i))
setting.value.mode = raw.departType === '间隔出发' ? 'interval' : 'together'
setting.value.interval = String(toNumber(raw.interval, 5))
@@ -727,6 +734,33 @@
}
}
/**
* 存储每个学生的真实时间计时器 setInterval ID
* 用于 elapsedTime不受休息影响只在 pause/complete/reset 时停止
*/
const elapsedTimeIntervals = new Map<string, number>()
/** 启动真实时间计时器(不受休息影响) */
const startElapsedTimeTimer = (timer : TimerItem) => {
if (elapsedTimeIntervals.has(timer.studentId)) return
if (timer.startTimestamp === 0) {
timer.startTimestamp = Date.now()
}
const id = setInterval(() => {
timer.elapsedTime = (Date.now() - timer.startTimestamp) / 1000
}, 50) as unknown as number
elapsedTimeIntervals.set(timer.studentId, id)
}
/** 停止真实时间计时器 */
const stopElapsedTimeTimer = (studentId : string) => {
const id = elapsedTimeIntervals.get(studentId)
if (id) {
clearInterval(id)
elapsedTimeIntervals.delete(studentId)
}
}
/**
* 启动学生主计时器
* 逻辑:
@@ -745,14 +779,45 @@
timer.isResting = false
timer.restCountdown = 0
timer.hasStarted = true
// 如果倒计时已归零,重置为目标时间
if (timer.countdownTime <= 0) {
timer.countdownTime = timer.targetTime
}
// 启动真实时间计时器(不受休息影响)
startElapsedTimeTimer(timer)
// 通过当前累计时间反推开始时刻,保证暂停后继续计时的连续性
const startAt = Date.now() - timer.currentTime * 1000
const countdownStartAt = Date.now()
const initialCountdown = timer.countdownTime
const id = setInterval(() => {
// 更新累计游泳时间
timer.currentTime = (Date.now() - startAt) / 1000
// 更新目标时间倒计时
const elapsed = (Date.now() - countdownStartAt) / 1000
timer.countdownTime = Math.max(0, initialCountdown - elapsed)
// 倒计时结束,自动完成一圈
if (timer.countdownTime <= 0 && timer.status === 'running') {
clearStudentInterval(timer.studentId)
autoCompleteLap(timer)
}
}, 50) as unknown as number
studentIntervals.set(timer.studentId, id)
}
/**
* 自动完成一圈(倒计时结束时触发,不记录成绩)
* 注意:圈数跑完后不停止计时器,等待用户手动记录完成后再暂停
*/
const autoCompleteLap = (timer : TimerItem) => {
timer.lapCount += 1
if (timer.lapCount >= timer.totalLapCount) {
// 圈数已跑完,但不暂停计时器,继续等待用户记录
Service.Msg(`${timer.studentName} 所有圈数已完成,请记录成绩`)
return
}
startRest(timer)
}
/**
* 暂停学生计时器
* 逻辑:
@@ -763,6 +828,7 @@
const pauseStudentTimer = (timer : TimerItem) => {
clearStudentInterval(timer.studentId)
clearStudentRestInterval(timer.studentId)
stopElapsedTimeTimer(timer.studentId)
if (timer.status === 'running' || timer.status === 'resting') {
timer.status = 'paused'
timer.isResting = false
@@ -772,37 +838,41 @@
/**
* 启动休息倒计时
* 触发时机:学生完成一圈记录后(且未完成总圈数
* 触发时机:学生完成一圈后(自动完成,不记录成绩
*
* 逻辑:
* 1. 先停止主计时器(休息时间不计入 currentTime
* 2. 设置状态为 resting开始倒计时
* 3. 每50ms更新 restCountdown
* 4. 倒计时结束后自动调用 startStudentTimer 恢复计时
* 2. 不停止 elapsedTime 计时器(休息期间继续计时)
* 3. 设置状态为 resting开始倒计时
* 4. 每50ms更新 restCountdown
* 5. 倒计时结束后重置 countdownTime 并自动调用 startStudentTimer 开始下一轮
*/
const startRest = (timer : TimerItem) => {
const restTime = timer.restTime || 0
if (restTime <= 0) {
Service.Msg('已记录')
return
}
clearStudentInterval(timer.studentId)
clearStudentRestInterval(timer.studentId)
timer.status = 'resting'
timer.isResting = true
timer.restCountdown = restTime
// 如果没有休息时间,直接开始下一轮
if (restTime <= 0) {
timer.countdownTime = timer.targetTime
startStudentTimer(timer)
return
}
const restStart = Date.now()
const id = setInterval(() => {
const remaining = Math.max(0, restTime - (Date.now() - restStart) / 1000)
timer.restCountdown = remaining
if (remaining <= 0) {
clearStudentRestInterval(timer.studentId)
// 休息结束,自动开始下一圈计时
// 休息结束,重置倒计时并开始下一
timer.countdownTime = timer.targetTime
startStudentTimer(timer)
}
}, 50) as unknown as number
studentRestIntervals.set(timer.studentId, id)
Service.Msg(`已记录,休息${restTime}`)
Service.Msg(`完成一圈,休息${restTime}`)
}
/**
@@ -812,39 +882,45 @@
const completeTimer = (timer : TimerItem) => {
clearStudentInterval(timer.studentId)
clearStudentRestInterval(timer.studentId)
stopElapsedTimeTimer(timer.studentId)
timer.status = 'completed'
timer.isResting = false
timer.restCountdown = 0
}
/**
* 记录学生一圈
* 记录学生成绩(手动点击,不增加圈数)
* 触发时机:点击学生圆形卡片、或详情弹窗点击"记录"
*
* 逻辑:
* 1. 校验:休息中和未运行时不允许记录
* 2. 将当前 currentTime 推入 records
* 3. lapCount + 1
* 4. 如果达到总圈数,标记完成并提示
* 5. 否则进入休息倒计时
* 1. 校验:未运行/未休息时不能记录
* 2. 如果记录数已达上限,不能记录
* 3. 计算净游泳时间 = elapsedTime - restTime * 已记录数 - sum(所有已记录时间)
* 记录1 = elapsedTime
* 记录2 = elapsedTime - restTime - 记录1
* 记录3 = elapsedTime - restTime*2 - 记录1 - 记录2
* 以此类推
* 4. 当前圈继续运行,不进入下一轮
*/
const recordLapForTimer = (timer : TimerItem) => {
if (timer.status === 'resting') {
Service.Msg('休息中,请等待倒计时结束')
if (timer.status !== 'running' && timer.status !== 'resting') {
Service.Msg('请在运行或休息中记录')
return
}
if (timer.status !== 'running') {
Service.Msg('请在运行中记录')
if (timer.records.length >= timer.totalLapCount) {
Service.Msg('已达到最大记录')
return
}
timer.records.push({ time: timer.currentTime })
timer.lapCount += 1
if (timer.lapCount >= timer.totalLapCount) {
completeTimer(timer)
Service.Msg(`${timer.studentName} 已完成`)
const sumRecords = timer.records.reduce((sum, r) => sum + r.time, 0)
const usedTime = Math.max(0, timer.elapsedTime - (timer.restTime * timer.records.length) - sumRecords)
timer.records.push({ time: usedTime })
// 判断记录数是否等于圈数,等于则暂停计时器
if (timer.records.length >= timer.totalLapCount) {
pauseStudentTimer(timer)
Service.Msg(`${timer.studentName} 已完成所有记录`)
return
}
startRest(timer)
Service.Msg(`${timer.studentName} 已记录`)
}
/**
@@ -854,12 +930,16 @@
const resetTimer = (timer : TimerItem) => {
pauseStudentTimer(timer)
timer.currentTime = 0
timer.countdownTime = 0
timer.elapsedTime = 0
timer.status = 'idle'
timer.records = []
timer.lapCount = 0
timer.hasStarted = false
timer.isResting = false
timer.restCountdown = 0
timer.startTimestamp = 0
timer.lastRecordTimestamp = 0
}
/**
@@ -909,7 +989,7 @@
* 5. 一旦 globalTimerTime >= 分组出发时间,且分组未出发过,则启动该分组内所有未开始的学生计时器
*/
const startGlobalTimer = () => {
stopGlobalTimer()
// stopGlobalTimer()
globalTimerRunning.value = true
globalStartAt = Date.now()
globalBaseTime = globalTimerTime.value
@@ -960,10 +1040,12 @@
* 将分组内所有正在运行或休息的学生计时器暂停
*/
const pauseGroup = (group : Group) => {
group.hasStarted = false
group.timers.forEach(t => {
if (t.status === 'running' || t.status === 'resting') {
pauseStudentTimer(t)
}
t.hasStarted = false
})
Service.Msg(`${group.name} 已暂停`)
}
@@ -994,10 +1076,12 @@
*/
const pauseAllTimers = () => {
stopGlobalTimer()
groups.value.forEach(g => g.hasStarted = false)
getAllTimers.value.forEach(t => {
if (t.status === 'running' || t.status === 'resting') {
pauseStudentTimer(t)
}
t.hasStarted = false
})
Service.Msg('全部已暂停')
}
@@ -1268,6 +1352,8 @@
* 分组内所有学生恢复为未分组状态,并停止他们的计时器
*/
const deleteGroup = (group : Group) => {
console.log(group);
let index = selectedProject.value.group.findIndex((item : any) => {
return item.groupId === group.id
})
@@ -1429,8 +1515,6 @@
*/
const submitData = () => {
pauseAllTimers()
console.log(getAllTimers.value);
const hasData = getAllTimers.value.some(t => t.currentTime > 0)
if (!hasData) { Service.Msg('暂无数据可提交'); return }
@@ -1462,6 +1546,9 @@
PlanService.AddPlanLog(selectedProject.value.planId,'包干项目','','',JSON.stringify(data)).then(res=>{
if(res.code==0){
Service.Msg('提交成功!')
setTimeout(()=>{
Service.GoPageBack()
},1000)
}else{
Service.Msg(res.msg)
}
@@ -1470,11 +1557,21 @@
// ===================== 格式化函数 =====================
/** 学生卡片主计时显示:分:秒:毫秒 */
/** 学生卡片主计时显示(目标时间倒计时):分:秒:毫秒 */
const formatTime = (timer : TimerItem) : string => {
const mins = Math.floor(timer.currentTime / 60)
const secs = Math.floor(timer.currentTime % 60)
const millis = Math.floor((timer.currentTime % 1) * 100)
const time = Math.max(0, timer.countdownTime || 0)
const mins = Math.floor(time / 60)
const secs = Math.floor(time % 60)
const millis = Math.floor((time % 1) * 100)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}:${millis.toString().padStart(2, '0')}`
}
/** 计时器显示(真实经过时间):分:秒:毫秒 */
const formatElapsedTime = (seconds : number) : string => {
const time = seconds || 0
const mins = Math.floor(time / 60)
const secs = Math.floor(time % 60)
const millis = Math.floor((time % 1) * 100)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}:${millis.toString().padStart(2, '0')}`
}
@@ -1524,14 +1621,10 @@
/**
* 详情弹窗中每条记录的单圈时间
* 逻辑第1条 = 记录时刻第N条 = 记录时刻 - 上一条记录时刻
* records 中存储的已经是净游泳时间(已扣除休息时间),直接显示即可
*/
const formatAdjustedRecordTime = (seconds : number, index : number) : string => {
let adjusted = seconds
if (index > 0 && selectedTimer.value) {
adjusted = seconds - selectedTimer.value.records[index - 1].time
}
return formatFullTime(adjusted)
return formatFullTime(seconds)
}
/**
@@ -1542,8 +1635,10 @@
stopGlobalTimer()
studentIntervals.forEach(id => clearInterval(id))
studentRestIntervals.forEach(id => clearInterval(id))
elapsedTimeIntervals.forEach(id => clearInterval(id))
studentIntervals.clear()
studentRestIntervals.clear()
elapsedTimeIntervals.clear()
})
</script>
@@ -1813,7 +1908,7 @@
justify-content: center;
align-items: center;
position: relative;
margin-bottom: 20rpx;
// margin-bottom: 20rpx;
}
.global-timer-label {
@@ -2085,7 +2180,7 @@
right: 0;
background-color: #fff;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
// padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid #f0f0f0;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
z-index: 100;
@@ -2147,7 +2242,7 @@
}
.timer-detail-popup {
padding: 50rpx 40rpx;
padding: 0rpx 40rpx 50rpx;
padding-bottom: calc(50rpx + env(safe-area-inset-bottom));
min-height: 450rpx;
max-height: 75vh;

View File

@@ -33,10 +33,15 @@
</view>
</view>
<view class="project-stats">
<view class="stat-badge">
<view v-if="project.planType=='混氧项目'" class="stat-badge">
<u-icon name="account" size="14" color="#1890ff"></u-icon>
<text class="badge-text">{{ JSON.parse(project.project).length }}个计划</text>
</view>
<view v-else class="stat-badge">
<u-icon name="account" size="14" color="#1890ff"></u-icon>
<text class="badge-text">{{ project.users.length }}位学员</text>
</view>
</view>
</view>
@@ -90,7 +95,14 @@
// 查看项目详情
const viewProjectDetail = (project : any) => {
Service.Msg(`查看「${project.name}」详情`)
if(project.planType=='分段项目'){
Service.GoPage('/pages/userFunc/segmentation?id=' + project.planId)
}else if(project.planType=='计时项目'){
Service.GoPage('/pages/userFunc/swiming?id=' + project.planId)
}else{
Service.GoPage('/pages/userFunc/hunyang?id=' + project.planId)
}
}
// 删除项目

View File

@@ -5,13 +5,14 @@
<view class="config-header">
<text class="config-title">分段设置</text>
<u-icon @click="Service.GoPage('/pages/userFunc/setCourse?id='+planId+'&type=2')" name="setting" size="24"
color="#1890ff"></u-icon>
<u-icon @click="Service.GoPage('/pages/userFunc/setCourse?id='+planId+'&type=2')" name="setting"
size="24" color="#1890ff"></u-icon>
</view>
<view class="config-info">
<text class="info-text">总距离: {{ totalDistance }} ({{ segmentCount }} × {{ segmentDistance }})</text>
<view class="info-text">总距离: {{ totalDistance }} ({{ segmentCount }} × {{ segmentDistance }}) {{ startMode==0?'一起出发':'间隔出发' }} {{ startMode==0?'':' · '+intervalTime+'s' }} </view>
<view class="info-text" style="margin-top: 14rpx;" >启动模式: {{ startMode==0?'一起出发':'间隔出发' }} {{ startMode==0?'':' · '+intervalTime+'s' }} </view>
</view>
</view>
@@ -23,7 +24,7 @@
</view>
<!-- 学生列表 -->
<view class="students-section">
<view class="students-section" style="margin-top: 20rpx;" >
<view class="section-header">
<text class="section-title">学生列表</text>
<text class="student-count">{{ students.length }}位学生</text>
@@ -171,10 +172,10 @@
let planId = ref('')
onLoad((data : any) => {
planId.value = data.id
})
onShow(()=>{
onShow(() => {
getPlanInfo()
})
@@ -183,17 +184,17 @@
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
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)=>{
students.value = res.data.plan.users.map((item : any, index : any) => {
return {
id: item.studentId,
number: index+1,
number: index + 1,
name: item.name,
segments: [],
hasStarted: res.data.plan.departType == '间隔出发'?false:true,
hasStarted: res.data.plan.departType == '间隔出发' ? false : true,
startTime: 0
}
})
@@ -230,10 +231,12 @@
return result
}
// 格式化时间差
// 格式化时间差(详情弹窗用)
// 第一段显示累计时间,后续段显示分段用时
const formatTimeDiff = (index : number, currentTime : number) : string => {
if (index === 0) {
return '00:00:00'
// 第一段显示累计时间
return formatTime(currentTime)
}
if (!currentStudent.value || !currentStudent.value.segments[index - 1]) {
return '00:00:00'
@@ -243,12 +246,19 @@
return formatTime(diff)
}
// 获取学生最后一次记录的累计时间
// 获取学生最后一次记录的分段用时
// 第一段返回累计时间,后续段返回当前段用时(当前累计 - 上一次累计)
const getLastSegmentTime = (student : any) : number => {
if (!student.segments || student.segments.length === 0) {
return 0
}
return student.segments[student.segments.length - 1].time
const lastIndex = student.segments.length - 1
if (lastIndex === 0) {
// 第一段,返回累计时间
return student.segments[0].time
}
// 后续段,返回分段用时
return student.segments[lastIndex].time - student.segments[lastIndex - 1].time
}
// 简化时间格式化(分:秒)
@@ -374,8 +384,8 @@
currentTime.value = 0
students.value.forEach(student => {
student.segments = []
student.hasStarted = startMode.value == 1?false:true,
student.startTime = 0
student.hasStarted = startMode.value == 1 ? false : true,
student.startTime = 0
})
Service.Msg('已全部重置')
}
@@ -384,15 +394,17 @@
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('暂无数据可保存')
Service.Msg('暂无数据可保存!')
return
}
if (!isSave) {
Service.Msg('存在学生未完成!')
return
}
console.log(students.value);
// 这里可以添加保存到后端或本地存储的逻辑
Service.Msg('保存成功', 'success')
let data = [
{
@@ -405,22 +417,27 @@
students.value.map((item : any) => {
let record = item.segments.map((content : any, index : any) => {
return {
circle: (index + 1)*segmentDistance.value,
circle: (index + 1) * segmentDistance.value,
time: content.time
}
})
data.push({
studentId: item.id,
studentName: item.name,
data: record
})
})
PlanService.AddPlanLog(planId.value,'分段项目','',JSON.stringify(data),'').then(res=>{
if(res.code==0){
console.log(data);
PlanService.AddPlanLog(planId.value, '分段项目', '', JSON.stringify(data), '').then(res => {
if (res.code == 0) {
Service.Msg('提交成功!')
}else{
setTimeout(()=>{
Service.GoPageBack()
},1000)
} else {
Service.Msg(res.msg)
}
})
@@ -495,15 +512,14 @@
/* 总计时器区域 */
.total-time-section {
margin-top: 20rpx;
margin-bottom: 30rpx;
}
.timer-bar {
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.timer-bar {
display: flex;
align-items: center;
justify-content: center;

View File

@@ -7,7 +7,7 @@
<view class="form-title">项目信息</view>
<view class="form-group">
<text class="form-label">项目名称</text>
<input class="form-input" v-model="courseData.projectName" placeholder="请输入项目名称"
<input class="form-input" :disabled="planId" v-model="courseData.projectName" placeholder="请输入项目名称"
placeholder-class="input-placeholder" />
</view>
</view>
@@ -115,11 +115,9 @@
@click="toggleStudentSelect(student.studentId)">
<view class="student-checkbox"
:class="{ checked: selectedStudentIds.includes(student.studentId) }">
<u-icon v-if="selectedStudentIds.includes(student.studentId)" name="checkmark" size="14"
color="#fff"></u-icon>
</view>
<view class="student-avatar">
<text class="avatar-text">{{ student.name.charAt(0) }}</text>
<text v-if="selectedStudentIds.includes(student.studentId)" class="checkbox-index">
{{ selectedStudentIds.indexOf(student.studentId) + 1 }}
</text>
</view>
<view class="student-info">
<view class="student-name">{{ student.name }}</view>
@@ -127,21 +125,7 @@
</view>
</view>
<!-- 已选学生预览 -->
<view v-if="selectedStudents.length > 0" class="selected-preview">
<view class="preview-header">
<text class="preview-title">已选学生</text>
</view>
<view class="preview-list">
<view v-for="(student, index) in selectedStudents" :key="student.studentId" class="preview-item">
<text class="preview-index">{{ index + 1 }}</text>
<text class="preview-name">{{ student.name }}</text>
<view class="preview-remove" @click.stop="removeSelectedStudent(student.studentId)">
<u-icon name="close" size="14" color="#ff4d4f"></u-icon>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="" style="width: 100%; height: 80rpx;">
@@ -326,10 +310,20 @@
PlanService.AddPlan(data).then(res=>{
if(res.code==0){
Service.Msg('添加成功!')
setTimeout(()=>{
Service.Msg( planId.value?'修改成功!': '添加成功!')
if(planId.value){
Service.GoPageBack()
},1000)
return
}
if(type.value=='1'){
Service.GoPageDelse('/pages/userFunc/swiming?id=' + res.data.planId)
}else{
Service.GoPageDelse('/pages/userFunc/segmentation?id=' + res.data.planId)
}
}else{
Service.Msg(res.msg)
}
@@ -697,14 +691,15 @@
/* 学生列表 */
.student-list {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 12rpx;
.student-item {
width: calc((100% - 24rpx) / 3);
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx;
gap: 8rpx;
padding: 16rpx 8rpx;
background-color: #f5f5f5;
border-radius: 16rpx;
border: 2rpx solid transparent;
@@ -720,8 +715,8 @@
}
.student-checkbox {
width: 40rpx;
height: 40rpx;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 2rpx solid #d9d9d9;
display: flex;
@@ -734,6 +729,13 @@
border-color: #1890ff;
background-color: #1890ff;
}
.checkbox-index {
font-size: 22rpx;
color: #fff;
font-weight: 600;
line-height: 1;
}
}
.student-avatar {
@@ -745,7 +747,7 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
.avatar-text {
color: #fff;
font-size: 28rpx;
@@ -754,14 +756,11 @@
}
.student-info {
flex: 1;
min-width: 0;
width: 100%;
.student-name {
font-size: 28rpx;
font-size: 26rpx;
font-weight: 600;
color: #333;
margin-bottom: 6rpx;
display: block;
overflow: hidden;
text-overflow: ellipsis;
@@ -771,6 +770,7 @@
.student-meta {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
.gender-badge {

View File

@@ -264,19 +264,19 @@
// 构造接口请求数据
const requestData = {
studentId:formData.value.id,
studentId:formData.value.id?formData.value.id:'' ,
name: formData.value.name,
sex: formData.value.gender,
birthday: formData.value.birthDate,
school: formData.value.school ,
address: formData.value.address
school: formData.value.school?formData.value.school:'' ,
address: formData.value.address ?formData.value.address:''
}
console.log(requestData);
// 调用添加学员接口
studentService.Add(requestData).then((content) => {
if (content.code == 0) {
Service.Msg('添加成功')
Service.Msg(formData.value.id?'修改成功!':'添加成功!')
getData()
} else {
Service.Msg(content.msg)

View File

@@ -18,7 +18,7 @@
<!-- 选手列表 -->
<view class="athletes-section">
<view class="section-header">
<text class="section-title">间隔出发·{{ groupedAthletes.length }}</text>
<text class="section-title">{{ stopwatchMode=='interval'?'间隔触发':'一起出发' }}·{{ groupedAthletes.length }}</text>
<text class="athlete-count">{{ athletes.length }}位选手</text>
</view>
@@ -44,7 +44,7 @@
<text
class="athlete-time">{{ !athlete.time? ' 00:00:00 ':formatTime(athlete.time) }}</text>
<text class="best-time">最快:
{{ athlete.bestTime !== null ? formatTime(athlete.quicklyTime) : '无' }}</text>
{{ athlete.bestTime !== null ? athlete.quicklyTime: '无' }}</text>
</view>
<view class="" style="display: flex;align-items: center; gap: 10rpx;">
@@ -65,7 +65,7 @@
</view>
<!-- 记录提示弹窗 -->
<u-popup :show="showRecord" mode="center" :round="20" :closeable="true" @close="showRecord=false" closeOnClickOverlay>
<u-popup :show="showRecord" mode="center" :round="20" :closeable="true" :safeAreaInsetBottom='false' @close="showRecord=false" closeOnClickOverlay>
<view class="record-notice-modal">
<view class="notice-header">
<text class="notice-title">恭喜以下学生打破记录</text>
@@ -80,7 +80,7 @@
<view v-for="(item,index) in record" class="table-row">
<text class="row-cell index-cell">{{ index+1 }}</text>
<text class="row-cell name-cell">{{ item.name }}</text>
<text class="row-cell time-cell">{{ formatTime(item.quicklyTime) }}</text>
<text class="row-cell time-cell">{{ item.quicklyTime }}</text>
</view>
</scroll-view>
</view>
@@ -89,7 +89,7 @@
<!-- 底部控制按钮 -->
<view class="control-buttons">
<view class="button-group">
<view class="button-group" :class="{ 'record-group': isRunning }">
<button v-if="!isRunning" class="start-btn" @click="startTimer">
<u-icon name="play-right" size="24" color="#fff"></u-icon>
<text class="btn-text">开始</text>
@@ -100,14 +100,13 @@
<text class="btn-text">记录</text>
</button>
</view>
<view class="button-group">
<view class="button-group" :class="{ 'pause-group': isRunning }">
<button v-if="!isRunning" class="reset-all-btn" @click="resetAll">
<u-icon name="reload" size="24" color="#fff"></u-icon>
<text class="btn-text">重置</text>
</button>
<button v-if="isRunning" class="reset-all-btn" @click="stopTimer">
<u-icon name="pause" size="24" color="#fff"></u-icon>
<text class="btn-text">暂停</text>
<text class="btn-text" style="font-size: 28rpx;">暂停</text>
</button>
</view>
@@ -265,7 +264,7 @@
athlete.time = Math.max(0, elapsed - offset)
}
})
}, 10)
}, 16)
}
// 停止计时
@@ -394,12 +393,18 @@
// 提交数据
const submitData = () => {
stopTimer()
const hasData = athletes.value.some(a => a.time > 0)
const allFinished = athletes.value.every(a => a.finished)
if (!hasData) {
Service.Msg('暂无数据可提交')
return
}
// if(!allFinished){
// Service.Msg('还用学生未完成!')
// return
// }
stopTimer()
let data = [{
planName: "",
studentId: "",
@@ -419,6 +424,15 @@
PlanService.AddPlanLog(planId.value,'计时项目',JSON.stringify(data),'','','').then(res=>{
if(res.code==0){
Service.Msg('提交成功!')
stopTimer()
currentTime.value = 0
currentAthleteIndex.value = 0
athletes.value.forEach(athlete => {
athlete.time = 0
athlete.finished = false
athlete.startOffset = undefined
})
if(res.data.record.length>0){
record.value=res.data.record
@@ -558,7 +572,7 @@
background-color: #ffffff;
border-radius: 16rpx;
box-shadow: 0 1rpx 6rpx rgba(0, 0, 0, 0.03);
transition: all 0.3s;
transition: background-color 0.3s, border-color 0.3s;
border: 2rpx solid transparent;
&.finished {
@@ -716,6 +730,14 @@
justify-content: center;
}
.record-group {
flex: 4;
}
.pause-group {
flex: 1;
}
.back-btn,
.start-btn,
.pause-btn,