442 lines
14 KiB
Vue
442 lines
14 KiB
Vue
<template>
|
||
<view class="scroll-container theme-light">
|
||
<view class="bt-page">
|
||
<PageHeader title="我的课程" subtitle="" :show-back="true">
|
||
<template #right>
|
||
<view class="header-scan-btn" @click="handleScan">
|
||
<text class="header-scan-icon">📷</text>
|
||
</view>
|
||
</template>
|
||
</PageHeader>
|
||
<view class="bt-page__body">
|
||
<!-- 子标签:进行中 / 已完成 -->
|
||
<view class="mi-mod-subtabs">
|
||
<text
|
||
class="mi-mod-subtab"
|
||
:class="{ 'mi-mod-subtab--on': activeTab === 'ongoing' }"
|
||
@tap="activeTab = 'ongoing'"
|
||
>进行中</text>
|
||
<text
|
||
class="mi-mod-subtab"
|
||
:class="{ 'mi-mod-subtab--on': activeTab === 'completed' }"
|
||
@tap="activeTab = 'completed'"
|
||
>已完成</text>
|
||
</view>
|
||
|
||
<!-- 骨架屏 -->
|
||
<ListSkeleton v-if="loading" :count="4" layout="card" />
|
||
|
||
<!-- 预约列表 -->
|
||
<view
|
||
v-for="item in displayedBookings"
|
||
:key="item.id"
|
||
class="mi-mod-course-card"
|
||
hover-class="mi-tap-card--hover"
|
||
>
|
||
<image
|
||
class="mi-mod-course-card__banner"
|
||
:src="item.coverImage"
|
||
mode="aspectFill"
|
||
/>
|
||
<view class="mi-mod-course-card__content">
|
||
<view class="mi-mod-course-card__header">
|
||
<text class="mi-mod-course-card__title">{{ item.title }}</text>
|
||
<text class="mi-mod-course-card__status" :class="'mi-mod-course-card__status--' + item.statusClass">{{ item.statusLabel }}</text>
|
||
</view>
|
||
<text class="mi-mod-course-card__meta">{{ item.schedule }} · {{ item.location }}</text>
|
||
<!-- 仅已预约状态显示取消按钮 -->
|
||
<view v-if="activeTab === 'ongoing' && item.statusClass === 'booked'" class="mi-mod-course-card__cancel" @tap.stop="handleCancel(item)">
|
||
<text>取消预约</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 无数据 -->
|
||
<view v-if="!displayedBookings.length" class="bt-empty">
|
||
<text class="bt-empty__text">
|
||
{{ activeTab === 'ongoing' ? '暂无进行中的预约' : '暂无历史预约' }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 支付密码弹窗 -->
|
||
<PayPasswordModal
|
||
:visible="showPayPwd"
|
||
:title="payPwdTitle"
|
||
:subtitle="payPwdSubtitle"
|
||
:cancel-notice="payPwdNotice"
|
||
@confirm="onPayPasswordConfirm"
|
||
@cancel="onPayPasswordCancel"
|
||
/>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import PageHeader from '@/components/index/PageHeader.vue'
|
||
import ListSkeleton from '@/components/Skeleton/ListSkeleton.vue'
|
||
import PayPasswordModal from '@/components/global/PayPasswordModal.vue'
|
||
import { getMemberId } from '@/utils/request.js'
|
||
import { getCourseCoverUrl } from '@/utils/request.js'
|
||
import { getMemberBookings, qrSignInGroupCourse } from '@/api/groupCourse.js'
|
||
import { cancelBooking as cancelBookingApi } from '@/api/groupCourse.js'
|
||
import { getConfigByKey } from '@/api/main.js'
|
||
|
||
const activeTab = ref('ongoing')
|
||
const ongoingBookings = ref([])
|
||
const completedBookings = ref([])
|
||
const loading = ref(false)
|
||
|
||
// 支付密码弹窗状态
|
||
const showPayPwd = ref(false)
|
||
const payPwdTitle = ref('')
|
||
const payPwdSubtitle = ref('')
|
||
const payPwdNotice = ref('')
|
||
let payPwdResolve = null
|
||
let cancelTarget = null
|
||
|
||
// 已取消次数
|
||
const cancelCount = ref(0)
|
||
const freeCancelLimit = ref(3) // 默认值,从API获取
|
||
const freeCampaignInitialized = ref(false)
|
||
const freeCancelsLeft = computed(() => Math.max(0, freeCancelLimit.value - cancelCount.value))
|
||
|
||
async function fetchFreeLimit() {
|
||
try {
|
||
const res = await getConfigByKey('group_course.cancel_free_limit')
|
||
const value = res?.configValue || res?.data?.configValue || '3'
|
||
freeCancelLimit.value = parseInt(value) || 3
|
||
freeCampaignInitialized.value = true
|
||
console.log('[myCourses] 免费取消次数阈值:', freeCancelLimit.value, '已取消次数:', cancelCount.value, '剩余:', freeCancelsLeft.value)
|
||
} catch (e) {
|
||
console.warn('[myCourses] 获取免费取消次数配置失败,使用默认值3:', e)
|
||
freeCampaignInitialized.value = true
|
||
}
|
||
}
|
||
|
||
const displayedBookings = computed(() => {
|
||
return activeTab.value === 'ongoing'
|
||
? ongoingBookings.value
|
||
: completedBookings.value
|
||
})
|
||
|
||
async function fetchBookings() {
|
||
if (loading.value) return
|
||
loading.value = true
|
||
console.log('[myCourses] 开始获取预约记录')
|
||
|
||
const memberId = getMemberId()
|
||
if (!memberId) {
|
||
console.warn('[myCourses] 未登录')
|
||
loading.value = false
|
||
return
|
||
}
|
||
|
||
try {
|
||
const res = await getMemberBookings(memberId)
|
||
const bookings = Array.isArray(res) ? res : (res?.data || res?.content || [])
|
||
console.log('[myCourses] 预约记录:', bookings.length, '条', JSON.stringify(bookings))
|
||
|
||
if (Array.isArray(bookings)) {
|
||
// 统计已取消次数(status='1')
|
||
cancelCount.value = bookings.filter(b => String(b.status) === '1').length
|
||
const mapped = bookings.map(mapBooking)
|
||
ongoingBookings.value = mapped.filter(b => b.status === 'ongoing')
|
||
completedBookings.value = mapped.filter(b => b.status === 'completed')
|
||
} else {
|
||
ongoingBookings.value = []
|
||
completedBookings.value = []
|
||
}
|
||
} catch (e) {
|
||
console.error('[myCourses] 获取预约记录失败:', e)
|
||
ongoingBookings.value = []
|
||
completedBookings.value = []
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function mapBooking(b) {
|
||
const courseStartTime = b.courseStartTime || b.startTime || ''
|
||
const bookStatus = String(b.status || '0')
|
||
|
||
// status: 0-已预约,1-已取消,2-已出席,3-缺席
|
||
let status = 'ongoing'
|
||
let statusLabel = '已预约'
|
||
let statusClass = 'booked'
|
||
|
||
if (bookStatus === '1') {
|
||
status = 'completed'
|
||
statusLabel = '已取消'
|
||
statusClass = 'cancelled'
|
||
} else if (bookStatus === '2') {
|
||
// 已签到保留在进行中,方便用户查看签到结果
|
||
status = 'ongoing'
|
||
statusLabel = '已签到'
|
||
statusClass = 'attended'
|
||
} else if (bookStatus === '3') {
|
||
status = 'ongoing'
|
||
statusLabel = '缺席'
|
||
statusClass = 'absent'
|
||
} else if (bookStatus === '0') {
|
||
status = 'ongoing'
|
||
statusLabel = '已预约'
|
||
statusClass = 'booked'
|
||
}
|
||
|
||
return {
|
||
id: b.id,
|
||
courseId: b.courseId,
|
||
title: b.courseName || '预约课程',
|
||
status: status,
|
||
originalStatus: bookStatus,
|
||
statusLabel: statusLabel,
|
||
statusClass: statusClass,
|
||
schedule: formatBookingTime(courseStartTime),
|
||
location: b.location || '',
|
||
coverImage: getCourseCoverUrl(b.coverImage) || 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/default-course.png'
|
||
}
|
||
}
|
||
|
||
function formatBookingTime(timeStr) {
|
||
if (!timeStr) return ''
|
||
try {
|
||
const d = new Date(timeStr)
|
||
const month = d.getMonth() + 1
|
||
const day = d.getDate()
|
||
const hour = d.getHours().toString().padStart(2, '0')
|
||
const minute = d.getMinutes().toString().padStart(2, '0')
|
||
return `${month}月${day}日 ${hour}:${minute}`
|
||
} catch {
|
||
return timeStr
|
||
}
|
||
}
|
||
|
||
async function handleCancel(item) {
|
||
uni.showModal({
|
||
title: '取消预约',
|
||
content: `确定要取消「${item.title}」吗?`,
|
||
success: async (res) => {
|
||
if (!res.confirm) return
|
||
|
||
// 弹出支付密码输入
|
||
cancelTarget = item
|
||
payPwdTitle.value = '取消验证'
|
||
payPwdSubtitle.value = item.title
|
||
// 实时获取最新免费次数阈值
|
||
await fetchFreeLimit()
|
||
// 设置退款提示
|
||
if (freeCancelsLeft.value > 0) {
|
||
payPwdNotice.value = `您还有 ${freeCancelsLeft.value} 次免手续费退款机会(超出后将扣除10%手续费)`
|
||
} else {
|
||
payPwdNotice.value = '本次取消将扣除10%手续费'
|
||
}
|
||
console.log('[myCourses] 取消弹窗: payPwdNotice=', payPwdNotice.value, 'freeCancelsLeft=', freeCancelsLeft.value, 'cancelCount=', cancelCount.value, 'freeLimit=', freeCancelLimit.value)
|
||
const payPassword = await requestPayPassword()
|
||
if (!payPassword) return
|
||
|
||
uni.showLoading({ title: '取消中...' })
|
||
try {
|
||
const result = await cancelBookingApi(Number(item.id), {
|
||
memberId: getMemberId(),
|
||
payPassword: payPassword
|
||
})
|
||
uni.hideLoading()
|
||
if (result.success) {
|
||
uni.showToast({ title: '已取消', icon: 'success' })
|
||
fetchBookings()
|
||
} else {
|
||
uni.showToast({ title: result.message || '取消失败', icon: 'none', duration: 3000 })
|
||
}
|
||
} catch (e) {
|
||
uni.hideLoading()
|
||
console.error('[myCourses] 取消预约失败:', e)
|
||
const errMsg = e?.message || '取消失败'
|
||
uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
function requestPayPassword() {
|
||
return new Promise((resolve) => {
|
||
payPwdResolve = resolve
|
||
showPayPwd.value = true
|
||
})
|
||
}
|
||
|
||
function onPayPasswordConfirm(password) {
|
||
payPwdResolve?.(password)
|
||
showPayPwd.value = false
|
||
}
|
||
|
||
function onPayPasswordCancel() {
|
||
payPwdResolve?.(null)
|
||
showPayPwd.value = false
|
||
}
|
||
|
||
// ===== 扫码签到 =====
|
||
function handleScan() {
|
||
uni.scanCode({
|
||
success: async (res) => {
|
||
const qrContent = res.result
|
||
console.log('[myCourses] 扫码结果:', qrContent)
|
||
|
||
// 解析二维码内容获取 courseId
|
||
let courseId = null
|
||
try {
|
||
const parsed = JSON.parse(qrContent)
|
||
courseId = Number(parsed.courseId)
|
||
} catch {
|
||
const num = Number(qrContent)
|
||
if (!isNaN(num)) {
|
||
courseId = num
|
||
}
|
||
}
|
||
|
||
if (!courseId) {
|
||
uni.showToast({ title: '无效的签到二维码', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
const memberId = getMemberId()
|
||
if (!memberId) {
|
||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
uni.showLoading({ title: '签到中...' })
|
||
try {
|
||
const result = await qrSignInGroupCourse(courseId, memberId)
|
||
uni.hideLoading()
|
||
if (result.success) {
|
||
uni.showToast({ title: '签到成功', icon: 'success' })
|
||
// 签到成功后刷新列表
|
||
await fetchBookings()
|
||
} else {
|
||
uni.showToast({ title: result.message || '签到失败', icon: 'none', duration: 3000 })
|
||
}
|
||
} catch (e) {
|
||
uni.hideLoading()
|
||
console.error('[myCourses] 扫码签到失败:', e)
|
||
const errMsg = e?.message || e?.data?.message || '签到失败'
|
||
uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
|
||
}
|
||
},
|
||
fail: (err) => {
|
||
console.log('[myCourses] 扫码取消或失败:', err)
|
||
}
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchBookings()
|
||
fetchFreeLimit()
|
||
})
|
||
</script>
|
||
|
||
<style>
|
||
@import '@/common/style/base.css';
|
||
@import '@/common/style/memberInfo/pages/page-reset.css';
|
||
@import '@/common/style/memberInfo/pages/sub-page-base.css';
|
||
@import '@/common/style/memberInfo/member-info-component-reset.css';
|
||
@import '@/common/style/memberInfo/member-info-tap.css';
|
||
@import '@/common/style/memberInfo/pages/body-test-common.css';
|
||
@import '@/common/style/memberInfo/pages/module-pages-common.css';
|
||
|
||
.mi-mod-subtabs {
|
||
display: flex;
|
||
flex-direction: row;
|
||
gap: 16px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.mi-mod-subtab {
|
||
font-size: 14px;
|
||
color: var(--text-muted, #5E6F8D);
|
||
}
|
||
|
||
.mi-mod-subtab--on {
|
||
color: var(--primary-dark, #0B2B4B);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.mi-mod-course-card__cancel {
|
||
margin-top: 10px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 8rpx 24rpx;
|
||
border-radius: 16rpx;
|
||
background: rgba(255, 107, 53, 0.1);
|
||
border: 2rpx solid var(--accent-orange, #FF6B35);
|
||
}
|
||
|
||
.mi-mod-course-card__cancel text {
|
||
font-size: 24rpx;
|
||
font-weight: 700;
|
||
color: var(--accent-orange, #FF6B35);
|
||
}
|
||
|
||
/* 状态标签 */
|
||
.mi-mod-course-card__header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.mi-mod-course-card__status {
|
||
font-size: 22rpx;
|
||
font-weight: 600;
|
||
padding: 4rpx 16rpx;
|
||
border-radius: 12rpx;
|
||
flex-shrink: 0;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.mi-mod-course-card__status--booked {
|
||
background: rgba(24, 144, 255, 0.1);
|
||
color: #1890ff;
|
||
border: 2rpx solid rgba(24, 144, 255, 0.2);
|
||
}
|
||
|
||
.mi-mod-course-card__status--attended {
|
||
background: rgba(82, 196, 26, 0.1);
|
||
color: #389e0d;
|
||
border: 2rpx solid rgba(82, 196, 26, 0.2);
|
||
}
|
||
|
||
.mi-mod-course-card__status--cancelled {
|
||
background: rgba(153, 153, 153, 0.1);
|
||
color: #999;
|
||
border: 2rpx solid rgba(153, 153, 153, 0.2);
|
||
}
|
||
|
||
.mi-mod-course-card__status--absent {
|
||
background: rgba(250, 173, 20, 0.1);
|
||
color: #d48806;
|
||
border: 2rpx solid rgba(250, 173, 20, 0.2);
|
||
}
|
||
|
||
/* 头部扫码按钮 */
|
||
.header-scan-btn {
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
background: rgba(24, 144, 255, 0.1);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.header-scan-btn:active {
|
||
background: rgba(24, 144, 255, 0.2);
|
||
}
|
||
|
||
.header-scan-icon {
|
||
font-size: 32rpx;
|
||
}
|
||
</style>
|