This commit is contained in:
Ls
2026-07-10 08:41:45 +08:00
parent d1eedfdc5b
commit d020483461
18 changed files with 1204 additions and 497 deletions

View File

@@ -3,8 +3,8 @@ import { Service } from '@/Service/Service';
class studentService {
private static GetStudentListPath : string = '/api/Student/GetStudentList';
/*****学生列表不分页接口*****/
static GetStudentList() {
var result = Service.Request(this.GetStudentListPath, "GET", { });
static GetStudentList(planId:string) {
var result = Service.Request(this.GetStudentListPath, "GET", { planId });
return result;
}

View File

@@ -24,6 +24,38 @@ class userService {
}
private static GetUserListPath : string = '/api/User/GetUserList';
/*****查询用户列表接口*****/
static GetUserList(serch:string,page:number) {
var result = Service.Request(this.GetUserListPath, "GET", {serch,page});
return result;
}
private static UpdateUserRolePath : string = '/api/User/UpdateUserRole';
/*****更改用户权限接口*****/
static UpdateUserRole(userId:string) {
var result = Service.Request(this.UpdateUserRolePath, "POST", {userId});
return result;
}
private static GetKefuInfoPath : string = '/api/User/GetKefuInfo';
/*****获取客服信息接口*****/
static GetKefuInfo() {
var result = Service.Request(this.GetKefuInfoPath, "GET", {});
return result;
}
private static UpdateKefuPath : string = '/api/User/UpdateKefu';
/*****修改客服信息接口*****/
static UpdateKefu(phone:string,name:string) {
var result = Service.Request(this.UpdateKefuPath, "POST", {phone,name});
return result;
}
// private static GetStudentInfoPath : string = '/api/Student/GetStudentInfo';
// /*****根据学生id查详情接口*****/
// static GetStudentInfo(studentId:string) {

View File

@@ -34,7 +34,7 @@
{
"path": "swiming",
"style": {
"navigationBarTitleText": "游泳项目"
"navigationBarTitleText": "计时项目"
}
},
{
@@ -107,6 +107,18 @@
"navigationBarTitleText": "测试",
"pageOrientation": "auto"
}
},
{
"path": "userPermission",
"style": {
"navigationBarTitleText": "权限管理"
}
},
{
"path": "contactManage",
"style": {
"navigationBarTitleText": "联系方式管理"
}
}
]
}, {

View File

@@ -108,7 +108,7 @@
</view>
<!-- 日历弹窗 -->
<up-calendar :show="showCalendar" mode="date" minDate="1776240407000" zIndex="1000" @confirm="calendarConfirm" @close="calendarClose"></up-calendar>
<uni-calendar ref="calendar" :insert="false" range='true' :lunar="true" @confirm="calendarConfirm" />
</view>
</template>
@@ -133,17 +133,17 @@
const projectOptions = ref<Array<any>>([[]])
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[]>([])
@@ -178,7 +178,7 @@
yAxis: {
data: [{
min: 0,
format: (val: number) => val.toFixed(1) + 's'
format: (val : number) => val.toFixed(1) + 's'
}]
},
extra: {
@@ -196,12 +196,14 @@
categories: [] as string[],
series: [] as any[]
})
let calendar=ref(null)
onLoad(() => {
getProjectData()
})
onShow(() => {})
onShow(() => { })
const getProjectData = () => {
PlanService.GetPlanListNoPage('计时项目').then(res => {
@@ -213,7 +215,7 @@
})
}
const selectProcetFunc = (project: any) => {
const selectProcetFunc = (project : any) => {
selectProcet.value = project.name
selectId.value = project.planId
showProject.value = false
@@ -228,11 +230,23 @@
const openCalendar = () => {
showCalendar.value = true
calendar.value.open()
}
const calendarConfirm = (e: any) => {
begin.value = e[0]
end.value = e[e.length - 1]
const calendarConfirm = (e : any) => {
const newDate = e.range
if (e.range.before !== begin.value || e.range.after !== end.value ) {
selectedStudentIds.value = []
lineChartData.value.categories = []
lineChartData.value.series = []
}
begin.value = newDate.before
if( e.range.after){
end.value = e.range.after
}else{
end.value = e.range.before
}
showCalendar.value = false
}
@@ -240,18 +254,18 @@
showCalendar.value = false
}
const loadProjectStudents = (projectId: string) => {
studentService.GetStudentList().then(res => {
const loadProjectStudents = (projectId : string) => {
studentService.GetStudentList('').then(res => {
if (res.code == 0) {
projectStudents.value = res.data
} else {
Service.Msg(res.msg)
}
})
}
const toggleStudent = (studentId: string) => {
const toggleStudent = (studentId : string) => {
const index = selectedStudentIds.value.indexOf(studentId)
if (index !== -1) {
selectedStudentIds.value.splice(index, 1)
@@ -262,36 +276,49 @@
}
selectedStudentIds.value.push(studentId)
}
let studentIdList = selectedStudentIds.value.map((item) => item)
let data = {
const studentIdList = selectedStudentIds.value.map((item) => item)
const reqData = {
planId: selectId.value,
studentId: JSON.stringify(studentIdList),
sTime: begin.value,
eTime: end.value
}
interface DataItem {
name: string
data: any[]
}
const xData = ref<DataItem[]>(
Array.from({ length: studentIdList.length }, () => ({
name: '',
data: []
}))
)
PlanService.GetQuxianLog(data).then(res => {
PlanService.GetQuxianLog(reqData).then(res => {
if (res.code == 0) {
lineChartData.value.categories = []
res.data.list.map((item: any) => {
lineChartData.value.categories.push(item.dayTime)
item.data.map((content: any, index: any) => {
xData.value[index].name = content.studentName
xData.value[index].data.push(content.time)
const list = res.data.list || []
const days = list.map((item : any) => item.dayTime)
// 按已选学生顺序初始化 series缺省日期填充 0
const series = studentIdList.map(id => {
const student = projectStudents.value.find(s => s.studentId === id)
return {
name: student?.name || '',
data: new Array(days.length).fill(0)
}
})
// 建立 studentId -> series 索引映射
const idToIndex : Record<string, number> = {}
studentIdList.forEach((id, idx) => {
idToIndex[id] = idx
})
// 按日期填充数据
list.forEach((dayItem : any, dayIndex : number) => {
; (dayItem.data || []).forEach((record : any) => {
const sIdx = idToIndex[record.studentId]
if (sIdx !== undefined) {
series[sIdx].data[dayIndex] = record.time ?? 0
if (!series[sIdx].name && record.studentName) {
series[sIdx].name = record.studentName
}
}
})
})
lineChartData.value.series = xData.value
lineChartData.value.categories = days
lineChartData.value.series = series
} else {
Service.Msg(res.msg)
}
@@ -689,4 +716,4 @@
color: #999;
}
}
</style>
</style>

View File

@@ -91,8 +91,11 @@
<!-- 日历弹窗 -->
<up-calendar :show="showCalendar" mode="date" minDate="1776240407000" @confirm="calendarConfirm"
@close="calendarClose"></up-calendar>
<uni-calendar ref="calendar" :insert="false" range='true' :lunar="true" @confirm="calendarConfirm" />
<!-- <up-calendar :show="showCalendar" mode="date" minDate="1776240407000" @confirm="calendarConfirm"
@close="calendarClose"></up-calendar> -->
<!-- 学生选择弹窗 -->
<view class="modal-overlay" v-if="showStudentPicker" @click="closeStudentPicker"></view>
@@ -189,7 +192,9 @@
let showProjectPopup = ref(false)
let selectProcet = ref('')
let selectId = ref('')
let calendar=ref(null)
// 日期选择
const begin = ref<string>('')
const end = ref<string>('')
@@ -264,7 +269,7 @@
}
const getStudentList = () => {
studentService.GetStudentList().then(res => {
studentService.GetStudentList('').then(res => {
if (res.code == 0) {
studentList.value = res.data
} else {
@@ -305,11 +310,17 @@
const openCalendar = () => {
showCalendar.value = true
calendar.value.open()
}
const calendarConfirm = (e : any) => {
begin.value = e[0]
end.value = e[e.length - 1]
console.log(e);
begin.value = e.range.before
if( e.range.after){
end.value = e.range.after
}else{
end.value = e.range.before
}
showCalendar.value = false
getRecord()
}

View File

@@ -38,7 +38,7 @@
<view class="student-info">
<text class="student-name">{{ item.studentName }}</text>
<text class="student-speed">最快用时{{ item.quicklyTime }}s</text>
<text class="student-speed">最快用时{{ formatTime(item.quicklyTime ) }}</text>
</view>
<view class="speed-detail">
@@ -105,7 +105,7 @@
studentId: string
}
const gradeList = ref<StudentGrade[]>([])
const gradeList = ref<Array<any>>([])
const maxSpeed = computed(() => {
if (gradeList.value.length === 0) return 0
@@ -143,6 +143,14 @@
}
})
}
// 格式化时间显示
const formatTime = (seconds : number) : string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 100)
return `${mins.toString().padStart(2, '0')}${secs.toString().padStart(2, '0')}${ms.toString().padStart(2, '0')}`
}
const handleRankingClick = (item: StudentGrade) => {
console.log('点击了排名项:', item)

View File

@@ -38,7 +38,8 @@
</view>
<!-- 项目选择弹窗 -->
<up-popup :show="showProjectPopup" mode="center" round="32" :safeAreaInsetBottom="false" @close="showProjectPopup = false">
<up-popup :show="showProjectPopup" mode="center" round="32" :safeAreaInsetBottom="false"
@close="showProjectPopup = false">
<view class="project-popup-content">
<view class="modal-header">
<text class="modal-title">选择项目</text>
@@ -48,7 +49,8 @@
</view>
<scroll-view scroll-y class="popup-project-list">
<view class="project-list-inner">
<view v-for="(project, index) in projectOptions[0]" :key="project.planId" class="project-item" @click="selectProject(project)">
<view v-for="(project, index) in projectOptions[0]" :key="project.planId" class="project-item"
@click="selectProject(project)">
<view class="item-info">
<text class="item-name">{{ project.name }}</text>
</view>
@@ -87,10 +89,8 @@
</view>
<uni-calendar ref="calendar" :insert="false" range='true' :lunar="true" @confirm="calendarConfirm" />
<!-- 日历弹窗 -->
<up-calendar :show="showCalendar" mode="date" minDate="1776240407000" @confirm="calendarConfirm"
@close="calendarClose"></up-calendar>
<!-- 学生选择弹窗 -->
<view class="modal-overlay" v-if="showStudentPicker" @click="closeStudentPicker"></view>
@@ -216,12 +216,12 @@
let row = ref('')
let rowData=ref({
studentName:'',
planName:''
let rowData = ref({
studentName: '',
planName: ''
})
const tableData = ref<any[]>([])
const projectOptions = ref<Array<any>>([[]])
const studentList = ref<Student[]>([])
@@ -268,7 +268,9 @@
}
})
})
let calendar=ref(null)
onLoad(() => {
getProjectData()
getRecordList()
@@ -282,7 +284,7 @@
const getProjectData = () => {
PlanService.GetPlanListNoPage('计时项目').then(res => {
PlanService.GetPlanListNoPage('分段项目').then(res => {
if (res.code == 0) {
projectOptions.value[0] = res.data.list
} else {
@@ -292,7 +294,7 @@
}
const getStudentList = () => {
studentService.GetStudentList().then(res => {
studentService.GetStudentList('').then(res => {
if (res.code == 0) {
studentList.value = res.data
} else {
@@ -303,7 +305,8 @@
const cellClick = (rows : any) => {
rowData.value=rows
rowData.value = rows
console.log(rowData.value);
row.value = rows.subsection
showRecordPopup.value = true
}
@@ -334,11 +337,16 @@
const openCalendar = () => {
showCalendar.value = true
calendar.value.open()
}
const calendarConfirm = (e : any) => {
begin.value = e[0]
end.value = e[e.length - 1]
begin.value = e.range.before
if( e.range.after){
end.value = e.range.after
}else{
end.value = e.range.before
}
showCalendar.value = false
getRecord()
}
@@ -785,8 +793,8 @@
::v-deep .sl-table__body__row:nth-child(odd) {
background-color: #fff !important;
}
/* 项目选择弹窗 */
.project-popup-content {
width: 680rpx;
@@ -950,81 +958,81 @@
}
/* 分段详情弹窗 */
.segment-popup-content {
width: 90vw;
max-height: 70vh;
padding: 32rpx;
.segment-popup-content {
width: 90vw;
max-height: 70vh;
padding: 32rpx;
display: flex;
flex-direction: column;
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
.popup-title {
font-size: 34rpx;
font-weight: 700;
color: #333;
}
}
.popup-table {
background-color: #fafafa;
border-radius: 16rpx;
overflow: hidden;
display: flex;
flex-direction: column;
.popup-header {
.popup-table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
.popup-title {
font-size: 34rpx;
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
padding: 24rpx 0;
.popup-th {
flex: 1;
text-align: center;
font-size: 28rpx;
font-weight: 700;
color: #333;
color: #fff;
}
}
.popup-table {
background-color: #fafafa;
border-radius: 16rpx;
overflow: hidden;
display: flex;
flex-direction: column;
.popup-table-header {
.popup-scroll {
max-height: 400rpx;
.popup-table-row {
display: flex;
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
padding: 24rpx 0;
.popup-th {
border-bottom: 1rpx solid #eee;
background-color: #fff;
&:nth-child(even) {
background-color: #fafafa;
}
.popup-td {
flex: 1;
text-align: center;
font-size: 28rpx;
font-weight: 700;
color: #fff;
color: #333;
}
}
.popup-scroll {
max-height: 400rpx;
.popup-table-row {
display: flex;
padding: 24rpx 0;
border-bottom: 1rpx solid #eee;
background-color: #fff;
&:nth-child(even) {
background-color: #fafafa;
}
.popup-td {
flex: 1;
text-align: center;
font-size: 28rpx;
color: #333;
}
}
.popup-empty {
padding: 60rpx 0;
text-align: center;
.popup-empty-text {
font-size: 28rpx;
color: #999;
}
.popup-empty {
padding: 60rpx 0;
text-align: center;
.popup-empty-text {
font-size: 28rpx;
color: #999;
}
}
}
}
}
@keyframes slideUp {
from {
transform: translateY(100%);

View File

@@ -90,9 +90,9 @@
</view>
<uni-calendar ref="calendar" :insert="false" range='true' :lunar="true" @confirm="calendarConfirm" />
<!-- 日历弹窗 -->
<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 class="modal-overlay" v-if="showStudentPicker" @click="closeStudentPicker"></view>
@@ -132,7 +132,7 @@
<up-popup :show="showRecordPopup" @close="closeRecordPopup" mode="center" round="16" bgColor="#fff">
<view class="record-popup-content">
<view class="record-popup-header">
<text class="record-popup-title">{{ rowData.studentName +'·' + rowData.planName}}</text>
<text class="record-popup-title">{{ rowData.studentName +'·' + rowData.planName+' PB: '+rowData.quicklyTime}}</text>
<u-icon name="close" size="22" color="#999" @click="closeRecordPopup"></u-icon>
</view>
<scroll-view class="record-popup-body" scroll-y>
@@ -195,7 +195,9 @@
const begin = ref<string>('')
const end = ref<string>('')
const showCalendar = ref(false)
let calendar=ref(null)
// 分页相关
let page = ref(1)
let pageTotal = ref(10)
@@ -265,7 +267,7 @@
}
const getStudentList=()=>{
studentService.GetStudentList().then(res=>{
studentService.GetStudentList('').then(res=>{
if (res.code == 0) {
studentList.value =res.data
} else {
@@ -276,6 +278,7 @@
const cellClick=(e:any)=>{
console.log(e);
rowData.value=e
showRecordPopup.value=true
}
@@ -306,11 +309,18 @@
const openCalendar = () => {
showCalendar.value = true
calendar.value.open()
}
const calendarConfirm = (e: any) => {
begin.value = e[0]
end.value = e[e.length - 1]
begin.value = e.range.before
if( e.range.after){
end.value = e.range.after
}else{
end.value = e.range.before
}
showCalendar.value = false
getRecord()
}

View File

@@ -21,7 +21,6 @@
</view>
<view class="card-info">
<text class="card-title">计时项目</text>
<text class="card-desc">记录单次训练的计时数据</text>
</view>
<view class="card-arrow">
<u-icon name="arrow-right" size="20" color="#1890ff"></u-icon>
@@ -37,7 +36,6 @@
</view>
<view class="card-info">
<text class="card-title">包干项目</text>
<text class="card-desc">记录固定距离的训练成绩</text>
</view>
<view class="card-arrow">
<u-icon name="arrow-right" size="20" color="#1890ff"></u-icon>
@@ -53,7 +51,6 @@
</view>
<view class="card-info">
<text class="card-title">分段项目</text>
<text class="card-desc">记录分段训练的详细数据</text>
</view>
<view class="card-arrow">
<u-icon name="arrow-right" size="20" color="#1890ff"></u-icon>
@@ -69,12 +66,11 @@
</view>
<view class="card-info">
<text class="card-title">混氧项目</text>
<text class="card-desc">记录混氧训练的计时数据</text>
</view>
<view class="card-arrow">
<u-icon name="arrow-right" size="20" color="#1890ff"></u-icon>
</view>
</view>
</view>
<!-- <view @click="Service.GoPage('/pages/userFunc/cs')" class="">
@@ -101,7 +97,8 @@
<!-- 项目列表 -->
<view class="project-list">
<text class="list-title" v-if="projects.length > 0">项目列表</text>
<scroll-view v-if="projects.length > 0" scroll-y="true" @scrolltolower='getList()' class="list-container">
<scroll-view v-if="projects.length > 0" scroll-y="true" @scrolltolower='getList()'
class="list-container">
<view v-for="(project,index) in projects" :key="project.planId" class="project-item"
@click="handleProjectClick(project)">
<view class="item-icon">
@@ -147,23 +144,23 @@
// 弹窗状态
const showTimingModal = ref(false)
let isUse=ref(false)
let isUse = ref(false)
onLoad(() => {
if (!Service.GetUserToken()) {
login()
}else{
} else {
isUseFunc()
}
})
onShow(()=>{
showTimingModal.value=false
onShow(() => {
showTimingModal.value = false
uni.showTabBar()
})
const login = () => {
uni.getProvider({
service: 'oauth',
@@ -190,18 +187,18 @@
}
});
}
const isUseFunc=()=>{
userService.IsCheakUserVerify().then(res=>{
if(res.code==0){
isUse.value=res.data.isCheak
}else{
const isUseFunc = () => {
userService.IsCheakUserVerify().then(res => {
if (res.code == 0) {
isUse.value = res.data.isCheak
} else {
Service.Msg(res.msg)
}
})
}
// 获取项目列表数据
const getData = () => {
@@ -213,7 +210,7 @@
// 获取项目列表
const getList = () => {
if (status.value == 'loading' || status.value == 'nomore') {
return
}
@@ -229,22 +226,22 @@
})
}
const goBaogan=()=>{
if(!isUse.value){
const goBaogan = () => {
if (!isUse.value) {
Service.Msg('用户暂无权限,请等待审核!')
return
}
Service.GoPage('/pages/userFunc/project')
}
// 显示计时项目列表弹窗
const showTimingProjectModal = (index : any) => {
if(!isUse.value){
if (!isUse.value) {
Service.Msg('用户暂无权限,请等待审核!')
return
}
uni.hideTabBar()
showTimingModal.value = true
currentIndex.value = index
@@ -283,7 +280,7 @@
showTimingModal.value = false
Service.GoPage('/pages/userFunc/hunyang?id=' + project.planId)
}
}
</script>

View File

@@ -6,7 +6,8 @@
<view class="avatar-circle" v-if="!userInfo.headImg">
<u-icon name="account" size="52" color="#fff"></u-icon>
</view>
<image v-else class="avatar-image" :src="Service.GetMateUrlByImg(userInfo.headImg)" mode="aspectFill"></image>
<image v-else class="avatar-image" :src="Service.GetMateUrlByImg(userInfo.headImg)" mode="aspectFill">
</image>
</view>
<view class="user-info">
<text class="user-nickname">{{ userInfo.name }}</text>
@@ -104,11 +105,55 @@
</view>
</view>
</view>
<!-- 管理员功能区域 -->
<view class="menu-section" v-if="userInfo.role==1">
<view class="menu-card">
<!-- 权限管理菜单项 -->
<view class="menu-item" @click="goPage('/pages/userFunc/userPermission')">
<view class="menu-icon-bg admin-icon-bg">
<u-icon name="lock" size="28" color="#fff"></u-icon>
</view>
<view class="menu-content">
<view class="menu-header">
<text class="menu-title">权限管理</text>
<view class="menu-tag admin-tag">
<text class="tag-text">管理员</text>
</view>
</view>
<text class="menu-desc">管理用户权限和角色</text>
</view>
<view class="menu-arrow">
<u-icon name="arrow-right" size="16" color="#bbb"></u-icon>
</view>
</view>
<view class="menu-divider"></view>
<!-- 联系方式管理菜单项 -->
<view class="menu-item" @click="goPage('/pages/userFunc/contactManage')">
<view class="menu-icon-bg contact-icon-bg">
<u-icon name="phone" size="28" color="#fff"></u-icon>
</view>
<view class="menu-content">
<view class="menu-header">
<text class="menu-title">联系方式管理</text>
<view class="menu-tag contact-tag">
<text class="tag-text">管理员</text>
</view>
</view>
<text class="menu-desc">管理客服联系方式</text>
</view>
<view class="menu-arrow">
<u-icon name="arrow-right" size="16" color="#bbb"></u-icon>
</view>
</view>
</view>
</view>
<!-- 底部联系信息 -->
<view class="contact-footer" @click="callContact">
<text class="contact-label">客服联系人张教练</text>
<text class="contact-phone">138-0000-0000</text>
<view v-if="userInfo.role!=1" class="contact-footer" @click="callContact">
<text class="contact-label">客服联系人{{ kefuName }}</text>
<text class="contact-phone">{{ kefuPhone }}</text>
</view>
</view>
</template>
@@ -119,44 +164,49 @@
import { Service } from '@/Service/Service'
import { userService } from '@/Service/swimming/userService'
let planCount=ref(0)
let studentCount=ref(0)
let planCount = ref(0)
let studentCount = ref(0)
let userInfo = ref<any>({})
let isUse=ref(false)
let isUse = ref(false)
let kefuName = ref('')
let kefuPhone = ref('')
onLoad(() => {
})
onShow(() => {
loadUserInfo()
isUseFunc()
})
const isUseFunc=()=>{
userService.IsCheakUserVerify().then(res=>{
if(res.code==0){
isUse.value=res.data.isCheak
}else{
const isUseFunc = () => {
userService.IsCheakUserVerify().then(res => {
if (res.code == 0) {
isUse.value = res.data.isCheak
} else {
Service.Msg(res.msg)
}
})
}
const loadUserInfo = () => {
userService.GetUserInfo().then((content:any) => {
userService.GetUserInfo().then((content : any) => {
if (content.code == 0) {
studentCount.value=content.data.studentCount
planCount.value=content.data.planCount
userInfo.value=content.data.userInfo
studentCount.value = content.data.studentCount
planCount.value = content.data.planCount
userInfo.value = content.data.userInfo
kefuName.value = content.data.kefuName
kefuPhone.value = content.data.kefuPhone
} else {
Service.Msg(content.msg)
}
})
}
const goPage=(path:any)=>{
if(!isUse.value){
const goPage = (path : any) => {
if (!isUse.value) {
Service.Msg('用户暂无权限,请等待审核!')
return
}
@@ -165,7 +215,7 @@
const callContact = () => {
uni.makePhoneCall({
phoneNumber: '13800000000',
phoneNumber: kefuPhone.value,
fail: () => {
Service.Msg('拨打电话失败')
}
@@ -181,7 +231,7 @@
.user-container {
padding: 20rpx;
padding-top: 30rpx;
padding-bottom: 140rpx;
}
/* 用户信息卡片 */
@@ -328,7 +378,7 @@
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
gap: 16rpx;
}
.stat-icon-bg {
@@ -467,6 +517,16 @@
background: linear-gradient(135deg, #faad14 0%, #ffc53d 50%, #d48806 100%);
}
/* 权限管理图标背景色 - 红色渐变 */
&.admin-icon-bg {
background: linear-gradient(135deg, #f5222d 0%, #ff4d4f 50%, #cf1322 100%);
}
/* 联系方式管理图标背景色 - 青色渐变 */
&.contact-icon-bg {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 50%, #08979c 100%);
}
&.settings-icon-bg {
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 50%, #096dd9 100%);
}
@@ -523,6 +583,13 @@
color: #faad14;
}
/* 管理员标签样式 - 红色 */
&.admin-tag,
&.contact-tag {
background: linear-gradient(135deg, rgba(245, 34, 45, 0.12) 0%, rgba(245, 34, 45, 0.08) 100%);
color: #f5222d;
}
.tag-text {
font-size: 20rpx;
font-weight: 500;
@@ -571,7 +638,6 @@
left: 0;
right: 0;
background-color: #fff;
border-top: 1rpx solid #eee;
padding: 16rpx 0;
display: flex;
flex-direction: column;

View File

@@ -359,7 +359,7 @@
}
})
console.log(projectData);
const data = {
planId: planId.value,
@@ -375,13 +375,24 @@
group: ''
}
console.log('保存的数据:', data)
PlanService.AddPlan(data).then(res => {
if (res.code == 0) {
Service.Msg(planId.value?'修改成功!':'添加成功!')
setTimeout(() => {
Service.GoPageDelse('/pages/userFunc/hunyang?id=' + res.data.planId)
}, 1000)
if(planId.value){
setTimeout(() => {
Service.GoPageBack()
}, 1000)
uni.$emit('change')
}else{
setTimeout(() => {
Service.GoPageDelse('/pages/userFunc/hunyang?id=' + res.data.planId)
}, 1000)
}
} else {

View File

@@ -0,0 +1,187 @@
<template>
<view class="contact-container">
<!-- 编辑表单 -->
<view class="edit-card">
<view class="card-header">
<u-icon name="edit-pen" size="20" color="#faad14"></u-icon>
<text class="card-title">修改联系方式</text>
</view>
<view class="form-content">
<view class="form-group">
<text class="form-label">联系人姓名</text>
<input class="form-input" v-model="kefuInfo.name" placeholder="请输入联系人姓名" type="text" />
</view>
<view class="form-group">
<text class="form-label">联系电话</text>
<input class="form-input" v-model="kefuInfo.phone" placeholder="请输入联系电话" type="number"
maxlength="11" />
</view>
<button class="save-btn" @click="saveContact">保存修改</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Service } from '@/Service/Service'
import { userService } from '@/Service/swimming/userService'
import { onLoad } from '@dcloudio/uni-app'
let kefuInfo = ref<any>({})
onLoad(() => {
getData()
})
const getData = () => {
userService.GetKefuInfo().then(res => {
if (res.code == 0) {
kefuInfo.value = res.data.kefuInfo
} else {
Service.Msg(res.msg)
}
})
}
const saveContact = () => {
if (!kefuInfo.value.name.trim()) {
Service.Msg('请输入联系人姓名')
return
}
if (!kefuInfo.value.phone.trim()) {
Service.Msg('请输入联系电话')
return
}
const phoneReg = /^1[3-9]\d{9}$|^400-\d{3}-\d{4}$|^\d{3,4}-\d{7,8}$/
if (!phoneReg.test(kefuInfo.value.phone)) {
Service.Msg('请输入有效的联系电话')
return
}
userService.UpdateKefu(kefuInfo.value.phone, kefuInfo.value.name).then(res => {
if (res.code == 0) {
Service.Msg('修改成功!')
getData()
} else {
Service.Msg(res.msg)
}
})
}
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
.contact-container {
min-height: 100vh;
padding: 20rpx;
padding-bottom: 40rpx;
}
.contact-card,
.edit-card {
background-color: #fff;
border-radius: 24rpx;
padding: 28rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
}
.card-header {
display: flex;
align-items: center;
gap: 10rpx;
margin-bottom: 20rpx;
.card-title {
font-size: 32rpx;
font-weight: 700;
color: #333;
}
}
.contact-info {
background-color: #f8f9fa;
border-radius: 16rpx;
padding: 24rpx;
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
.info-label {
font-size: 28rpx;
color: #666;
}
.info-value {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
}
}
.form-content {
.form-group {
margin-bottom: 24rpx;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 12rpx;
}
.form-input {
width: 100%;
height: 88rpx;
background-color: #f5f5f5;
border-radius: 16rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #333;
transition: all 0.2s ease;
&:focus {
background-color: #fff;
box-shadow: 0 0 0 4rpx rgba(24, 144, 255, 0.15);
}
}
}
.save-btn {
width: 100%;
height: 96rpx;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-radius: 20rpx;
border: none;
font-size: 32rpx;
color: #fff;
font-weight: 700;
box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.4);
transition: all 0.3s ease;
&:active {
transform: scale(0.97);
box-shadow: 0 4rpx 12rpx rgba(24, 144, 255, 0.3);
}
}
}
</style>

View File

@@ -1,12 +1,13 @@
<template>
<up-navbar v-if="!isLandscape" title="混氧项目" placeholder='true' :autoBack="true">
<up-navbar v-if="!isLandscape" title="混氧项目" placeholder='true' :autoBack="true">
</up-navbar>
<view class="hunyang-container">
<!-- 项目信息头部 -->
<view class="card-section header-section">
<view class="header-content">
<view class="project-name">{{ name }}</view>
<view class="header-action" @click="Service.GoPage('/pages/userFunc/addHunyang?id='+planId)">
<view class="header-action" :style="{ 'margin-right': isLandscape?'70rpx':'0' } "
@click="Service.GoPage('/pages/userFunc/addHunyang?id='+planId)">
<up-icon name="setting" size="22"></up-icon>
</view>
</view>
@@ -22,47 +23,42 @@
<!-- 分组头部 -->
<view class="group-header">
<view class="group-title-area">
<text class="group-name">{{ groupState.groupName }}</text>
<view class="group-name">{{ groupState.groupName }}</view>
</view>
<view class="plan-name">{{ groupState.currentPlan?.name }}</view>
<text class="group-plan-index">
计划总时长: {{ groupState.allTime }}
{{ groupState.allTime }}
<!-- {{ groupState.currentPlanIndex }}/{{ groupState.plans.length }} -->
</text>
</view>
<!-- 当前计划内容 -->
<view class="plan-content" v-if="!groupState.isCompleted">
<!-- 计划名称 -->
<view class="plan-name">{{ groupState.currentPlan?.name }}</view>
<!-- 圈数指示器 -->
<!-- <view class="laps-indicator">
<view v-for="lap in groupState.currentPlan?.circle" :key="lap" class="lap-dot"
:class="{ completed: lap <= groupState.completedLaps }"></view>
</view> -->
<!-- 倒计时 -->
<view class="countdown-box" :class="{ resting: groupState.isResting }">
<text class="countdown-number">
<view class="countdown-box" :style="{ 'margin': !isLandscape? '23rpx 0':'4rpx 0' }"
:class="{ resting: groupState.isResting }">
<view class="countdown-number"
:style="{transform: !isLandscape? 'scale(2, 2)':'scale(1.3, 1.4 )'}">
{{ formatCountdown(groupState.isResting ? groupState.restCountdown : groupState.countdown) }}
</text>
</view>
</view>
<!-- 阶段提示 -->
<!-- <view class="phase-hint" :class="{ resting: groupState.isResting }">
<text v-if="groupState.isResting && groupState.isPlanCompleted" class="phase-text">
计划完成 - 休息中
</text>
<text v-else-if="groupState.isResting" class="phase-text">
休息中 - {{ groupState.completedLaps + 1 }} 圈准备
</text>
<text v-else class="phase-text">
训练中 - {{ groupState.completedLaps + 1 }}
</text>
</view> -->
<!-- 计划详情 -->
<view class="plan-details">
<view v-if="!isLandscape" class="group-controls">
<view v-if="!groupState.isRunning && !groupState.isCompleted"
class="group-control-btn group-start-btn" @click="startGroup(groupState)">
<u-icon name="play-circle" size="24" color="#fff"></u-icon>
<text>开始</text>
</view>
<view v-else-if="groupState.isRunning" class="group-control-btn group-pause-btn"
@click="pauseGroup(groupState)">
<u-icon name="pause-circle" size="24" color="#fff"></u-icon>
<text>暂停</text>
</view>
</view>
<text class="detail-badge">
M:{{ formatCountdown(groupState.currentPlan?.target) }}
</text>
@@ -70,12 +66,18 @@
X:{{ groupState.currentPlan?.rest }}
</text>
<text class="detail-badge">
Q:{{ groupState.currentPlan?.circle }}
Q: {{ groupState.completedLaps }}/{{ groupState.currentPlan?.circle }}
</text>
<view v-if="!isLandscape" class="group-controls">
<view class="group-control-btn group-reset-btn" @click="resetGroup(groupIndex)">
<u-icon name="reload" size="24" color="#fff"></u-icon>
<text>重置</text>
</view>
</view>
</view>
<!-- 分组控制按钮 -->
<view class="group-controls">
<view v-if="isLandscape" class="group-controls">
<view v-if="!groupState.isRunning && !groupState.isCompleted"
class="group-control-btn group-start-btn" @click="startGroup(groupState)">
<u-icon name="play-circle" size="24" color="#fff"></u-icon>
@@ -97,12 +99,14 @@
<view class="plan-completed" v-else>
<u-icon name="checkmark-circle-fill" size="48" color="#4caf50"></u-icon>
<text class="completed-label">分组完成</text>
<text class="completed-label" style="color: var(--nav-mian); font-size: 24rpx;"
@click="resetGroup(groupIndex)">重置</text>
</view>
</view>
</view>
</view>
<!-- 底部按钮 -->
<view class="bottom-controls">
@@ -194,12 +198,17 @@
// ==================== 生命周期 ====================
onLoad((data : any) => {
planId.value = data.id
getPlanInfo()
})
onShow(() => {
checkOrientation()
uni.onWindowResize(checkOrientation)
})
onShow(() => {
getPlanInfo()
uni.$on('change',()=>{
getPlanInfo()
})
})
onUnmounted(() => {
@@ -542,6 +551,7 @@
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
gap: 4rpx;
}
.group-title-area {
@@ -554,6 +564,10 @@
font-size: 22rpx;
font-weight: 600;
color: #333;
width: 78rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.group-plan-index {
@@ -580,10 +594,11 @@
}
.plan-name {
font-size: 28rpx;
font-size: 22rpx;
font-weight: 600;
color: #333;
margin-bottom: 8rpx;
}
/* 圈数指示器 */
@@ -631,14 +646,11 @@
}
.countdown-box {
margin: 20rpx 0;
.countdown-number {
font-size: 72rpx;
font-weight: bold;
font-family: 'Helvetica Neue', monospace;
color: #000;
letter-spacing: 2rpx;
}
&.resting .countdown-number {
@@ -649,7 +661,7 @@
.plan-details {
display: flex;
justify-content: center;
justify-content: space-between;
align-items: center;
gap: 16rpx;
}
@@ -684,7 +696,7 @@
display: flex;
justify-content: space-between;
gap: 20rpx;
padding-top: 16rpx;
}
@@ -869,19 +881,27 @@
}
.group-name {
font-size: 22rpx;
font-size: 18rpx;
letter-spacing: 1rpx;
width: 78rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.group-plan-index {
font-size: 22rpx;
font-size: 18rpx;
letter-spacing: 1rpx;
}
.plan-name {
font-size: 28rpx;
margin-bottom: 8rpx;
font-size: 18rpx;
letter-spacing: 1rpx;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: center;
}
.laps-indicator {
@@ -898,8 +918,8 @@
margin: 10rpx 0;
.countdown-number {
font-size: 60rpx;
letter-spacing: 4rpx;
font-size: 72rpx;
transform: scale(1.2, 1.3);
}
}

View File

@@ -1,9 +1,9 @@
<template>
<up-navbar v-if="!isLandscape" title="包干项目" placeholder='true' :autoBack="true">
<up-navbar v-if="!isLandscape" title="包干项目" placeholder='true' :autoBack="true">
</up-navbar>
<view class="project-container">
<!-- 顶部一排项目选择 + 全局设置 + 添加分组 -->
<view class="header-row">
<view class="header-row">
<view class="header-item project-select-half" @click="showProjectSelect = true">
<text class="header-text">{{ selectedProject?.name || '请选择项目' }}</text>
<view class="header-icon-wrap gray">
@@ -52,7 +52,7 @@
</view>
</view>
<view class="timers-grid">
<view :class=" isLandscape?'timers-grid-y':'timers-grid-x' ">
<view v-for="(timer, index) in group.timers" :key="timer.id" class="timer-card-wrapper">
<view class="timer-circle" :class="{ resting: timer.isResting }" @click="recordTimer(timer)">
<view class="timer-header">
@@ -60,6 +60,14 @@
<text class="name-text">{{ timer.studentName }}</text>
</view>
<view class="delete-btn-wrapper">
<view v-if="isLandscape" @click.stop="openTimerDetail(timer)">
<u-icon name="edit-pen" size="24" color="#22a6f2"></u-icon>
</view>
<view v-if="isLandscape" @click.stop="toggleTimer(timer)" class="">
<u-icon
:name="timer.status === 'running' || timer.status === 'resting' ? 'pause-circle' : 'play-circle'"
size="24" :color="timer.status === 'resting' ? '#faad14' : '#22a6f2'"></u-icon>
</view>
<view class="delete-btn" @click.stop="deleteTimer(timer, group, index)">
<u-icon name="trash" size="14" color="#fff"></u-icon>
</view>
@@ -73,25 +81,33 @@
已完成
</view>
<view v-else-if="timer.isResting" class="timer-rest-countdown">
<text class="rest-label">休息</text>
<text
class="rest-time">{{ formatRestCountdown(timer.restCountdown || 0) }}</text>
<text :class=" isLandscape? 'rest-time-x':'rest-time-y'"
>{{ formatRestCountdown(timer.restCountdown || 0) }}</text>
</view>
<view v-else class="">
<view class="timer-label">
M{{formatTimeObject(timer.targetTime)}}X{{ timer.restTime }}
<view v-else class="circle-inner" >
<view class="timer-label-box ">
<view class="timer-label">
M{{formatTimeObject(timer.targetTime)}}X{{ timer.restTime }}
</view>
<view class="timer-info">
<text
class="timer-duration">{{ timer.records.length }}/{{ timer.totalLapCount || 4 }}</text>
</view>
</view>
<view :class=" isLandscape? 'timer-time-x':'timer-time-y'">
{{ formatTime(timer) }}
</view>
<view class="timer-time">{{ formatTime(timer) }}</view>
</view>
<view class="timer-info">
<!-- <view v-if="!isLandscape && !timer.isResting " class="timer-info">
<text
class="timer-duration">{{ timer.records.length }}/{{ timer.totalLapCount || 4 }}</text>
</view>
</view> -->
</view>
</view>
</view>
<view class="timer-controls">
<view v-if="!isLandscape" class="timer-controls">
<view @click.stop="openTimerDetail(timer)">
<u-icon name="edit-pen" size="24" color="#22a6f2"></u-icon>
</view>
@@ -147,7 +163,7 @@
<view class="detail-name">{{ selectedTimer?.studentName || '计时器' }}</view>
<view class="stopwatch-display">
<text class="stopwatch-time">{{ formatStopwatchTime(selectedTimer?.elapsedTime || 0) }}</text>
<text class="stopwatch-millis">{{ formatMillis(selectedTimer?.elapsedTime || 0) }}</text>
<!-- <text class="stopwatch-millis">{{ formatMillis(selectedTimer?.elapsedTime || 0) }}</text> -->
</view>
<view class="current-settings-info">
<view class="setting-info-item">
@@ -379,7 +395,7 @@
</view>
</u-popup>
</view>
</template>
<script setup lang="ts">
@@ -438,6 +454,10 @@
lastRecordTimestamp : number // 上次记录时的真实时间戳(毫秒)
currentLapTime : number // 当前圈已用时间(秒),用于记录
hasRecordedThisLap : boolean // 当前圈是否已手动记录
// 以下为运行时字段,不持久化,由全局驱动引擎维护
_startAt ?: number // 当前轮次开始时间戳(毫秒)
_currentLapStartAt ?: number // 当前圈开始时间戳(毫秒)
_restStartAt ?: number // 休息倒计时开始时间戳(毫秒)
}
/** 分组 */
@@ -749,74 +769,135 @@
getData()
})
// ===================== 学生计时器核心逻辑 =====================
// ===================== 全局统一驱动引擎 =====================
/**
* 存储每个学生的主计时器 setInterval ID
* Key = studentIdValue = intervalId
* 方案 B单一定时器驱动引擎
* 不再给每个学生创建独立的 setInterval而是用一个全局 interval 每 50ms 统一 tick
* 遍历所有 running/resting 的学生并计算时间差。彻底消除人多时大量 interval 导致的性能问题。
*/
const studentIntervals = new Map<string, number>()
let engineIntervalId : number | null = null
/**
* 存储每个学生的休息倒计时 setInterval ID
* Key = studentIdValue = intervalId
*/
const studentRestIntervals = new Map<string, number>()
/** 清除指定学生的主计时器定时器 */
const clearStudentInterval = (studentId : string) => {
const id = studentIntervals.get(studentId)
if (id) {
clearInterval(id)
studentIntervals.delete(studentId)
}
}
/** 清除指定学生的休息倒计时定时器 */
const clearStudentRestInterval = (studentId : string) => {
const id = studentRestIntervals.get(studentId)
if (id) {
clearInterval(id)
studentRestIntervals.delete(studentId)
}
}
/**
* 存储每个学生的真实时间计时器 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
const startEngine = () => {
if (engineIntervalId) return
engineIntervalId = setInterval(() => {
tick()
}, 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)
const stopEngine = () => {
if (engineIntervalId) {
clearInterval(engineIntervalId)
engineIntervalId = null
}
}
const tick = () => {
const now = Date.now()
// 1. 更新全局计时器(间隔出发模式用)
if (globalTimerRunning.value) {
globalTimerTime.value = globalBaseTime + (now - globalStartAt) / 1000
}
// 2. 间隔出发检查(全局计时器驱动模式下)
if (globalTimerRunning.value && setting.value.mode === 'interval') {
const intervalSec = toNumber(setting.value.interval, 5)
groups.value.forEach((group) => {
group.timers.forEach((t, studentIndex) => {
const startAt = studentIndex * intervalSec
if (globalTimerTime.value >= startAt && !t.hasStarted && t.status !== 'completed' && t.status !== 'running' && t.status !== 'resting') {
startStudentTimer(t)
if (studentIndex > 0) {
Service.Msg(`${t.studentName} 已出发`)
}
}
})
})
}
// 3. 统一更新所有 running / resting 的学生计时器
getAllTimers.value.forEach((timer) => {
if (timer.status === 'running') {
// elapsedTime真实时间休息不暂停
if (timer.startTimestamp > 0) {
timer.elapsedTime = (now - timer.startTimestamp) / 1000
}
// currentTime累计游泳时间
if (timer._startAt) {
timer.currentTime = (now - timer._startAt) / 1000
}
// currentLapTime当前圈已用时间
if (timer._currentLapStartAt) {
timer.currentLapTime = (now - timer._currentLapStartAt) / 1000
}
// 计算周期和显示状态
const cycleTime = timer.targetTime + timer.restTime
if (timer.currentLapTime <= timer.targetTime) {
// 游泳阶段:显示目标倒计时
timer.countdownTime = Math.max(0, timer.targetTime - timer.currentLapTime)
timer.isResting = false
timer.restCountdown = 0
} else if (timer.currentLapTime < cycleTime) {
// 休息阶段:显示休息倒计时
timer.isResting = true
timer.restCountdown = Math.max(0, cycleTime - timer.currentLapTime)
timer.countdownTime = 0
}
// 检查是否达到周期(目标时间 + 休息时间)
if (timer.currentLapTime >= cycleTime) {
// 周期结束自动记录0分如果本圈还没被记录过
if (!timer.hasRecordedThisLap && timer.records.length < timer.totalLapCount) {
timer.records.push({ time: 0 })
}
// 清零,准备下一轮
timer.currentLapTime = 0
timer.hasRecordedThisLap = false
timer.countdownTime = timer.targetTime
timer.isResting = false
timer.restCountdown = 0
timer._currentLapStartAt = now
// 检查是否完成所有圈数
if (timer.records.length >= timer.totalLapCount) {
completeTimer(timer)
}
}
} else if (timer.status === 'resting') {
// elapsedTime休息期间也继续计时
if (timer.startTimestamp > 0) {
timer.elapsedTime = (now - timer.startTimestamp) / 1000
}
// 休息倒计时
if (timer._restStartAt) {
const remaining = Math.max(0, timer.restTime - (now - timer._restStartAt) / 1000)
timer.restCountdown = remaining
if (remaining <= 0) {
timer.pausedRestCountdown = 0
if (timer.lapCount >= timer.totalLapCount) {
completeTimer(timer)
Service.Msg(`${timer.studentName} 休息结束,已完成所有圈数`)
} else {
timer.countdownTime = timer.targetTime
startStudentTimer(timer)
}
}
}
}
})
}
/**
* 启动学生主计时器
* 启动学生主计时器方案B不再创建独立 setInterval由全局 tick 驱动)
* 逻辑:
* 1. 如果当前已经在运行中或已完成,则直接返回
* 2. 先清理旧的定时器防止重复启动多个interval导致时间加速
* 2. 如果有保存的暂停休息时间,优先恢复休息倒计时
* 3. 设置状态为 running记录 hasStarted = true
* 4. 通过 Date.now() 与 currentTime 反推"开始时刻"然后用 setInterval 每50ms更新 currentTime
*
* 注意:允许从 resting 状态恢复计时,所以不拦截 resting
* 4. 通过 Date.now() 与 currentTime 反推"开始时刻"存入 _startAt / _currentLapStartAt
* 5. 启动全局引擎
*/
const startStudentTimer = (timer : TimerItem) => {
if (timer.status === 'running' || timer.status === 'completed') return
@@ -825,60 +906,20 @@
startRestFromPause(timer)
return
}
clearStudentInterval(timer.studentId)
clearStudentRestInterval(timer.studentId)
timer.status = 'running'
timer.hasStarted = true
// 如果倒计时已归零,重置为目标时间
if (timer.countdownTime <= 0) {
timer.countdownTime = timer.targetTime
}
// 启动真实时间计时器(不受休息影响
startElapsedTimeTimer(timer)
// 初始化 elapsedTime 起始时间戳(仅首次
if (timer.startTimestamp === 0) {
timer.startTimestamp = Date.now()
}
// 通过当前累计时间反推开始时刻,保证暂停后继续计时的连续性
const startAt = Date.now() - timer.currentTime * 1000
// 当前圈开始时刻(用于计算 currentLapTime
let currentLapStartAt = Date.now() - timer.currentLapTime * 1000
const id = setInterval(() => {
// 更新累计游泳时间
timer.currentTime = (Date.now() - startAt) / 1000
// 更新当前圈已用时间
timer.currentLapTime = (Date.now() - currentLapStartAt) / 1000
// 计算周期和显示状态
const cycleTime = timer.targetTime + timer.restTime
if (timer.currentLapTime <= timer.targetTime) {
// 游泳阶段:显示目标倒计时
timer.countdownTime = Math.max(0, timer.targetTime - timer.currentLapTime)
timer.isResting = false
timer.restCountdown = 0
} else if (timer.currentLapTime < cycleTime) {
// 休息阶段:显示休息倒计时
timer.isResting = true
timer.restCountdown = Math.max(0, cycleTime - timer.currentLapTime)
timer.countdownTime = 0
}
// 检查是否达到周期(目标时间 + 休息时间)
if (timer.currentLapTime >= cycleTime && timer.status === 'running') {
// 周期结束自动记录0分如果本圈还没被记录过
if (!timer.hasRecordedThisLap && timer.records.length < timer.totalLapCount) {
timer.records.push({ time: 0 })
}
// 清零,准备下一轮
timer.currentLapTime = 0
timer.hasRecordedThisLap = false
timer.countdownTime = timer.targetTime
timer.isResting = false
timer.restCountdown = 0
currentLapStartAt = Date.now()
// 检查是否完成所有圈数
if (timer.records.length >= timer.totalLapCount) {
completeTimer(timer)
}
}
}, 50) as unknown as number
studentIntervals.set(timer.studentId, id)
timer._startAt = Date.now() - timer.currentTime * 1000
timer._currentLapStartAt = Date.now() - timer.currentLapTime * 1000
startEngine()
}
/**
@@ -895,16 +936,12 @@
}
/**
* 暂停学生计时器
* 暂停学生计时器方案B不再需要 clearInterval只改状态
* 逻辑:
* 1. 清除主计时器和休息倒计时
* 2. 如果当前在 running 或 resting状态改为 paused
* 3. 重置 resting 相关标志
* 1. 如果当前在 running 或 resting状态改为 paused
* 2. 重置 resting 相关标志
*/
const pauseStudentTimer = (timer : TimerItem) => {
clearStudentInterval(timer.studentId)
clearStudentRestInterval(timer.studentId)
stopElapsedTimeTimer(timer.studentId)
if (timer.status === 'running' || timer.status === 'resting') {
// 如果正在休息,保存剩余休息时间
if (timer.status === 'resting' && timer.restCountdown > 0) {
@@ -917,20 +954,16 @@
}
/**
* 启动休息倒计时
* 启动休息倒计时方案B不再创建独立 setInterval由全局 tick 驱动)
* 触发时机:学生完成一圈后(自动完成,不记录成绩)
*
*
* 逻辑:
* 1. 先停止主计时器(休息时间不计入 currentTime
* 2. 不停止 elapsedTime 计时器(休息期间继续计时)
* 3. 设置状态为 resting开始倒计时
* 4. 每50ms更新 restCountdown
* 5. 倒计时结束后:如果是最后一圈则标记为完成,否则开始下一轮
* 1. 设置状态为 resting
* 2. 记录 _restStartAt由全局 tick 计算 restCountdown
* 3. 如果没有休息时间,直接处理后续逻辑
*/
const startRest = (timer : TimerItem) => {
const restTime = timer.restTime || 0
clearStudentInterval(timer.studentId)
clearStudentRestInterval(timer.studentId)
timer.status = 'resting'
timer.isResting = true
timer.restCountdown = restTime
@@ -947,31 +980,14 @@
}
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)
// 休息结束,判断是否为最后一圈
if (timer.lapCount >= timer.totalLapCount) {
// 最后一圈休息结束,标记为完成
// completeTimer(timer)
Service.Msg(`${timer.studentName} 休息结束,已完成所有圈数`)
} else {
// 非最后一圈,开始下一轮
timer.countdownTime = timer.targetTime
startStudentTimer(timer)
}
}
}, 50) as unknown as number
studentRestIntervals.set(timer.studentId, id)
timer._restStartAt = Date.now()
startEngine()
const isLastLap = timer.lapCount >= timer.totalLapCount
// Service.Msg(isLastLap ? '最后一圈完成,进入休息' : `完成一圈,休息${restTime}秒`)
}
/**
* 从暂停状态恢复休息倒计时
* 从暂停状态恢复休息倒计时方案B不再创建独立 setInterval由全局 tick 驱动)
* 触发时机:学生在休息中暂停后再次点击开始
*/
const startRestFromPause = (timer : TimerItem) => {
@@ -988,41 +1004,20 @@
}
return
}
clearStudentInterval(timer.studentId)
clearStudentRestInterval(timer.studentId)
timer.status = 'resting'
timer.isResting = true
timer.restCountdown = restTime
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.pausedRestCountdown = 0
if (timer.lapCount >= timer.totalLapCount) {
completeTimer(timer)
Service.Msg(`${timer.studentName} 休息结束,已完成所有圈数`)
} else {
timer.countdownTime = timer.targetTime
startStudentTimer(timer)
}
}
}, 50) as unknown as number
studentRestIntervals.set(timer.studentId, id)
timer._restStartAt = Date.now()
startEngine()
const isLastLap = timer.lapCount >= timer.totalLapCount
Service.Msg(isLastLap ? '最后一圈休息中...' : `继续休息,剩余${restTime.toFixed(1)}`)
}
/**
* 将学生计时器标记为已完成
* 将学生计时器标记为已完成方案B不再需要 clearInterval
* 触发时机lapCount 达到 totalLapCount
*/
const completeTimer = (timer : TimerItem) => {
clearStudentInterval(timer.studentId)
clearStudentRestInterval(timer.studentId)
stopElapsedTimeTimer(timer.studentId)
timer.status = 'completed'
timer.isResting = false
timer.restCountdown = 0
@@ -1087,6 +1082,9 @@
timer.lastRecordTimestamp = 0
timer.currentLapTime = 0
timer.hasRecordedThisLap = false
timer._startAt = undefined
timer._currentLapStartAt = undefined
timer._restStartAt = undefined
}
/**
@@ -1109,7 +1107,6 @@
const globalTimerTime = ref(0) // 全局累计时间(秒)
const globalTimerRunning = ref(false) // 全局计时器是否运行中
let globalTimerInterval : number | null = null // 全局定时器ID
let globalBaseTime = 0 // 暂停前已积累的时间
let globalStartAt = 0 // 本次启动的时刻戳
@@ -1119,19 +1116,15 @@
globalTimerTime.value = globalBaseTime + (Date.now() - globalStartAt) / 1000
}
globalTimerRunning.value = false
if (globalTimerInterval) {
clearInterval(globalTimerInterval)
globalTimerInterval = null
}
}
/**
* 启动全局计时器
*
* 启动全局计时器方案B全局计时逻辑已合并到统一 tick 中)
*
* 逻辑:
* 1. 先停止旧全局计时器
* 2. 恢复所有已暂停的学生计时器(间隔模式下)
* 3. 启动全局时间累计每50ms更新 globalTimerTime
* 3. 启动全局时间累计(由统一 tick 每50ms更新 globalTimerTime
* 4. "一起出发"模式下所有学生立即出发startAt = 0
* 5. "间隔出发"模式下每组内学生按索引 * 间隔秒数依次出发
* 6. 一旦 globalTimerTime >= 学生出发时间,且学生未出发过,则启动该学生计时器
@@ -1141,7 +1134,6 @@
globalTimerRunning.value = true
globalStartAt = Date.now()
globalBaseTime = globalTimerTime.value
const intervalSec = toNumber(setting.value.interval, 5)
// 间隔模式下:立即恢复所有已暂停的学生
if (setting.value.mode === 'interval') {
@@ -1159,20 +1151,7 @@
}
}
globalTimerInterval = setInterval(() => {
globalTimerTime.value = globalBaseTime + (Date.now() - globalStartAt) / 1000
groups.value.forEach((group) => {
group.timers.forEach((t, studentIndex) => {
const startAt = setting.value.mode === 'together' ? 0 : studentIndex * intervalSec
if (globalTimerTime.value >= startAt && !t.hasStarted && t.status !== 'completed' && t.status !== 'running' && t.status !== 'resting') {
startStudentTimer(t)
if (setting.value.mode !== 'together' && studentIndex > 0) {
Service.Msg(`${t.studentName} 已出发`)
}
}
})
})
}, 50) as unknown as number
startEngine()
}
// ===================== 分组级操作 =====================
@@ -1792,39 +1771,53 @@
data: record
})
})
PlanService.AddPlanLog(selectedProject.value.planId, '包干项目', '', '', JSON.stringify(data)).then(res => {
if (res.code == 0) {
Service.Msg('提交成功!')
// resetAllTimers()
} else {
Service.Msg(res.msg)
uni.showModal({
title: '提示', // 对话框标题
content: '请确定提交数据!', // 显示的内容
showCancel: true, // 是否显示取消按钮
cancelText: '取消', // 取消按钮的文字
confirmText: '确定', // 确认按钮的文字
success: function (res) {
if (res.confirm) {
PlanService.AddPlanLog(selectedProject.value.planId, '包干项目', '', '', JSON.stringify(data)).then(res => {
if (res.code == 0) {
Service.Msg('提交成功!')
// resetAllTimers()
} else {
Service.Msg(res.msg)
}
})
} else if (res.cancel) {
console.log('用户点击取消');
}
}
})
});
}
// ===================== 格式化函数 =====================
/** 学生目标时间显示 */
const formatTimeObject = (timer : any) : string => {
const time = Math.max(0, timer)
const time = Math.max(0, timer)
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')}`
return `${mins.toString().padStart(2, '0')}"${secs.toString().padStart(2, '0')}`
}
/** 学生卡片主计时显示(目标时间倒计时):分:秒:毫秒 */
const formatTime = (timer : any) : string => {
const time = Math.max(0, timer.countdownTime ||0)
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')}`
return `${mins.toString().padStart(2, '0')}"${secs.toString().padStart(2, '0')}`
}
/** 计时器显示(真实经过时间):分:秒:毫秒 */
@@ -1833,7 +1826,7 @@
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')}`
return `${mins.toString().padStart(2, '0')}"${secs.toString().padStart(2, '0')}`
}
/** 目标时间显示:分:秒:毫秒 */
@@ -1841,21 +1834,21 @@
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
const millis = Math.floor((seconds % 1) * 100)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}:${millis.toString().padStart(2, '0')}`
return `${mins.toString().padStart(2, '0')}"${secs.toString().padStart(2, '0')}`
}
/** 休息倒计时显示:秒.毫秒 */
/** 休息倒计时显示:秒 */
const formatRestCountdown = (seconds : number) : string => {
const secs = Math.floor(seconds)
const millis = Math.floor((seconds % 1) * 100)
return `${secs.toString().padStart(2, '0')}.${millis.toString().padStart(2, '0')}`
return `${secs.toString().padStart(2, '0')}`
}
/** 详情弹窗秒表大字体部分:分:秒 */
const formatStopwatchTime = (seconds : number) : string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
return `${mins.toString().padStart(2, '0')}"${secs.toString().padStart(2, '0')}`
}
/** 详情弹窗毫秒小字体部分 */
@@ -1898,12 +1891,7 @@
onUnmounted(() => {
stopGlobalTimer()
groups.value.forEach(g => stopGroupDeparture(g))
studentIntervals.forEach(id => clearInterval(id))
studentRestIntervals.forEach(id => clearInterval(id))
elapsedTimeIntervals.forEach(id => clearInterval(id))
studentIntervals.clear()
studentRestIntervals.clear()
elapsedTimeIntervals.clear()
stopEngine()
uni.offWindowResize(updateOrientation)
})
</script>
@@ -2117,8 +2105,8 @@
.card-section {
background-color: #fff;
border-radius: 16px;
padding: 20px 14px;
margin-bottom: 16px;
padding: 14rpx 20rpx;
margin-bottom: 10rpx;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
@@ -2198,7 +2186,7 @@
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
margin-bottom: 4rpx;
.section-title {
margin-bottom: 0;
@@ -2308,9 +2296,17 @@
font-variant-numeric: tabular-nums;
}
.timers-grid {
.timers-grid-y {
display: grid;
grid-template-columns: repeat(auto-fill, 162px);
grid-template-columns: repeat(4, 1fr);
gap: 6px;
justify-content: start;
}
.timers-grid-x {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
justify-content: start;
}
@@ -2344,7 +2340,7 @@
border-radius: 10px;
background-color: #fff;
display: flex;
padding: 12px 10px;
padding: 10rpx 6rpx 14rpx;
flex-direction: column;
align-items: center;
justify-content: center;
@@ -2381,6 +2377,9 @@
.delete-btn-wrapper {
z-index: 10;
margin-left: 8px;
display: flex;
align-items: center;
gap: 10rpx;
.delete-btn {
width: 24px;
@@ -2407,6 +2406,10 @@
.circle-content {
width: 100%;
height: 110px;
display: flex;
align-items: center;
justify-content: center;
padding: 6px 0;
border-radius: 6px;
border: 2px solid #faad14;
@@ -2422,12 +2425,13 @@
}
.circle-inner {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px 8px;
justify-content: space-around;
gap: 2rpx;
// padding: 4px 8px;
}
}
@@ -2447,12 +2451,22 @@
background: rgba(255, 255, 255, 0.8);
}
.rest-time {
font-size: 24px;
.rest-time-x {
font-size: 48rpx;
transform: scale(1.7,1.9);
font-weight: bold;
color: #ff7875;
animation: countdownFlash 0.5s ease-in-out infinite;
}
.rest-time-y {
font-size: 48rpx;
transform: scale(2.8,3.2);
font-weight: bold;
color: #ff7875;
animation: countdownFlash 0.5s ease-in-out infinite;
}
}
.timer-target {
@@ -2466,17 +2480,34 @@
white-space: nowrap;
}
.timer-label-box {
display: flex;
justify-content: space-between;
align-items: center;
}
.timer-label {
font-size: 12px;
color: #333;
text-align: center;
}
.timer-time {
font-size: 24px;
.timer-time-y {
font-size: 56rpx;
font-weight: bold;
color: #333;
transform: scale(1.5, 1.9);
}
.timer-time-x {
font-size: 40rpx;
font-weight: bold;
color: #333;
line-height: 1.2;
transform: scale(1.2, 1.3);
}
.timer-duration {

View File

@@ -98,7 +98,7 @@
</view>
</view>
<view class="record-list"
<scroll-view class="record-list" scroll-y
v-if="currentStudent && currentStudent.segments && currentStudent.segments.length > 0">
<view class="record-header">
<text class="record-cell header-cell">分段</text>
@@ -112,7 +112,7 @@
<text class="record-cell time-cell">{{ formatTime(segment.time) }}</text>
<text class="record-cell time-cell">{{ formatTimeDiff(index, segment.time) }}</text>
</view>
</view>
</scroll-view>
<view class="empty-record" v-else>
<u-icon name="info-circle" size="48" color="#ccc"></u-icon>
@@ -351,9 +351,9 @@
student.segments.push({
time: Number(recordTime).toFixed(3)
})
Service.Msg(`${student.name}${student.segments.length}段已记录`)
// 所有学生分段记录完成后自动停止计时
const allCompleted = students.value.every(s => s.segments.length >= maxSeg)
if (allCompleted) {
@@ -402,7 +402,7 @@
students.value.map((item : any) => {
let record = item.segments.map((content : any, index : any) => {
return {
circle: (index + 1) * segmentDistance.value,
circle: (index + 1),
time: content.time
}
})
@@ -671,7 +671,7 @@
.record-popup {
background-color: #fff;
border-radius: 20rpx 20rpx 0 0;
max-height: 80vh;
height: 70vh;
overflow: hidden;
display: flex;
flex-direction: column;

View File

@@ -220,7 +220,7 @@
const getStudentList = async () => {
loading.value = true
studentService.GetStudentList().then(res => {
studentService.GetStudentList(planId.value ).then(res => {
if (res.code == 0) {
allStudents.value = res.data
loading.value = false

View File

@@ -316,7 +316,7 @@
const groupOffset = (group.groupNumber - 1) * interval
if (elapsed >= groupOffset && !notifiedGroupNumbers.value.has(group.groupNumber)) {
notifiedGroupNumbers.value.add(group.groupNumber)
Service.Msg(`${group.groupNumber}组出发了`)
// Service.Msg(`第${group.groupNumber}组出发了`)
speak()
}
})
@@ -672,8 +672,7 @@
.athlete-item {
display: flex;
align-items: center;
padding: 24rpx;
margin-bottom: 12rpx;
padding: 14rpx 24rpx;
background-color: #ffffff;
border-radius: 16rpx;
box-shadow: 0 1rpx 6rpx rgba(0, 0, 0, 0.03);
@@ -883,8 +882,8 @@
/* 可拖动悬浮球 */
.float-ball {
position: fixed;
width: 120rpx;
height: 120rpx;
width: 170rpx;
height: 170rpx;
border-radius: 50%;
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
display: flex;
@@ -916,7 +915,7 @@
.ball-text {
color: #fff;
font-size: 28rpx;
font-size: 40rpx;
font-weight: 700;
letter-spacing: 2rpx;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.15);

View File

@@ -0,0 +1,288 @@
<template>
<view class="permission-container">
<!-- 搜索栏 -->
<view class="search-section">
<view class="search-box">
<u-icon name="search" size="20" color="#bbb"></u-icon>
<input class="search-input" v-model="keyword" placeholder="搜索用户姓名或手机号" confirm-type="search"
@input="onSearch" />
<view v-if="keyword" class="search-clear" @click="clearSearch">
<u-icon name="close-circle" size="18" color="#ccc"></u-icon>
</view>
</view>
</view>
<!-- 用户列表 -->
<view class="users-section">
<view v-if="users.length === 0" class="empty-state">
<view class="empty-icon">
<u-icon name="account" size="64" color="#ddd"></u-icon>
</view>
<text class="empty-text">暂无用户</text>
</view>
<view v-else class="users-list">
<view v-for="user in users" :key="user.userId" class="user-card">
<view class="user-avatar">
<image v-if="user.headImg" class="avatar-img" :src="Service.GetMateUrlByImg(user.headImg)"
mode="aspectFill"></image>
<view v-else class="avatar-default">
<text class="avatar-text">{{ user.name ? user.name.charAt(0) : '?' }}</text>
</view>
</view>
<view class="user-info">
<text class="user-name">{{ user.name }}</text>
<text class="user-phone">{{ user.phone }}</text>
</view>
<view class="user-action">
<!-- 手写开关器status == 1 为开 -->
<view
class="custom-switch"
:class="{ 'switch-on': user.status == 1 }"
@click="toggleStatus(user)"
>
<view class="switch-thumb"></view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Service } from '@/Service/Service'
import { userService } from '@/Service/swimming/userService'
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
const keyword = ref('')
const users = ref<Array<any>>([])
let page = ref(1)
let loadStatus = ref('nomore')
onLoad(() => {
getData()
})
onReachBottom(() => {
getList()
})
const getData = () => {
users.value = []
page.value = 1
loadStatus.value = 'loadmore'
getList()
}
const getList = () => {
if (loadStatus.value == 'loading' || loadStatus.value == 'nomore') {
return
}
loadStatus.value = 'loading'
userService.GetUserList(keyword.value, page.value).then((res: any) => {
if (res.code == 0) {
users.value = [...users.value, ...res.data.list]
loadStatus.value = res.data.list.length == 10 ? 'loadmore' : 'nomore'
page.value++
} else {
Service.Msg(res.msg)
}
})
}
const onSearch = () => {
getData()
}
const clearSearch = () => {
keyword.value = ''
getData()
}
const toggleStatus = (user: any) => {
userService.UpdateUserRole(user.userId).then(res=>{
if (res.code == 0) {
const newStatus = user.status == 1 ? 0 : 1
user.status = newStatus
} else {
Service.Msg(res.msg)
}
})
}
</script>
<style lang="scss" scoped>
page {
background-color: #f5f5f5;
}
.permission-container {
min-height: 100vh;
padding: 20rpx;
padding-bottom: 40rpx;
}
/* 搜索栏 */
.search-section {
margin-bottom: 20rpx;
.search-box {
display: flex;
align-items: center;
gap: 12rpx;
background-color: #fff;
border-radius: 16rpx;
padding: 16rpx 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
.search-input {
flex: 1;
font-size: 28rpx;
color: #333;
height: 48rpx;
}
.search-clear {
display: flex;
align-items: center;
justify-content: center;
width: 44rpx;
height: 44rpx;
}
}
}
/* 用户列表 */
.users-section {
.empty-state {
background-color: #fff;
border-radius: 24rpx;
padding: 80rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
.empty-icon {
margin-bottom: 24rpx;
opacity: 0.6;
}
.empty-text {
font-size: 30rpx;
font-weight: 600;
color: #666;
}
}
}
.users-list {
.user-card {
background-color: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 16rpx;
display: flex;
align-items: center;
gap: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
}
.user-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
.avatar-img {
width: 100%;
height: 100%;
}
.avatar-default {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.avatar-text {
color: #fff;
font-size: 30rpx;
font-weight: 700;
}
}
}
.user-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
min-width: 0;
.user-name {
font-size: 32rpx;
font-weight: 700;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-phone {
font-size: 24rpx;
color: #999;
}
}
.user-action {
flex-shrink: 0;
}
}
}
/* 手写开关器 */
.custom-switch {
width: 96rpx;
height: 52rpx;
border-radius: 26rpx;
background-color: #d9d9d9;
position: relative;
transition: background-color 0.3s ease;
cursor: pointer;
.switch-thumb {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background-color: #fff;
position: absolute;
top: 4rpx;
left: 4rpx;
transition: transform 0.3s ease;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.15);
}
&.switch-on {
background-color: #52c41a;
.switch-thumb {
transform: translateX(44rpx);
}
}
&:active {
opacity: 0.8;
}
}
</style>