392 lines
16 KiB
Vue
392 lines
16 KiB
Vue
<template>
|
|
<view class="detail-root">
|
|
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
|
|
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
|
<text class="back-btn" @click="goBack">‹</text>
|
|
<text class="nav-title">课程详情</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="cover-area">
|
|
<image v-if="course.coverImage" class="cover-img" :src="course.coverImage" mode="aspectFill"
|
|
@error="course.coverError = true" />
|
|
<view v-if="!course.coverImage || course.coverError" class="cover-bg">
|
|
<text class="cover-text">{{ course.typeName ? course.typeName.slice(0, 2) : '课程' }}</text>
|
|
</view>
|
|
<view class="status-tag" :class="course.statusClass" v-if="course.statusText">{{ course.statusText }}</view>
|
|
</view>
|
|
|
|
<scroll-view class="content" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px)' }">
|
|
<view class="content-inner">
|
|
<view class="course-header">
|
|
<view class="header-top">
|
|
<text class="course-title">{{ course.courseName }}</text>
|
|
<view class="difficulty-badge" :class="'diff-' + course.difficultyLevel" v-if="course.difficultyLabel">
|
|
{{ course.difficultyLabel }}
|
|
</view>
|
|
</view>
|
|
<view class="header-meta" v-if="course.category">
|
|
<text class="meta-type">{{ course.category }}</text>
|
|
<text class="meta-type-name" v-if="course.typeName"> {{ course.typeName }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="info-card">
|
|
<view class="info-row">
|
|
<view class="info-item">
|
|
<text class="info-label">教练</text>
|
|
<text class="info-value">{{ course.coachName || '-' }}</text>
|
|
</view>
|
|
<view class="info-item">
|
|
<text class="info-label">地点</text>
|
|
<text class="info-value">{{ course.location || '-' }}</text>
|
|
</view>
|
|
</view>
|
|
<view class="divider"></view>
|
|
<view class="info-row">
|
|
<view class="info-item">
|
|
<text class="info-label">日期</text>
|
|
<text class="info-value">{{ course.fullDate }}</text>
|
|
</view>
|
|
</view>
|
|
<view class="divider"></view>
|
|
<view class="info-row">
|
|
<view class="info-item">
|
|
<text class="info-label">时间</text>
|
|
<text class="info-value">{{ course.shortTime }} - {{ course.endShortTime }}</text>
|
|
</view>
|
|
<view class="info-item">
|
|
<text class="info-label">时长</text>
|
|
<text class="info-value">{{ course.duration }}</text>
|
|
</view>
|
|
</view>
|
|
<view class="divider"></view>
|
|
<view class="info-row">
|
|
<view class="info-item">
|
|
<text class="info-label">人数</text>
|
|
<text class="info-value">
|
|
{{ course.currentMembers }} / {{ course.maxMembers }}
|
|
<text v-if="course.maxMembers - course.currentMembers > 0" class="remain-text">
|
|
(剩{{ course.maxMembers - course.currentMembers }}位)
|
|
</text>
|
|
</text>
|
|
</view>
|
|
<view class="info-item">
|
|
<text class="info-label">储值消耗</text>
|
|
<text class="info-value price">{{ course.storedValueAmount > 0 ? '¥' + course.storedValueAmount + '/次' : '免费' }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="section-card" v-if="course.description">
|
|
<text class="section-title">• 课程介绍</text>
|
|
<text class="desc-text">{{ course.description }}</text>
|
|
</view>
|
|
|
|
<view class="section-card">
|
|
<text class="section-title">• 课程信息</text>
|
|
<view class="detail-grid">
|
|
<view class="detail-item" v-if="course.typeName">
|
|
<text class="detail-label">课程类型</text>
|
|
<text class="detail-value">{{ course.typeName }}</text>
|
|
</view>
|
|
<view class="detail-item">
|
|
<text class="detail-label">基础难度</text>
|
|
<view class="stars">
|
|
<text v-for="i in 10" :key="i" class="star" :class="{ filled: i <= (course.difficulty || 0) }">
|
|
{{ i <= (course.difficulty || 0) ? '★' : '☆' }}
|
|
</text>
|
|
</view>
|
|
</view>
|
|
<view class="detail-item" v-if="course.category">
|
|
<text class="detail-label">分类</text>
|
|
<text class="detail-value">{{ course.category }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="bottom-safe"></view>
|
|
</view>
|
|
</scroll-view>
|
|
|
|
<view class="bottom-bar">
|
|
<view class="bottom-info">
|
|
<text class="bottom-price" :class="{ free: course.storedValueAmount === 0 }">
|
|
{{ course.storedValueAmount > 0 ? '¥' + course.storedValueAmount : '免费' }}
|
|
</text>
|
|
<text class="bottom-desc">/ 次</text>
|
|
</view>
|
|
<button class="booking-btn" :class="{ disabled: !course.canBook }" :disabled="!course.canBook"
|
|
@click="handleBooking">
|
|
{{ course.btnText }}
|
|
</button>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script>
|
|
const courseApi = require('../../api/course')
|
|
const bookingApi = require('../../api/booking')
|
|
const store = require('../../store/index')
|
|
|
|
export default {
|
|
data() {
|
|
return {
|
|
statusBarHeight: 0,
|
|
navBarHeight: 44,
|
|
totalHeaderHeight: 44,
|
|
loading: true,
|
|
submitting: false,
|
|
course: {
|
|
id: 0,
|
|
courseName: '加载中...',
|
|
coachName: '',
|
|
location: '',
|
|
typeName: '',
|
|
category: '',
|
|
startTime: '',
|
|
endTime: '',
|
|
fullDate: '',
|
|
shortTime: '--',
|
|
endShortTime: '--',
|
|
duration: '',
|
|
maxMembers: 0,
|
|
currentMembers: 0,
|
|
isFull: false,
|
|
canBook: false,
|
|
btnText: '立即预约',
|
|
difficulty: 0,
|
|
difficultyLevel: 'mid',
|
|
difficultyLabel: '',
|
|
storedValueAmount: 0,
|
|
description: '',
|
|
statusText: '',
|
|
statusClass: '',
|
|
coverImage: '',
|
|
coverError: false
|
|
}
|
|
}
|
|
},
|
|
onLoad(options) {
|
|
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
|
|
if (options.id) this.loadCourseDetail(options.id)
|
|
},
|
|
methods: {
|
|
async loadCourseDetail(id) {
|
|
this.loading = true
|
|
try {
|
|
const detail = await courseApi.getCourseDetail(id)
|
|
if (detail) {
|
|
// 基础难度:优先 calculatedDifficulty,其次 baseDifficulty
|
|
const bd = detail.calculatedDifficulty || detail.baseDifficulty || 0
|
|
// 名称来源:API 可能已 join 返回 typeName / coachName / category
|
|
const tn = detail.typeName || (detail.typeInfo && detail.typeInfo.typeName) || ''
|
|
const cat = detail.typeCategory || (detail.typeInfo && detail.typeInfo.category) || ''
|
|
const desc = detail.description || (detail.typeInfo && detail.typeInfo.description) || ''
|
|
|
|
// 日期解析(兼容 ISO 格式 "2026-07-15T16:45:00" 和空格格式)
|
|
const st = detail.startTime || ''
|
|
const et = detail.endTime || ''
|
|
const dt = st ? new Date(st.replace(/ /, 'T')) : null
|
|
const edt = et ? new Date(et.replace(/ /, 'T')) : null
|
|
|
|
// 格式化
|
|
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 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') : ''
|
|
|
|
// 时长
|
|
let durationStr = ''
|
|
if (dt && edt && !isNaN(dt) && !isNaN(edt)) {
|
|
const diffMin = Math.round((edt - dt) / 60000)
|
|
if (diffMin > 0) {
|
|
const hours = Math.floor(diffMin / 60)
|
|
const mins = diffMin % 60
|
|
durationStr = hours > 0 ? hours + '小时' + (mins > 0 ? mins + '分钟' : '') : diffMin + '分钟'
|
|
}
|
|
}
|
|
|
|
// 状态:数据库 status 字段 (0正常 1已取消 2已结束 3进行中 4超时)
|
|
const statusStr = detail.status != null ? Number(detail.status) : 0
|
|
const curMembers = detail.currentMembers != null ? detail.currentMembers : 0
|
|
const maxMems = detail.maxMembers != null ? detail.maxMembers : 0
|
|
const isFull = maxMems > 0 && curMembers >= maxMems
|
|
|
|
let statusText = ''
|
|
let statusClass = ''
|
|
let canBook = false
|
|
let btnText = '立即预约'
|
|
|
|
if (statusStr === 0) {
|
|
canBook = !isFull
|
|
btnText = isFull ? '已约满' : '立即预约'
|
|
statusText = isFull ? '已约满' : ''
|
|
} else if (statusStr === 1) {
|
|
statusText = '已取消'
|
|
statusClass = 'status-cancel'
|
|
btnText = '已取消'
|
|
} else if (statusStr === 2) {
|
|
statusText = '已结束'
|
|
statusClass = 'status-end'
|
|
btnText = '已结束'
|
|
} else if (statusStr === 3) {
|
|
statusText = '进行中'
|
|
statusClass = 'status-going'
|
|
btnText = '进行中'
|
|
} else if (statusStr === 4) {
|
|
statusText = '已超时'
|
|
statusClass = 'status-timeout'
|
|
btnText = '已超时'
|
|
}
|
|
|
|
this.course = {
|
|
id: detail.id || parseInt(id),
|
|
courseName: detail.courseName || '未命名课程',
|
|
coachName: detail.coachName || '',
|
|
location: detail.location || '',
|
|
typeName: tn,
|
|
category: cat,
|
|
description: desc,
|
|
startTime: st,
|
|
endTime: et,
|
|
fullDate: y + '-' + mo + '-' + d,
|
|
shortTime: (hh && mm) ? hh + ':' + mm : '--',
|
|
endShortTime: (ehh && emm) ? ehh + ':' + emm : '--',
|
|
duration: durationStr,
|
|
maxMembers: maxMems,
|
|
currentMembers: curMembers,
|
|
isFull: isFull,
|
|
canBook: canBook,
|
|
btnText: btnText,
|
|
difficulty: bd,
|
|
difficultyLevel: bd <= 3 ? 'low' : bd <= 6 ? 'mid' : 'high',
|
|
difficultyLabel: bd > 0 ? (bd <= 3 ? '入门' : bd <= 6 ? '中等' : '困难') : '',
|
|
storedValueAmount: detail.storedValueAmount != null ? detail.storedValueAmount : 0,
|
|
statusText: statusText,
|
|
statusClass: statusClass,
|
|
coverImage: detail.coverImage || '',
|
|
coverError: false
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('加载课程详情失败:', e)
|
|
uni.showToast({ title: '加载课程详情失败', icon: 'none' })
|
|
} finally {
|
|
this.loading = false
|
|
}
|
|
},
|
|
goBack() { uni.navigateBack() },
|
|
async handleBooking() {
|
|
if (!this.course.canBook || this.submitting) return
|
|
const memberInfo = store.getMemberInfo()
|
|
const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null
|
|
if (!memberId) {
|
|
uni.showToast({ title: '请先登录', icon: 'none' })
|
|
return
|
|
}
|
|
this.submitting = true
|
|
try {
|
|
await bookingApi.bookCourse({
|
|
courseId: this.course.id,
|
|
memberId: memberId
|
|
})
|
|
uni.hideLoading()
|
|
uni.showToast({ title: '预约成功', icon: 'success' })
|
|
// 跳转到「我的课程」页面
|
|
setTimeout(() => { uni.switchTab({ url: '/pages/my-courses/my-courses' }) }, 1000)
|
|
} catch (e) {
|
|
console.error('预约失败:', e)
|
|
const msg = (e.data && e.data.message) || '预约失败,请重试'
|
|
uni.showToast({ title: msg, icon: 'none' })
|
|
} finally {
|
|
this.submitting = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.detail-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; }
|
|
.back-btn { font-size: 26px; color: #FFFFFF; font-weight: 700; margin-right: 10px; line-height: 1; }
|
|
.nav-title { font-size: 18px; font-weight: 700; color: #FFFFFF; }
|
|
|
|
/* 封面区 */
|
|
.cover-area { height: 200px; position: relative; overflow: hidden; }
|
|
.cover-img { width: 100%; height: 100%; }
|
|
.cover-bg { width: 100%; height: 100%; background: linear-gradient(135deg, #00C853, #00BFA5); display: flex; align-items: center; justify-content: center; }
|
|
.cover-text { font-size: 56px; font-weight: 700; color: rgba(255,255,255,0.9); }
|
|
.status-tag { position: absolute; top: 14px; right: 16px; color: #FFFFFF; font-size: 12px; font-weight: 600; padding: 4px 12px; border-radius: 40px; }
|
|
.status-cancel { background: #FF5252; }
|
|
.status-end { background: #9E9E9E; }
|
|
.status-going { background: #00C853; }
|
|
.status-timeout { background: #FF9800; }
|
|
/* 已约满不是DB status,是计算出来的 */
|
|
.status-tag:not(.status-cancel):not(.status-end):not(.status-going):not(.status-timeout) { background: #FF5252; }
|
|
|
|
/* 课程信息卡片 */
|
|
.content-inner { padding-bottom: 100px; }
|
|
.course-header { background: #FFFFFF; padding: 20px 16px 16px; border-radius: 20px 20px 0 0; margin-top: -16px; position: relative; z-index: 1; }
|
|
.header-top { display: flex; justify-content: space-between; align-items: center; }
|
|
.course-title { font-size: 22px; font-weight: 700; color: #1E1E1E; flex: 1; }
|
|
.difficulty-badge { padding: 4px 12px; border-radius: 40px; font-size: 12px; font-weight: 600; flex-shrink: 0; margin-left: 10px; }
|
|
.diff-low { background: rgba(0,230,118,0.15); color: #00C853; }
|
|
.diff-mid { background: rgba(255,165,2,0.15); color: #FFA502; }
|
|
.diff-high { background: rgba(255,82,82,0.15); color: #FF5252; }
|
|
.header-meta { margin-top: 8px; display: flex; align-items: center; }
|
|
.meta-type { font-size: 13px; color: #00C853; background: rgba(0,230,118,0.15); padding: 3px 10px; border-radius: 20px; font-weight: 500; }
|
|
.meta-type-name { font-size: 13px; color: #7A7E84; margin-left: 6px; }
|
|
|
|
.info-card { background: #FFFFFF; margin: 0 16px; border-radius: 20px; padding: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); margin-top: 12px; }
|
|
.info-row { display: flex; justify-content: space-between; }
|
|
.info-item { display: flex; flex-direction: column; flex: 1; }
|
|
.info-label { font-size: 12px; color: #7A7E84; margin-bottom: 4px; }
|
|
.info-value { font-size: 14px; font-weight: 600; color: #1E1E1E; }
|
|
.info-value.price { color: #00C853; }
|
|
.remain-text { font-size: 12px; color: #00C853; font-weight: 500; }
|
|
.divider { height: 1px; background: #EEEEEE; margin: 14px 0; }
|
|
|
|
.section-card { background: #FFFFFF; margin: 12px 16px; border-radius: 20px; padding: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
|
|
.section-title { font-size: 16px; font-weight: 700; color: #1E1E1E; display: block; margin-bottom: 12px; }
|
|
.desc-text { font-size: 14px; color: #7A7E84; line-height: 1.7; }
|
|
|
|
.detail-grid { display: flex; flex-wrap: wrap; }
|
|
.detail-item { width: 50%; display: flex; flex-direction: column; margin-bottom: 14px; }
|
|
.detail-label { font-size: 12px; color: #7A7E84; }
|
|
.detail-value { font-size: 14px; font-weight: 600; color: #1E1E1E; margin-top: 4px; }
|
|
|
|
.stars { display: flex; flex-wrap: wrap; }
|
|
.star { font-size: 13px; color: #E0E0E0; }
|
|
.star.filled { color: #FFA502; }
|
|
|
|
.bottom-safe { height: 30px; }
|
|
|
|
/* 底部栏 */
|
|
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #FFFFFF; padding: 12px 16px; padding-bottom: calc(12px + env(safe-area-inset-bottom)); display: flex; align-items: center; justify-content: space-between; box-shadow: 0 -2px 12px rgba(0,0,0,0.06); z-index: 100; }
|
|
.bottom-info { display: flex; align-items: baseline; }
|
|
.bottom-price { font-size: 22px; font-weight: 700; color: #00C853; }
|
|
.bottom-price.free { color: #00C853; }
|
|
.bottom-desc { font-size: 13px; color: #7A7E84; margin-left: 2px; }
|
|
.booking-btn { background: #00E676; color: #1A1A1A; border: none; padding: 12px 32px; border-radius: 40px; font-weight: 700; font-size: 16px; box-shadow: 0 4px 14px rgba(0,230,118,0.35); line-height: 1.2; }
|
|
.booking-btn::after { border: none; }
|
|
.booking-btn.disabled { background: #E0E0E0; color: #BDBDBD; box-shadow: none; }
|
|
</style>
|