实现用户登录→查询团课→预约团课→扫码签到→后台查看数据闭环

This commit is contained in:
2026-07-17 16:36:25 +08:00
parent 922c5850b5
commit ff899f491e
8 changed files with 369 additions and 7 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

+20 -3
View File
@@ -11,14 +11,16 @@
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页",
"navigationStyle": "custom"
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/search/search",
"style": {
"navigationBarTitleText": "团课搜索",
"navigationStyle": "custom"
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
@@ -32,7 +34,16 @@
"path": "pages/profile/profile",
"style": {
"navigationBarTitleText": "个人中心",
"navigationStyle": "custom"
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/my-courses/my-courses",
"style": {
"navigationBarTitleText": "我的课程",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
}
],
@@ -60,6 +71,12 @@
"iconPath": "static/icon/jianshen.png",
"selectedIconPath": "static/icon/jianshen.png"
},
{
"pagePath": "pages/my-courses/my-courses",
"text": "我的课程",
"iconPath": "static/icon/jianshen2.png",
"selectedIconPath": "static/icon/jianshen2.png"
},
{
"pagePath": "pages/profile/profile",
"text": "我的",
@@ -294,7 +294,8 @@
async handleBooking() {
if (!this.course.canBook || this.submitting) return
const memberInfo = store.getMemberInfo()
if (!memberInfo || !memberInfo.memberId) {
const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null
if (!memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
@@ -302,10 +303,12 @@
try {
await bookingApi.bookCourse({
courseId: this.course.id,
memberId: memberInfo.memberId || memberInfo.id
memberId: memberId
})
uni.hideLoading()
uni.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => { uni.navigateBack() }, 1000)
// 跳转到「我的课程」页面
setTimeout(() => { uni.switchTab({ url: '/pages/my-courses/my-courses' }) }, 1000)
} catch (e) {
console.error('预约失败:', e)
const msg = (e.data && e.data.message) || '预约失败,请重试'
+3
View File
@@ -117,6 +117,9 @@
}
this.loadData()
},
onPullDownRefresh() {
this.loadData().finally(() => { uni.stopPullDownRefresh() })
},
methods: {
goToCourseDetail(id) { uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + id }) },
goToSearch() { uni.switchTab({ url: '/pages/search/search' }) },
@@ -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">&#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 {
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>
@@ -164,6 +164,9 @@
if (!store.isLoggedIn) return
this.loadData()
},
onPullDownRefresh() {
this.loadData().finally(() => { uni.stopPullDownRefresh() })
},
methods: {
async loadData() {
this.loading = true
+31 -1
View File
@@ -125,6 +125,8 @@
<script>
const courseApi = require('../../api/course')
const bookingApi = require('../../api/booking')
const store = require('../../store/index')
export default {
data() {
@@ -170,6 +172,10 @@
this.loadCourses()
this.loadTypes()
},
onPullDownRefresh() {
Promise.all([this.loadCourses(), this.loadTypes()])
.finally(() => { uni.stopPullDownRefresh() })
},
computed: {
// 将类型列表按每行 COLS_PER_ROW 个拆分为二维数组
typeRows() {
@@ -325,7 +331,31 @@
},
switchSort(value) { this.currentSort = value },
goToDetail(course) { uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + course.id }) },
bookCourse(course) { if (course.canBook) uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + course.id }) }
async bookCourse(course) {
if (!course.canBook) return
const memberInfo = store.getMemberInfo()
const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null
if (!memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
uni.showLoading({ title: '预约中...' })
try {
await bookingApi.bookCourse({
courseId: course.id,
memberId: memberId
})
uni.hideLoading()
uni.showToast({ title: '预约成功', icon: 'success' })
// 跳转到「我的课程」页面
setTimeout(() => { uni.switchTab({ url: '/pages/my-courses/my-courses' }) }, 1000)
} catch (e) {
uni.hideLoading()
console.error('预约失败:', e)
const msg = (e.data && e.data.message) || '预约失败,请重试'
uni.showToast({ title: msg, icon: 'none' })
}
}
}
}
</script>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB