智慧教务系统
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.
 
 
 
 
 
 

1082 lines
24 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 {
// 模拟获取学员信息
const mockStudentInfo = {
id: this.studentId,
name: '小明',
remaining_courses: 24,
total_courses: 48
}
this.studentInfo = mockStudentInfo
} 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.getCourseTimeSlots({
// student_id: this.studentId,
// date: this.selectedDate
// })
// 使用模拟数据
const mockResponse = {
code: 1,
data: [
{
id: 1,
start_time: '09:00',
end_time: '10:00',
duration: 60,
coach_name: '张教练',
course_type: '基础体能训练',
venue_name: '训练馆A',
status: 'available',
max_students: 8,
current_students: 3
},
{
id: 2,
start_time: '10:30',
end_time: '11:30',
duration: 60,
coach_name: '李教练',
course_type: '少儿体适能',
venue_name: '训练馆B',
status: 'available',
max_students: 6,
current_students: 2
},
{
id: 3,
start_time: '14:00',
end_time: '15:00',
duration: 60,
coach_name: '王教练',
course_type: '专项训练',
venue_name: '训练馆A',
status: 'full',
max_students: 4,
current_students: 4
},
{
id: 4,
start_time: '16:00',
end_time: '17:00',
duration: 60,
coach_name: '张教练',
course_type: '基础体能训练',
venue_name: '训练馆A',
status: 'booked',
max_students: 8,
current_students: 5
}
]
}
if (mockResponse.code === 1) {
// 检查是否已预约
const bookedSlotIds = this.myBookings
.filter(b => b.booking_date === this.selectedDate)
.map(b => b.time_slot_id)
this.timeSlots = mockResponse.data.map(slot => {
if (bookedSlotIds.includes(slot.id)) {
slot.status = 'booked'
}
return slot
})
console.log('时段数据加载成功:', this.timeSlots)
} else {
uni.showToast({
title: mockResponse.msg || '获取时段失败',
icon: 'none'
})
}
} catch (error) {
console.error('获取时段失败:', error)
uni.showToast({
title: '获取时段失败',
icon: 'none'
})
} finally {
this.loading = false
}
},
async loadMyBookings() {
try {
console.log('加载我的预约')
// 模拟API调用
// const response = await apiRoute.getMyBookings(this.studentId)
// 使用模拟数据
const mockResponse = {
code: 1,
data: [
{
id: 1,
booking_date: this.formatDateString(new Date(Date.now() + 24 * 60 * 60 * 1000)),
start_time: '16:00',
end_time: '17:00',
coach_name: '张教练',
course_type: '基础体能训练',
venue_name: '训练馆A',
status: 'pending',
time_slot_id: 4
}
]
}
if (mockResponse.code === 1) {
this.myBookings = mockResponse.data
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,
date: this.selectedDate,
time_slot_id: this.selectedSlot.id
})
// 模拟API调用
// const response = await apiRoute.createBooking({
// student_id: this.studentId,
// date: this.selectedDate,
// time_slot_id: this.selectedSlot.id
// })
// 模拟预约
await new Promise(resolve => setTimeout(resolve, 1000))
const mockResponse = { code: 1, message: '预约成功' }
if (mockResponse.code === 1) {
uni.showToast({
title: '预约成功',
icon: 'success'
})
// 更新本地数据
const newBooking = {
id: Date.now(),
booking_date: this.selectedDate,
start_time: this.selectedSlot.start_time,
end_time: this.selectedSlot.end_time,
coach_name: this.selectedSlot.coach_name,
course_type: this.selectedSlot.course_type,
venue_name: this.selectedSlot.venue_name,
status: 'pending',
time_slot_id: this.selectedSlot.id
}
this.myBookings.push(newBooking)
this.closeBookingPopup()
// 刷新时段数据
await this.loadTimeSlots()
} else {
uni.showToast({
title: mockResponse.message || '预约失败',
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调用
await new Promise(resolve => setTimeout(resolve, 500))
const mockResponse = { code: 1, message: '取消成功' }
if (mockResponse.code === 1) {
uni.showToast({
title: '取消成功',
icon: 'success'
})
// 从列表中移除
const index = this.myBookings.findIndex(b => b.id === booking.id)
if (index !== -1) {
this.myBookings.splice(index, 1)
}
// 刷新时段数据
await this.loadTimeSlots()
} else {
uni.showToast({
title: mockResponse.message || '取消失败',
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
}
}
}
</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>