实现用户登录→查询团课→预约团课→扫码签到→后台查看数据闭环
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
<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">⌕ 我的课程</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 ? '✓ 已签到' : '◯ 未签到' }}
|
||||
</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">⊙</text>
|
||||
<text class="empty-text">暂无预约课程</text>
|
||||
<text class="empty-sub" @click="goToSearch">前往预约团课 ›</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 {
|
||||
await bookingApi.cancelBooking(item.id)
|
||||
uni.showToast({ title: '已取消', icon: 'success' })
|
||||
this.loadBookings()
|
||||
} 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)
|
||||
uni.showToast({ title: '签到成功', icon: 'success' })
|
||||
this.loadBookings()
|
||||
} 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>
|
||||
Reference in New Issue
Block a user