Files
gym-manage/gym-manage-uniapp/pages/groupCourse/detail.vue
T
2026-06-05 15:35:05 +08:00

908 lines
21 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="course-detail-page">
<!-- 顶部导航 -->
<MemberInfoSubNav :title="course.courseName || '课程详情'" @back="goBack" />
<!-- 骨架屏 -->
<view v-if="loading" class="skeleton">
<view class="skeleton-header">
<view class="skeleton-cover skeleton-block"></view>
</view>
<view class="skeleton-content">
<view class="skeleton-name skeleton-block"></view>
<view class="skeleton-meta">
<view class="skeleton-meta-item skeleton-block"></view>
<view class="skeleton-meta-item skeleton-block"></view>
<view class="skeleton-meta-item skeleton-block"></view>
</view>
<view class="skeleton-card">
<view class="skeleton-card-title skeleton-block"></view>
<view class="skeleton-card-content">
<view class="skeleton-text-line skeleton-block"></view>
<view class="skeleton-text-line skeleton-block"></view>
<view class="skeleton-text-line skeleton-block short"></view>
</view>
</view>
<view class="skeleton-card">
<view class="skeleton-info-row">
<view class="skeleton-info-label skeleton-block"></view>
<view class="skeleton-info-value skeleton-block"></view>
</view>
<view class="skeleton-info-row">
<view class="skeleton-info-label skeleton-block"></view>
<view class="skeleton-info-value skeleton-block"></view>
</view>
<view class="skeleton-info-row">
<view class="skeleton-info-label skeleton-block"></view>
<view class="skeleton-info-value skeleton-block"></view>
</view>
<view class="skeleton-info-row">
<view class="skeleton-info-label skeleton-block"></view>
<view class="skeleton-payment">
<view class="skeleton-payment-item skeleton-block"></view>
<view class="skeleton-payment-item skeleton-block"></view>
</view>
</view>
</view>
<view class="skeleton-card">
<view class="skeleton-card-title skeleton-block"></view>
<view class="skeleton-notice">
<view class="skeleton-notice-item skeleton-block"></view>
<view class="skeleton-notice-item skeleton-block"></view>
<view class="skeleton-notice-item skeleton-block"></view>
<view class="skeleton-notice-item skeleton-block"></view>
</view>
</view>
</view>
</view>
<!-- 内容区域 -->
<view v-else>
<!-- 顶部图片区域 -->
<view class="header-section">
<!-- 课程封面图 -->
<view class="cover-image-wrapper">
<image
v-if="course.coverImage"
:src="course.coverImage"
mode="aspectFill"
class="cover-image"
/>
<view v-else class="cover-image-placeholder">
<uni-icons type="image" size="80" color="#CCCCCC" />
<text class="placeholder-text">暂无封面</text>
</view>
</view>
</view>
<!-- 课程基本信息 -->
<view class="basic-info-section">
<view class="course-name-wrapper">
<text class="course-name">{{ course.courseName }}</text>
<!-- 课程状态标签 -->
<view :class="['status-tag', statusClass]">
{{ statusText }}
</view>
<view :class="['course-type-badge', courseTypeClass]">
{{ courseTypeText }}
</view>
</view>
<view class="course-meta">
<view class="meta-item">
<uni-icons type="calendar" size="24" color="#8A99B4" />
<text class="meta-text">{{ formatDate(course.startTime) }}</text>
</view>
<view class="meta-item">
<uni-icons type="clock" size="24" color="#8A99B4" />
<text class="meta-text">{{ formatTime(course.startTime) }} - {{ formatTime(course.endTime) }}</text>
</view>
<view class="meta-item">
<uni-icons type="map-pin" size="24" color="#8A99B4" />
<text class="meta-text">{{ course.location }}</text>
</view>
</view>
</view>
<!-- 课程详情卡片 -->
<view class="detail-card">
<view class="card-header">
<uni-icons type="info" size="24" color="#FF6B35" />
<text class="card-title">课程详情</text>
</view>
<view class="card-content">
<text class="description-text">{{ course.description }}</text>
</view>
</view>
<!-- 课程信息卡片 -->
<view class="info-card">
<view class="info-row">
<view class="info-label">
<uni-icons type="users" size="24" color="#5E6F8D" />
<text>课程容量</text>
</view>
<text class="info-value">{{ course.currentMembers }} / {{ course.maxMembers }}</text>
</view>
<view class="info-row">
<view class="info-label">
<uni-icons type="time" size="24" color="#5E6F8D" />
<text>课程时长</text>
</view>
<text class="info-value">{{ formatDuration(course.startTime, course.endTime) }}</text>
</view>
<view class="info-row">
<view class="info-label">
<uni-icons type="star" size="24" color="#5E6F8D" />
<text>教练</text>
</view>
<text class="info-value">ID: {{ course.coachId }}</text>
</view>
<view class="info-row">
<view class="info-label">
<uni-icons type="credit-card" size="24" color="#5E6F8D" />
<text>支付方式</text>
</view>
<view class="payment-options">
<view
v-if="course.storedValueAmount > 0"
:class="['payment-item', { active: selectedPayment === 'storedValue' || (!selectedPayment && course.pointCardAmount === 0) }]"
@click="selectPayment('storedValue')"
>
<text class="payment-label">储值卡</text>
<text class="payment-value">¥{{ course.storedValueAmount }}</text>
<view v-if="selectedPayment === 'storedValue' || (!selectedPayment && course.pointCardAmount === 0)" class="payment-check">
<uni-icons type="checkmark" size="20" color="#ffffff" />
</view>
</view>
<view
v-if="course.pointCardAmount > 0"
:class="['payment-item', { active: selectedPayment === 'pointCard' || (!selectedPayment && course.storedValueAmount === 0) }]"
@click="selectPayment('pointCard')"
>
<text class="payment-label">次卡</text>
<text class="payment-value">{{ course.pointCardAmount }}</text>
<view v-if="selectedPayment === 'pointCard' || (!selectedPayment && course.storedValueAmount === 0)" class="payment-check">
<uni-icons type="checkmark" size="20" color="#ffffff" />
</view>
</view>
<view v-if="course.storedValueAmount === 0 && course.pointCardAmount === 0" class="payment-item free">
<text class="payment-value">免费</text>
</view>
</view>
</view>
</view>
<!-- 预约须知 -->
<view class="notice-card">
<view class="card-header">
<uni-icons type="alert-circle" size="24" color="#F39C12" />
<text class="card-title">预约须知</text>
</view>
<view class="notice-list">
<view class="notice-item">
<text class="notice-dot"></text>
<text>请在课程开始前30分钟到达教室</text>
</view>
<view class="notice-item">
<text class="notice-dot"></text>
<text>如需取消预约请提前2小时操作</text>
</view>
<view class="notice-item">
<text class="notice-dot"></text>
<text>课程开始后将无法取消预约</text>
</view>
<view class="notice-item">
<text class="notice-dot"></text>
<text>迟到超过15分钟将无法入场</text>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="bottom-bar">
<view class="price-display">
<text v-if="selectedPrice.type === 'storedValue'" class="price">
<text class="currency">¥</text>{{ selectedPrice.amount }}
</text>
<text v-else-if="selectedPrice.type === 'pointCard'" class="price points">
<uni-icons type="shop" size="20" color="#FF6B35" />
<text>{{ selectedPrice.amount }}</text>
</text>
<text v-else class="price free">免费</text>
</view>
<view :class="['booking-btn', { disabled: !canBook }]" @click="handleBooking">
<text>{{ canBook ? '立即预约' : (course.currentMembers >= course.maxMembers ? '已满员' : statusText) }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { groupCourseService } from '@/request_api/groupCourse.mock.js'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
// 加载状态
const loading = ref(true)
// 课程详情数据
const course = ref({
id: '',
courseName: '',
courseType: '',
startTime: '',
endTime: '',
maxMembers: 0,
currentMembers: 0,
status: '',
location: '',
coverImage: '',
description: '',
coachId: '',
pointCardAmount: 0,
storedValueAmount: 0
})
// 选中的支付方式 ('storedValue' | 'pointCard' | 'free')
const selectedPayment = ref('')
// 课程状态文本
const statusText = computed(() => {
const status = course.value.status
if (status === '0') return '进行中'
if (status === '1') return '已取消'
if (status === '2') return '已结束'
return '未知'
})
// 课程状态样式
const statusClass = computed(() => {
const status = course.value.status
if (status === '0') return 'active'
if (status === '1') return 'canceled'
if (status === '2') return 'ended'
return ''
})
// 课程类型文本
const courseTypeText = computed(() => {
const type = course.value.courseType
if (type === '1') return '瑜伽/普拉提'
if (type === '2') return '有氧训练'
if (type === '3') return '力量训练'
return '其他'
})
// 课程类型样式
const courseTypeClass = computed(() => {
const type = course.value.courseType
if (type === '1') return 'yoga'
if (type === '2') return 'cardio'
if (type === '3') return 'strength'
return ''
})
// 是否可以预约
const canBook = computed(() => {
const status = course.value.status
const isFull = course.value.currentMembers >= course.value.maxMembers
return status === '0' && !isFull
})
// 是否有多种支付方式
const hasMultiplePayment = computed(() => {
return course.value.storedValueAmount > 0 && course.value.pointCardAmount > 0
})
// 当前选中的支付方式价格显示
const selectedPrice = computed(() => {
if (selectedPayment.value === 'storedValue') {
return { type: 'storedValue', amount: course.value.storedValueAmount }
} else if (selectedPayment.value === 'pointCard') {
return { type: 'pointCard', amount: course.value.pointCardAmount }
} else if (course.value.storedValueAmount > 0 && course.value.pointCardAmount > 0) {
// 默认优先显示储值卡
return { type: 'storedValue', amount: course.value.storedValueAmount }
} else if (course.value.storedValueAmount > 0) {
return { type: 'storedValue', amount: course.value.storedValueAmount }
} else if (course.value.pointCardAmount > 0) {
return { type: 'pointCard', amount: course.value.pointCardAmount }
}
return { type: 'free', amount: 0 }
})
// 选择支付方式
const selectPayment = (type) => {
selectedPayment.value = type
}
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const weekDay = weekDays[date.getDay()]
return `${year}${month}${day}${weekDay}`
}
// 格式化时间
const formatTime = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
// 格式化时长
const formatDuration = (startStr, endStr) => {
if (!startStr || !endStr) return ''
const start = new Date(startStr)
const end = new Date(endStr)
const minutes = Math.floor((end.getTime() - start.getTime()) / 60000)
return `${minutes}分钟`
}
// 返回上一页
const goBack = () => {
uni.navigateBack()
}
// 预约处理
const handleBooking = () => {
if (!canBook.value) return
uni.showModal({
title: '确认预约',
content: `确定要预约「${course.value.courseName}」吗?`,
success: (res) => {
if (res.confirm) {
uni.showLoading({ title: '预约中...' })
groupCourseService.book({
courseId: course.value.id,
memberId: '1' // 模拟会员ID
}).then(() => {
uni.hideLoading()
uni.showToast({
title: '预约成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
}).catch(() => {
uni.hideLoading()
uni.showToast({
title: '预约失败',
icon: 'none'
})
})
}
}
})
}
// 获取课程详情
const fetchCourseDetail = async (id) => {
try {
const result = await groupCourseService.getDetail(id)
course.value = result
console.log('[detail.vue] 课程详情获取成功:', course.value)
} catch (error) {
console.error('[detail.vue] 获取课程详情失败:', error)
uni.showToast({
title: '获取课程详情失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
// 页面挂载时获取课程详情
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || {}
const courseId = options.id || '1'
console.log('[detail.vue] 页面挂载,课程ID:', courseId)
fetchCourseDetail(courseId)
})
</script>
<style lang="scss" scoped>
.course-detail-page {
min-height: 100vh;
background: #F5F7FA;
padding-bottom: calc(140rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
}
/* 顶部图片区域 */
.header-section {
width: 100%;
background: #FFFFFF;
position: relative;
}
.cover-image-wrapper {
width: 100%;
height: 480rpx;
background: #F5F7FA;
}
.cover-image {
width: 100%;
height: 100%;
}
.cover-image-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #F3F4F6 0%, #E5E7EB 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
}
.placeholder-text {
font-size: 28rpx;
color: #9CA3AF;
}
/* 课程基本信息 */
.basic-info-section {
background: #ffffff;
padding: 32rpx;
margin-top: -40rpx;
border-radius: 32rpx 32rpx 0 0;
position: relative;
z-index: 10;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
}
.course-name-wrapper {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 24rpx;
}
.course-name {
font-size: 40rpx;
font-weight: 700;
color: #1E2A3A;
flex: 1;
line-height: 1.4;
}
/* 课程基本信息区域的状态标签 */
.status-tag {
padding: 8rpx 20rpx;
border-radius: 16rpx;
font-size: 22rpx;
font-weight: 500;
&.active {
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
color: #ffffff;
}
&.canceled {
background: linear-gradient(135deg, #9CA3AF 0%, #6B7280 100%);
color: #ffffff;
}
&.ended {
background: linear-gradient(135deg, #D1D5DB 0%, #9CA3AF 100%);
color: #ffffff;
}
}
.course-type-badge {
padding: 8rpx 20rpx;
border-radius: 16rpx;
font-size: 22rpx;
font-weight: 500;
&.yoga {
background: linear-gradient(135deg, #D1FAE5 0%, #A7F3D0 100%);
color: #065F46;
}
&.cardio {
background: linear-gradient(135deg, #FEF3C7 0%, #FDE68A 100%);
color: #92400E;
}
&.strength {
background: linear-gradient(135deg, #DBEAFE 0%, #93C5FD 100%);
color: #1E40AF;
}
}
.course-meta {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.meta-item {
display: flex;
align-items: center;
gap: 12rpx;
padding: 16rpx 20rpx;
background: linear-gradient(135deg, #F9FAFB 0%, #F3F4F6 100%);
border-radius: 16rpx;
}
.meta-text {
font-size: 28rpx;
color: #4B5563;
flex: 1;
}
/* 详情卡片 */
.detail-card {
background: #ffffff;
margin: 24rpx;
border-radius: 24rpx;
padding: 28rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.card-header {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 20rpx;
}
.card-title {
font-size: 32rpx;
font-weight: 600;
color: #1E2A3A;
}
.card-content {
padding-top: 8rpx;
}
.description-text {
font-size: 28rpx;
color: #5E6F8D;
line-height: 1.8;
text-align: justify;
}
/* 信息卡片 */
.info-card {
background: #ffffff;
margin: 0 24rpx 24rpx;
border-radius: 24rpx;
padding: 28rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.info-row {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #F3F4F6;
&:last-child {
border-bottom: none;
}
}
.info-label {
display: flex;
align-items: center;
gap: 12rpx;
width: 200rpx;
font-size: 28rpx;
color: #5E6F8D;
}
.info-value {
font-size: 28rpx;
color: #1E2A3A;
font-weight: 500;
flex: 1;
text-align: right;
}
.payment-options {
display: flex;
gap: 20rpx;
flex: 1;
justify-content: flex-end;
}
.payment-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 16rpx 28rpx;
background: linear-gradient(135deg, #F9FAFB 0%, #F3F4F6 100%);
border-radius: 16rpx;
border: 2rpx solid transparent;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:active {
transform: scale(0.97);
}
&.active {
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
border-color: #FF6B35;
.payment-label,
.payment-value {
color: #ffffff;
}
}
&.free {
background: linear-gradient(135deg, #ECFDF5 0%, #D1FAE5 100%);
}
}
.payment-label {
font-size: 20rpx;
color: #6B7280;
}
.payment-value {
font-size: 28rpx;
font-weight: 600;
color: #FF6B35;
.free & {
color: #059669;
}
}
.payment-check {
position: absolute;
top: -8rpx;
right: -8rpx;
width: 36rpx;
height: 36rpx;
background: #059669;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
/* 预约须知 */
.notice-card {
background: #ffffff;
margin: 0 24rpx 24rpx;
border-radius: 24rpx;
padding: 28rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.notice-list {
padding-top: 8rpx;
}
.notice-item {
display: flex;
gap: 12rpx;
padding: 12rpx 0;
font-size: 26rpx;
color: #5E6F8D;
line-height: 1.6;
}
.notice-dot {
color: #F39C12;
font-size: 24rpx;
}
/* 底部操作栏 */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #ffffff;
padding: 20rpx 24rpx;
padding-bottom: calc(20rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: 24rpx;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.08);
z-index: 100;
}
.price-display {
flex: 1;
padding-left: 20rpx;
.price {
font-size: 40rpx;
font-weight: 700;
color: #FF6B35;
display: flex;
align-items: baseline;
gap: 4rpx;
.currency {
font-size: 26rpx;
font-weight: 600;
}
&.points {
gap: 8rpx;
}
&.free {
color: #10B981;
}
}
}
.booking-btn {
padding: 24rpx 64rpx;
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
border-radius: 52rpx;
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.3);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:active {
transform: scale(0.97);
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.2);
}
&.disabled {
background: linear-gradient(135deg, #F3F4F6 0%, #E5E7EB 100%);
color: #9CA3AF;
box-shadow: none;
}
}
/* 骨架屏样式 */
.skeleton {
padding-bottom: calc(140rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
}
.skeleton-header {
background: #ffffff;
padding-top: 20rpx;
}
.skeleton-cover {
width: 100%;
height: 480rpx;
border-radius: 0;
}
.skeleton-content {
margin-top: -40rpx;
padding: 0 24rpx;
}
.skeleton-block {
background: linear-gradient(90deg, #F0F0F0 25%, #E0E0E0 50%, #F0F0F0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s ease-in-out infinite;
border-radius: 16rpx;
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton-name {
width: 50%;
height: 48rpx;
margin-bottom: 20rpx;
}
.skeleton-meta {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 24rpx;
}
.skeleton-meta-item {
width: 70%;
height: 48rpx;
}
.skeleton-card {
background: #ffffff;
border-radius: 24rpx;
padding: 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.skeleton-card-title {
width: 30%;
height: 36rpx;
margin-bottom: 20rpx;
}
.skeleton-card-content {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.skeleton-text-line {
height: 32rpx;
&.short {
width: 60%;
}
}
.skeleton-info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #F3F4F6;
&:last-child {
border-bottom: none;
}
}
.skeleton-info-label {
width: 160rpx;
height: 32rpx;
}
.skeleton-info-value {
width: 120rpx;
height: 32rpx;
}
.skeleton-payment {
display: flex;
gap: 16rpx;
}
.skeleton-payment-item {
width: 100rpx;
height: 60rpx;
}
.skeleton-notice {
display: flex;
flex-direction: column;
gap: 16rpx;
padding-top: 8rpx;
}
.skeleton-notice-item {
height: 32rpx;
width: 90%;
}
</style>