实现用户登录→查询团课→预约团课→扫码签到→后台查看数据闭环
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
@@ -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) || '预约失败,请重试'
|
||||
|
||||
@@ -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">⌕ 我的课程</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>
|
||||
@@ -164,6 +164,9 @@
|
||||
if (!store.isLoggedIn) return
|
||||
this.loadData()
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.loadData().finally(() => { uni.stopPullDownRefresh() })
|
||||
},
|
||||
methods: {
|
||||
async loadData() {
|
||||
this.loading = true
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user