Files
gym-manage/gym-manage-uniapp/pages/groupCourse/detail.vue
T

822 lines
18 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">
<!-- 页面头部 -->
<PageHeader title="课程详情" subtitle="团课预约" :show-back="true" />
<!-- 骨架屏 -->
<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="getCourseCoverUrl(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 class="course-tags">
<view class="tags-label">
<uni-icons type="tag" size="20" color="#8A99B4" />
</view>
<view class="tags-list">
<view
v-for="label in courseLabels"
:key="label.id"
class="tag-item"
:style="{ background: `${label.color}15`, color: label.color }"
>
<text>{{ label.labelName }}</text>
</view>
<view v-if="courseLabels.length === 0" class="no-tags">
<text>暂无标签</text>
</view>
</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>
<!-- 预约须知 -->
<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="['booking-btn', { disabled: !canBook }]" @click="handleBooking">
<text>{{ canBook ? '立即预约' : (course.currentMembers >= course.maxMembers ? '已满员' : statusText) }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getGroupCourseDetail, bookGroupCourse } from '@/api/groupCourse.js'
import { getCourseCoverUrl } from '@/utils/request.js'
import { getMemberId } from '@/utils/request.js'
import PageHeader from '@/components/index/PageHeader.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
})
// 课程标签数据
const courseLabels = 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 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 handleBooking = async () => {
if (!canBook.value) return
const memberId = getMemberId()
if (!memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
uni.showModal({
title: '确认预约',
content: `确定要预约「${course.value.courseName}」吗?`,
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '预约中...' })
try {
const result = await bookGroupCourse({
courseId: Number(course.value.id),
memberId: Number(memberId)
})
uni.hideLoading()
if (result.success) {
uni.showToast({
title: '预约成功',
icon: 'success'
})
setTimeout(() => {
uni.redirectTo({ url: '/pages/memberInfo/myCourses' })
}, 1500)
} else {
uni.showToast({
title: result.message || '预约失败',
icon: 'none',
duration: 3000
})
}
} catch (error) {
uni.hideLoading()
console.error('[detail.vue] 预约失败:', error)
const errMsg = error?.message || error?.data?.message || '预约失败'
uni.showToast({
title: errMsg,
icon: 'none',
duration: 3000
})
}
}
})
}
// 获取课程详情
const fetchCourseDetail = async (id) => {
try {
console.log('[detail.vue] 开始获取课程详情, id:', id)
const result = await getGroupCourseDetail(id)
course.value = result
console.log('[detail.vue] 课程详情获取成功:', course.value)
// 从完整信息中提取标签
if (result.labels && Array.isArray(result.labels)) {
courseLabels.value = result.labels
}
console.log('[detail.vue] 课程标签获取成功:', courseLabels.value)
} catch (error) {
console.error('[detail.vue] 获取课程详情失败:', error)
uni.showToast({
title: '获取课程详情失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
// 页面加载时获取课程详情
onLoad((options) => {
const courseId = options.id
console.log('[detail.vue] onLoad,课程ID:', courseId)
if (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;
}
/* 课程标签 */
.course-tags {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid #F3F4F6;
}
.tags-label {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 26rpx;
color: #6B7280;
margin-bottom: 16rpx;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.tag-item {
display: inline-flex;
align-items: center;
padding: 10rpx 24rpx;
border-radius: 24rpx;
font-size: 24rpx;
font-weight: 500;
}
.no-tags {
font-size: 24rpx;
color: #9CA3AF;
}
/* 详情卡片 */
.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;
}
/* 预约须知 */
.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: 16rpx 24rpx;
padding-bottom: calc(16rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.08);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
}
/* 预约按钮 */
.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);
}
.booking-btn.disabled {
background: linear-gradient(135deg, #D1D5DB 0%, #9CA3AF 100%);
color: #ffffff;
box-shadow: none;
}
.booking-btn.disabled text {
color: #c8c8c8;
}
.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>