智慧教务系统
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1032 lines
23 KiB

<!--学员课程预约页面-->
<template>
<view class="main_box">
<!-- 自定义导航栏 -->
<view class="navbar_section">
<view class="navbar_back" @click="goBack">
<text class="back_icon"></text>
</view>
<view class="navbar_title">课程预约</view>
<view class="navbar_action"></view>
</view>
<!-- 学员信息 -->
<view class="student_info_section" v-if="studentInfo">
<view class="student_name">{{ studentInfo.name }}</view>
<view class="course_info">
<text class="info_item">剩余课时{{ studentInfo.remaining_courses || 0 }}</text>
<text class="info_item">可预约{{ availableBookings }}</text>
</view>
</view>
<!-- 日期选择器 -->
<view class="date_selector_section">
<view class="date_header">
<view class="month_info">{{ currentMonth }}</view>
<view class="date_controls">
<view class="control_button" @click="prevWeek"></view>
<view class="control_button" @click="nextWeek"></view>
</view>
</view>
<view class="date_tabs">
<view
v-for="date in dateList"
:key="date.date"
:class="['date_tab', selectedDate === date.date ? 'active' : '', date.disabled ? 'disabled' : '']"
@click="selectDate(date)"
>
<view class="tab_weekday">{{ date.weekday }}</view>
<view class="tab_date">{{ date.day }}</view>
<view class="tab_dot" v-if="date.hasBooking"></view>
</view>
</view>
</view>
<!-- 时段选择 -->
<view class="time_slots_section">
<view class="section_title">可预约时段</view>
<view v-if="loading" class="loading_section">
<view class="loading_text">加载中...</view>
</view>
<view v-else-if="timeSlots.length === 0" class="empty_section">
<view class="empty_icon">📅</view>
<view class="empty_text">当日暂无可预约时段</view>
<view class="empty_hint">请选择其他日期</view>
</view>
<view v-else class="time_slots_list">
<view
v-for="slot in timeSlots"
:key="slot.id"
:class="['time_slot_item', slot.status]"
@click="selectTimeSlot(slot)"
>
<view class="slot_time">
<view class="time_range">{{ slot.start_time }} - {{ slot.end_time }}</view>
<view class="time_duration">{{ slot.duration }}分钟</view>
</view>
<view class="slot_info">
<view class="coach_name">教练:{{ slot.coach_name }}</view>
<view class="course_type">{{ slot.course_type }}</view>
<view class="venue_info">{{ slot.venue_name }}</view>
</view>
<view class="slot_status">
<view v-if="slot.status === 'available'" class="status_text available">可预约</view>
<view v-else-if="slot.status === 'booked'" class="status_text booked">已预约</view>
<view v-else-if="slot.status === 'full'" class="status_text full">已满员</view>
<view v-else-if="slot.status === 'closed'" class="status_text closed">已结束</view>
</view>
</view>
</view>
</view>
<!-- 我的预约 -->
<view class="my_bookings_section">
<view class="section_title">我的预约</view>
<view v-if="myBookings.length === 0" class="empty_bookings">
<view class="empty_text">暂无预约</view>
</view>
<view v-else class="bookings_list">
<view
v-for="booking in myBookings"
:key="booking.id"
class="booking_item"
>
<view class="booking_header">
<view class="booking_date">{{ formatBookingDate(booking.booking_date) }}</view>
<view class="booking_status" :class="booking.status">
{{ getBookingStatusText(booking.status) }}
</view>
</view>
<view class="booking_details">
<view class="detail_row">
<text class="detail_label">时间:</text>
<text class="detail_value">{{ booking.start_time }} - {{ booking.end_time }}</text>
</view>
<view class="detail_row">
<text class="detail_label">教练:</text>
<text class="detail_value">{{ booking.coach_name }}</text>
</view>
<view class="detail_row">
<text class="detail_label">课程:</text>
<text class="detail_value">{{ booking.course_type }}</text>
</view>
<view class="detail_row">
<text class="detail_label">场地:</text>
<text class="detail_value">{{ booking.venue_name }}</text>
</view>
</view>
<view class="booking_actions" v-if="booking.status === 'pending'">
<fui-button
background="transparent"
color="#e74c3c"
size="small"
@click="cancelBooking(booking)"
>
取消预约
</fui-button>
</view>
</view>
</view>
</view>
<!-- 预约确认弹窗 -->
<view class="booking_popup" v-if="showBookingPopup" @click="closeBookingPopup">
<view class="popup_content" @click.stop>
<view class="popup_header">
<view class="popup_title">确认预约</view>
<view class="popup_close" @click="closeBookingPopup">×</view>
</view>
<view class="popup_booking_info" v-if="selectedSlot">
<view class="info_row">
<text class="info_label">日期:</text>
<text class="info_value">{{ formatDate(selectedDate) }}</text>
</view>
<view class="info_row">
<text class="info_label">时间:</text>
<text class="info_value">{{ selectedSlot.start_time }} - {{ selectedSlot.end_time }}</text>
</view>
<view class="info_row">
<text class="info_label">教练:</text>
<text class="info_value">{{ selectedSlot.coach_name }}</text>
</view>
<view class="info_row">
<text class="info_label">课程:</text>
<text class="info_value">{{ selectedSlot.course_type }}</text>
</view>
<view class="info_row">
<text class="info_label">场地:</text>
<text class="info_value">{{ selectedSlot.venue_name }}</text>
</view>
</view>
<view class="popup_actions">
<fui-button
background="#f8f9fa"
color="#666"
@click="closeBookingPopup"
>
取消
</fui-button>
<fui-button
background="#29d3b4"
:loading="booking"
@click="confirmBooking"
>
{{ booking ? '预约中...' : '确认预约' }}
</fui-button>
</view>
</view>
</view>
</view>
</template>
<script>
import apiRoute from '@/api/apiRoute.js'
export default {
data() {
return {
studentId: 0,
studentInfo: {},
selectedDate: '',
dateList: [],
timeSlots: [],
myBookings: [],
loading: false,
booking: false,
showBookingPopup: false,
selectedSlot: null,
currentWeekStart: new Date()
}
},
computed: {
currentMonth() {
const date = new Date(this.selectedDate || new Date())
return `${date.getFullYear()}${date.getMonth() + 1}`
},
availableBookings() {
// 根据业务规则计算可预约课时数
const remaining = this.studentInfo.remaining_courses || 0
const pending = this.myBookings.filter(b => b.status === 'pending').length
return Math.max(0, remaining - pending)
}
},
onLoad(options) {
this.studentId = parseInt(options.student_id) || 0
if (this.studentId) {
this.initPage()
} else {
uni.showToast({
title: '参数错误',
icon: 'none'
})
}
},
methods: {
goBack() {
uni.navigateBack()
},
async initPage() {
await this.loadStudentInfo()
this.generateDateList()
await this.loadMyBookings()
// 默认选择今天
const today = new Date()
const todayStr = this.formatDateString(today)
this.selectedDate = todayStr
await this.loadTimeSlots()
},
async loadStudentInfo() {
try {
// 调用真实API获取学员信息
const response = await apiRoute.getStudentSummary(this.studentId)
if (response.code === 1) {
this.studentInfo = response.data
} else {
console.error('获取学员信息失败:', response)
}
} catch (error) {
console.error('获取学员信息失败:', error)
}
},
generateDateList() {
const dates = []
const startDate = new Date(this.currentWeekStart)
for (let i = 0; i < 7; i++) {
const date = new Date(startDate)
date.setDate(startDate.getDate() + i)
const dateStr = this.formatDateString(date)
const today = new Date()
const isDisabled = date < today
dates.push({
date: dateStr,
day: date.getDate(),
weekday: this.getWeekday(date.getDay()),
disabled: isDisabled,
hasBooking: this.myBookings.some(b => b.booking_date === dateStr)
})
}
this.dateList = dates
},
prevWeek() {
this.currentWeekStart.setDate(this.currentWeekStart.getDate() - 7)
this.generateDateList()
},
nextWeek() {
this.currentWeekStart.setDate(this.currentWeekStart.getDate() + 7)
this.generateDateList()
},
selectDate(dateObj) {
if (dateObj.disabled) {
uni.showToast({
title: '不能选择过去的日期',
icon: 'none'
})
return
}
this.selectedDate = dateObj.date
this.loadTimeSlots()
},
async loadTimeSlots() {
if (!this.selectedDate) return
this.loading = true
try {
console.log('加载时段:', this.selectedDate)
// 调用真实API
const response = await apiRoute.getAvailableCourses({
student_id: this.studentId,
date: this.selectedDate
})
if (response.code === 1) {
// 处理响应数据
this.timeSlots = response.data.list.map(course => ({
id: course.id,
start_time: course.start_time,
end_time: course.end_time,
duration: course.duration || 60,
coach_name: course.coach_name,
course_name: course.course_name,
course_type: course.course_type || course.course_name,
venue_name: course.venue_name,
status: course.booking_status,
max_students: course.max_students,
current_students: course.current_students
}))
console.log('时段数据加载成功:', this.timeSlots)
} else {
uni.showToast({
title: response.msg || '获取时段失败',
icon: 'none'
})
}
} catch (error) {
console.error('获取时段失败:', error)
uni.showToast({
title: '获取时段失败',
icon: 'none'
})
} finally {
this.loading = false
}
},
async loadMyBookings() {
try {
console.log('加载我的预约, studentId:', this.studentId)
// 验证studentId
if (!this.studentId || this.studentId <= 0) {
console.error('无效的studentId:', this.studentId)
uni.showToast({
title: '学员ID无效',
icon: 'none'
})
return
}
// 调用真实API
const response = await apiRoute.getMyBookingList({
student_id: this.studentId
})
if (response.code === 1) {
// 处理响应数据
this.myBookings = response.data.list.map(booking => ({
id: booking.id,
booking_date: booking.booking_date,
start_time: booking.start_time,
end_time: booking.end_time,
coach_name: booking.coach_name,
course_type: booking.course_type,
venue_name: booking.venue_name,
status: this.mapBookingStatus(booking.status),
time_slot_id: booking.schedule_id
}))
console.log('我的预约加载成功:', this.myBookings)
}
} catch (error) {
console.error('获取我的预约失败:', error)
}
},
selectTimeSlot(slot) {
if (slot.status !== 'available') {
if (slot.status === 'booked') {
uni.showToast({
title: '您已预约此时段',
icon: 'none'
})
} else if (slot.status === 'full') {
uni.showToast({
title: '此时段已满员',
icon: 'none'
})
} else if (slot.status === 'closed') {
uni.showToast({
title: '此时段已结束',
icon: 'none'
})
}
return
}
if (this.availableBookings <= 0) {
uni.showToast({
title: '剩余课时不足',
icon: 'none'
})
return
}
this.selectedSlot = slot
this.showBookingPopup = true
},
closeBookingPopup() {
this.showBookingPopup = false
this.selectedSlot = null
},
async confirmBooking() {
if (!this.selectedSlot || this.booking) return
this.booking = true
try {
console.log('确认预约:', {
student_id: this.studentId,
schedule_id: this.selectedSlot.id,
booking_date: this.selectedDate,
time_slot: `${this.selectedSlot.start_time}-${this.selectedSlot.end_time}`
})
// 调用真实API
const response = await apiRoute.createBooking({
student_id: this.studentId,
schedule_id: this.selectedSlot.id,
booking_date: this.selectedDate,
time_slot: `${this.selectedSlot.start_time}-${this.selectedSlot.end_time}`
})
if (response.code === 1) {
uni.showToast({
title: '预约成功',
icon: 'success'
})
this.closeBookingPopup()
// 重新加载数据
await Promise.all([
this.loadMyBookings(),
this.loadTimeSlots()
])
} else {
uni.showToast({
title: response.msg || '预约失败',
icon: 'none'
})
}
} catch (error) {
console.error('预约失败:', error)
uni.showToast({
title: '预约失败',
icon: 'none'
})
} finally {
this.booking = false
}
},
async cancelBooking(booking) {
uni.showModal({
title: '确认取消',
content: '确定要取消此预约吗?',
success: async (res) => {
if (res.confirm) {
try {
console.log('取消预约:', booking.id)
// 调用真实API
const response = await apiRoute.cancelBooking({
booking_id: booking.id,
cancel_reason: '用户主动取消'
})
if (response.code === 1) {
uni.showToast({
title: '取消成功',
icon: 'success'
})
// 重新加载数据
await Promise.all([
this.loadMyBookings(),
this.loadTimeSlots()
])
} else {
uni.showToast({
title: response.msg || '取消失败',
icon: 'none'
})
}
} catch (error) {
console.error('取消预约失败:', error)
uni.showToast({
title: '取消失败',
icon: 'none'
})
}
}
}
})
},
// 工具方法
formatDateString(date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
},
formatDate(dateString) {
const date = new Date(dateString)
const month = date.getMonth() + 1
const day = date.getDate()
const weekday = this.getWeekday(date.getDay())
return `${month}${day}${weekday}`
},
formatBookingDate(dateString) {
const date = new Date(dateString)
const month = date.getMonth() + 1
const day = date.getDate()
const weekday = this.getWeekday(date.getDay())
return `${month}${day}${weekday}`
},
getWeekday(dayIndex) {
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
return weekdays[dayIndex]
},
getBookingStatusText(status) {
const statusMap = {
'pending': '待上课',
'completed': '已完成',
'cancelled': '已取消'
}
return statusMap[status] || status
},
// 将后端状态码映射为前端状态
mapBookingStatus(status) {
const statusMap = {
0: 'pending', // 待上课
1: 'completed', // 已完成
2: 'leave', // 请假
3: 'cancelled' // 已取消
}
return statusMap[status] || 'pending'
}
}
}
</script>
<style lang="less" scoped>
.main_box {
background: #f8f9fa;
min-height: 100vh;
}
// 自定义导航栏
.navbar_section {
display: flex;
justify-content: space-between;
align-items: center;
background: #29D3B4;
padding: 40rpx 32rpx 20rpx;
// 小程序端适配状态栏
// #ifdef MP-WEIXIN
padding-top: 80rpx;
// #endif
.navbar_back {
width: 60rpx;
.back_icon {
color: #fff;
font-size: 40rpx;
font-weight: 600;
}
}
.navbar_title {
color: #fff;
font-size: 32rpx;
font-weight: 600;
}
.navbar_action {
width: 60rpx;
}
}
// 学员信息
.student_info_section {
background: #fff;
padding: 24rpx 32rpx;
border-bottom: 1px solid #f0f0f0;
.student_name {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 12rpx;
}
.course_info {
display: flex;
gap: 24rpx;
.info_item {
font-size: 24rpx;
color: #666;
}
}
}
// 日期选择器
.date_selector_section {
background: #fff;
margin: 20rpx;
border-radius: 16rpx;
padding: 24rpx 32rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
.date_header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
.month_info {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.date_controls {
display: flex;
gap: 16rpx;
.control_button {
width: 40rpx;
height: 40rpx;
background: #f8f9fa;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: #666;
}
}
}
.date_tabs {
display: flex;
justify-content: space-between;
.date_tab {
flex: 1;
text-align: center;
padding: 16rpx 8rpx;
border-radius: 12rpx;
position: relative;
&.active {
background: #29D3B4;
color: #fff;
}
&.disabled {
opacity: 0.4;
}
.tab_weekday {
font-size: 22rpx;
margin-bottom: 4rpx;
}
.tab_date {
font-size: 28rpx;
font-weight: 600;
}
.tab_dot {
position: absolute;
top: 8rpx;
right: 12rpx;
width: 8rpx;
height: 8rpx;
background: #ff4757;
border-radius: 50%;
}
}
}
}
// 时段列表
.time_slots_section {
margin: 0 20rpx 20rpx;
.section_title {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
padding-left: 16rpx;
}
.loading_section, .empty_section {
background: #fff;
border-radius: 16rpx;
padding: 60rpx 32rpx;
text-align: center;
.loading_text, .empty_text {
font-size: 28rpx;
color: #666;
margin-bottom: 12rpx;
}
.empty_icon {
font-size: 60rpx;
margin-bottom: 16rpx;
}
.empty_hint {
font-size: 24rpx;
color: #999;
}
}
.time_slots_list {
.time_slot_item {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
display: flex;
align-items: center;
gap: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
&.available {
border-left: 4rpx solid #29D3B4;
}
&.booked {
background: #f8f9fa;
border-left: 4rpx solid #3498db;
}
&.full {
background: #f8f9fa;
border-left: 4rpx solid #f39c12;
}
&.closed {
background: #f8f9fa;
border-left: 4rpx solid #95a5a6;
opacity: 0.6;
}
.slot_time {
min-width: 120rpx;
.time_range {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 4rpx;
}
.time_duration {
font-size: 22rpx;
color: #999;
}
}
.slot_info {
flex: 1;
.coach_name {
font-size: 26rpx;
color: #333;
margin-bottom: 6rpx;
}
.course_type {
font-size: 24rpx;
color: #666;
margin-bottom: 4rpx;
}
.venue_info {
font-size: 22rpx;
color: #999;
}
}
.slot_status {
.status_text {
font-size: 22rpx;
padding: 6rpx 12rpx;
border-radius: 12rpx;
&.available {
color: #27ae60;
background: rgba(39, 174, 96, 0.1);
}
&.booked {
color: #3498db;
background: rgba(52, 152, 219, 0.1);
}
&.full {
color: #f39c12;
background: rgba(243, 156, 18, 0.1);
}
&.closed {
color: #95a5a6;
background: rgba(149, 165, 166, 0.1);
}
}
}
}
}
}
// 我的预约
.my_bookings_section {
margin: 0 20rpx 20rpx;
.section_title {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
padding-left: 16rpx;
}
.empty_bookings {
background: #fff;
border-radius: 16rpx;
padding: 40rpx;
text-align: center;
.empty_text {
font-size: 26rpx;
color: #999;
}
}
.bookings_list {
.booking_item {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
.booking_header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
.booking_date {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.booking_status {
font-size: 22rpx;
padding: 6rpx 12rpx;
border-radius: 12rpx;
&.pending {
color: #f39c12;
background: rgba(243, 156, 18, 0.1);
}
&.completed {
color: #27ae60;
background: rgba(39, 174, 96, 0.1);
}
&.cancelled {
color: #e74c3c;
background: rgba(231, 76, 60, 0.1);
}
}
}
.booking_details {
.detail_row {
display: flex;
margin-bottom: 8rpx;
.detail_label {
font-size: 24rpx;
color: #666;
min-width: 80rpx;
}
.detail_value {
font-size: 24rpx;
color: #333;
}
}
}
.booking_actions {
margin-top: 16rpx;
text-align: right;
}
}
}
}
// 预约弹窗
.booking_popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
.popup_content {
background: #fff;
border-radius: 16rpx;
width: 85%;
max-height: 70vh;
overflow: hidden;
.popup_header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
border-bottom: 1px solid #f0f0f0;
.popup_title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.popup_close {
font-size: 48rpx;
color: #999;
font-weight: 300;
}
}
.popup_booking_info {
padding: 32rpx;
.info_row {
display: flex;
margin-bottom: 16rpx;
.info_label {
font-size: 26rpx;
color: #666;
min-width: 100rpx;
}
.info_value {
font-size: 26rpx;
color: #333;
font-weight: 500;
}
}
}
.popup_actions {
padding: 24rpx 32rpx;
display: flex;
gap: 16rpx;
fui-button {
flex: 1;
}
}
}
}
</style>