Files

313 lines
12 KiB
Vue

<template>
<view class="my-courses-page">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<text class="nav-title">&#8981; 我的课程</text>
</view>
</view>
<scroll-view class="content" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px)' }">
<view class="content-inner">
<view v-if="loading" class="loading-state">
<text>加载中...</text>
</view>
<view v-for="(item, idx) in bookings" :key="idx" class="booking-card">
<view class="booking-header">
<text class="booking-name">{{ item.courseName }}</text>
<view class="booking-badges">
<text class="booking-checkin" :class="item.checkedIn ? 'checkin-done' : 'checkin-pending'">
{{ item.checkedIn ? '&#10003; 已签到' : '&#9711; 未签到' }}
</text>
<text class="booking-status" :class="'status-' + item.statusValue">{{ item.statusLabel }}</text>
</view>
</view>
<view class="booking-info">
<view class="booking-row">
<text class="booking-label">日期</text>
<text class="booking-value">{{ item.fullDate }}</text>
</view>
<view class="booking-row">
<text class="booking-label">时间</text>
<text class="booking-value">{{ item.shortTime }} - {{ item.endShortTime }}</text>
</view>
<view class="booking-row" v-if="item.location">
<text class="booking-label">地点</text>
<text class="booking-value">{{ item.location }}</text>
</view>
<view class="booking-row">
<text class="booking-label">预约时间</text>
<text class="booking-value">{{ item.bookingTime }}</text>
</view>
</view>
<view class="booking-actions" v-if="item.canCancel || item.statusValue === 0">
<button class="signin-btn" @click="handleScanSignIn(item)" :disabled="signing === item.id">
{{ signing === item.id ? '签到中...' : '扫码签到' }}
</button>
<button v-if="item.canCancel" class="cancel-btn" @click="handleCancel(item)" :disabled="cancelling">
{{ cancelling === item.id ? '取消中...' : '取消预约' }}
</button>
</view>
</view>
<view v-if="!loading && bookings.length === 0" class="empty-state">
<text class="empty-symbol">&#8857;</text>
<text class="empty-text">暂无预约课程</text>
<text class="empty-sub" @click="goToSearch">前往预约团课 &#8250;</text>
</view>
<view class="bottom-safe"></view>
</view>
</scroll-view>
</view>
</template>
<script>
const bookingApi = require('../../api/booking')
const store = require('../../store/index')
export default {
data() {
return {
statusBarHeight: 0,
navBarHeight: 44,
totalHeaderHeight: 44,
loading: true,
cancelling: null,
signing: null,
bookings: []
}
},
onLoad() {
const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 20
let navBarHeight = 44
// #ifdef MP-WEIXIN
const menuButton = uni.getMenuButtonBoundingClientRect()
if (menuButton) {
navBarHeight = (menuButton.top - statusBarHeight) * 2 + menuButton.height
}
// #endif
this.statusBarHeight = statusBarHeight
this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight
},
onShow() {
if (!store.isLoggedIn) return
this.loadBookings()
},
onPullDownRefresh() {
this.loadBookings().finally(() => { uni.stopPullDownRefresh() })
},
methods: {
goToSearch() { uni.switchTab({ url: '/pages/search/search' }) },
async loadBookings() {
this.loading = true
try {
const memberInfo = store.getMemberInfo()
const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null
if (!memberId) {
this.bookings = []
this.loading = false
return
}
const list = await bookingApi.getMemberBookings(memberId)
const arr = Array.isArray(list) ? list : (list && list.content ? list.content : [])
this.bookings = arr.map(item => {
const statusVal = item.status != null ? Number(item.status) : 0
// booking status: 0=已预约, 1=已取消, 2=已出席, 3=缺席
let statusLabel = '已预约'
if (statusVal === 1) statusLabel = '已取消'
else if (statusVal === 2) statusLabel = '已出席'
else if (statusVal === 3) statusLabel = '缺席'
const startTime = item.courseStartTime || ''
const endTime = item.courseEndTime || ''
const dt = startTime ? new Date(startTime.replace(/ /, 'T')) : null
const edt = endTime ? new Date(endTime.replace(/ /, 'T')) : null
const y = dt && !isNaN(dt) ? String(dt.getFullYear()) : ''
const mo = dt && !isNaN(dt) ? String(dt.getMonth() + 1).padStart(2, '0') : ''
const d = dt && !isNaN(dt) ? String(dt.getDate()).padStart(2, '0') : ''
const hh = dt && !isNaN(dt) ? String(dt.getHours()).padStart(2, '0') : '--'
const mm = dt && !isNaN(dt) ? String(dt.getMinutes()).padStart(2, '0') : '--'
const ehh = edt && !isNaN(edt) ? String(edt.getHours()).padStart(2, '0') : '--'
const emm = edt && !isNaN(edt) ? String(edt.getMinutes()).padStart(2, '0') : '--'
// 格式化预约时间
const bt = item.bookingTime || ''
const bdt = bt ? new Date(bt.replace(/ /, 'T')) : null
let bookingTimeStr = ''
if (bdt && !isNaN(bdt)) {
bookingTimeStr = String(bdt.getFullYear()) + '-' +
String(bdt.getMonth() + 1).padStart(2, '0') + '-' +
String(bdt.getDate()).padStart(2, '0') + ' ' +
String(bdt.getHours()).padStart(2, '0') + ':' +
String(bdt.getMinutes()).padStart(2, '0')
}
return {
id: item.id,
courseId: item.courseId,
courseName: item.courseName || '未命名课程',
location: item.location || '',
fullDate: y + '-' + mo + '-' + d,
shortTime: hh + ':' + mm,
endShortTime: ehh + ':' + emm,
bookingTime: bookingTimeStr,
statusValue: statusVal,
statusLabel: statusLabel,
checkedIn: statusVal === 2,
canCancel: statusVal === 0
}
})
} catch (e) {
console.error('加载预约列表失败:', e)
} finally {
this.loading = false
}
},
async handleCancel(item) {
if (this.cancelling) return
uni.showModal({
title: '取消预约',
content: '确定要取消「' + item.courseName + '」的预约吗?',
success: async (res) => {
if (!res.confirm) return
this.cancelling = item.id
try {
const memberInfo = store.getMemberInfo()
const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null
if (!memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
await bookingApi.cancelBooking(item.id, memberId)
await this.loadBookings()
uni.showToast({ title: '已取消', icon: 'success' })
} catch (e) {
console.error('取消预约失败:', e)
const msg = (e.data && e.data.message) || '取消失败,请重试'
uni.showToast({ title: msg, icon: 'none' })
} finally {
this.cancelling = null
}
}
})
},
async handleScanSignIn(item) {
if (this.signing) return
// #ifdef MP-WEIXIN
uni.scanCode({
onlyFromCamera: true,
scanType: ['qrCode'],
success: async (scanRes) => {
const scanResult = scanRes.result || ''
let scannedCourseId = null
// 尝试解析 QR 码内容:可能是 JSON 或纯数字
try {
const parsed = JSON.parse(scanResult)
scannedCourseId = parsed.courseId || parsed.id
} catch {
// 非 JSON,尝试提取纯数字
const match = scanResult.match(/\d+/)
scannedCourseId = match ? match[0] : null
}
if (!scannedCourseId) {
uni.showToast({ title: '无效的签到二维码', icon: 'none' })
return
}
if (Number(scannedCourseId) !== Number(item.courseId)) {
uni.showToast({ title: '二维码与课程不匹配', icon: 'none' })
return
}
await this.doSignIn(item, scannedCourseId)
},
fail: (err) => {
if (err.errMsg && err.errMsg.includes('cancel')) return
console.error('扫码失败:', err)
uni.showToast({ title: '扫码失败,请重试', icon: 'none' })
}
})
// #endif
// #ifndef MP-WEIXIN
uni.showToast({ title: '请在微信小程序中使用扫码签到', icon: 'none' })
// #endif
},
async doSignIn(item, scannedCourseId) {
this.signing = item.id
try {
const memberInfo = store.getMemberInfo()
const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null
if (!memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
await bookingApi.signIn(Number(scannedCourseId), memberId)
await this.loadBookings()
uni.showToast({ title: '签到成功', icon: 'success' })
} catch (e) {
console.error('签到失败:', e)
const msg = (e.data && e.data.message) || '签到失败,请重试'
uni.showToast({ title: msg, icon: 'none' })
} finally {
this.signing = null
}
}
}
}
</script>
<style scoped>
.my-courses-page { min-height: 100vh; background: #F5F7FA; overflow-x: hidden; }
.header-wrap { background: #1A1A1A; }
.nav-bar { background: #1A1A1A; padding: 0 20px; display: flex; align-items: center; }
.nav-title { font-size: 18px; font-weight: 700; color: #FFFFFF; }
.content-inner { padding: 16px; }
.loading-state { text-align: center; padding: 40px 0; color: #7A7E84; font-size: 14px; }
/* 预约卡片 */
.booking-card { background: #FFFFFF; border-radius: 16px; padding: 16px; margin-bottom: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.booking-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.booking-name { font-size: 16px; font-weight: 700; color: #1E1E1E; flex: 1; margin-right: 8px; }
.booking-badges { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; }
.booking-checkin { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; white-space: nowrap; }
.checkin-pending { background: rgba(255,165,2,0.12); color: #E6A000; }
.checkin-done { background: rgba(0,230,118,0.12); color: #00C853; }
.booking-status { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; white-space: nowrap; }
.status-0 { background: rgba(0,230,118,0.15); color: #00C853; }
.status-1 { background: rgba(158,158,158,0.15); color: #9E9E9E; }
.status-2 { background: rgba(24,144,255,0.15); color: #1890FF; }
.status-3 { background: rgba(255,82,82,0.15); color: #FF5252; }
.booking-info { margin-bottom: 8px; }
.booking-row { display: flex; justify-content: space-between; padding: 4px 0; }
.booking-label { font-size: 13px; color: #7A7E84; }
.booking-value { font-size: 13px; color: #1E1E1E; font-weight: 500; }
.booking-actions { display: flex; justify-content: flex-end; gap: 10px; padding-top: 8px; border-top: 1px solid #F0F0F0; }
.signin-btn { background: transparent; border: 1px solid #00C853; color: #00C853; font-size: 13px; padding: 6px 16px; border-radius: 20px; line-height: 1.4; }
.signin-btn::after { border: none; }
.signin-btn[disabled] { opacity: 0.5; }
.cancel-btn { background: transparent; border: 1px solid #FF5252; color: #FF5252; font-size: 13px; padding: 6px 16px; border-radius: 20px; line-height: 1.4; }
.cancel-btn::after { border: none; }
.cancel-btn[disabled] { opacity: 0.5; }
/* 空状态 */
.empty-state { text-align: center; padding: 60px 0; }
.empty-symbol { font-size: 48px; color: #E0E0E0; display: block; margin-bottom: 16px; }
.empty-text { font-size: 16px; color: #BDBDBD; display: block; margin-bottom: 12px; }
.empty-sub { font-size: 14px; color: #00C853; }
.bottom-safe { height: 20px; }
</style>