Files
gym-manage/gym-manage-uniapp/pages/memberInfo/myCourses.vue
T

442 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>