@@ -74,40 +74,46 @@
< view class = "course-cover" >
< view class = "cover-skeleton" v-if = "course.coverImage && !course.coverLoaded && !course.coverError" > < / view >
< image v-if = "course.coverImage" class="cover-img" :class="{ loaded: course.coverLoaded }" :src="course.coverImage" mode="aspectFill" @load="onCoverLoad(course)" @error="onCoverError(course)" > < / image >
< text class = "cover-text " v-if = "(!course.coverImage || course.coverError) && course.typeName" > {{ course.typeName.slice ( 0 , 2 ) }} < / text >
< view class = "cover-placeholder " v-if = "(!course.coverImage || course.coverError)" >
< text class = "cover-icon" > & # 9963 ; < / text >
< text class = "cover-type-name" > { { course . typeName } } < / text >
< / view >
< view class = "cover-overlay" >
< view class = "cover-badge diff-badge" : class = "'diff-' + course.difficultyLevel" >
< text class = "diff-badge-star" v-for = "s in course.difficultyStars" :key="s" > & # 9733 ; < / text >
< text class = "diff-badge-label" > { { course . difficultyLabel } } < / text >
< / view >
< / view >
< / view >
< view class = "course-body" >
< view class = "course-top" >
< text class = "course-name" > { { course . courseName } } < / text >
< view class = "course-tags " >
< text class = "course-category" > { { course . category } } < / text >
< text class = "course-difficulty" : class = "'diff-' + course.difficultyLevel" > { { course . difficultyLabel } } < / text >
< / view >
< text class = "course-price" : class = "{ free: course.storedValueAmount === 0 } ">
{ { course . storedValueAmount > 0 ? '¥' + course . storedValueAmount + '/次' : '免费' } }
< / text >
< / view >
< view class = "course-labels" v-if = "course.labels && course.labels.length" >
< text v-for = "(label, li) in course.labels" :key="li" class="course-label"
: style = "{ background: label.color + '1a', color: label.color }" > { { label . labelName } } < / text >
< / view >
< view class = "course-meta" >
< text class = "meta-icon" > & # 128339 ; < / text >
< text class = "course-date" > { { course . shortDate } } < / text >
< text class = "course-time" > { { course . shortTime } } - { { course . endShortTime } } < / text >
< text class = "course-duration" v-if = "course.duration" > {{ course.duration }} < / text >
< / view >
< view class = "course-meta" >
< text class = "meta-icon" > & # 128205 ; < / text >
< text class = "course-location" > { { course . location } } < / text >
< / view >
< view class = "course-meta " >
< view class = "course-bottom " >
< text class = "course-capacity" > 已预约 { { course . currentMembers } } / { { course . maxMembers } } 人 < / text >
< button class = "btn-book" : class = "{ disabled: !course.canBook }" :disabled = "!course.canBook"
@click.stop ="bookCourse(course)" >
{{ course.canBook ? ' 立即预约 ' : course.statusLabel }}
< / button >
< / view >
< view class = "course-labels" v-if = "course.labels" >
< text v-for = "(label, li) in course.labels" :key="li" class="course-label"
: style = "{ background: label.color + '1a', color: label.color }" > { { label . labelName } } < / text >
< / view >
< text class = "course-price" : class = "{ free: course.storedValueAmount === 0 }" >
{ { course . storedValueAmount > 0 ? '¥' + course . storedValueAmount + '/次' : '免费' } }
< / text >
< view class = "course-bottom" >
< button class = "btn-book" : class = "{ disabled: !course.canBook }" :disabled = "!course.canBook"
@click.stop ="bookCourse(course)" >
{{ course.canBook ? ' 立即预约 ' : course.statusLabel }}
< / button >
< / view >
< / view >
< / view >
< / view >
@@ -127,6 +133,7 @@
const courseApi = require ( '../../api/course' )
const bookingApi = require ( '../../api/booking' )
const store = require ( '../../store/index' )
const { resolveCoverUrl } = require ( '../../utils/request' )
export default {
data ( ) {
@@ -153,7 +160,8 @@
{ label : '难度' , value : 'difficulty' } ,
{ label : '热度' , value : 'popular' }
] ,
courses : [ ]
courses : [ ] ,
activeBookedCourseIds : new Set ( ) // 用户当前已预约(status=0)的课程ID集合
}
} ,
onLoad ( ) {
@@ -172,8 +180,11 @@
this . loadCourses ( )
this . loadTypes ( )
} ,
onShow ( ) {
this . loadActiveBookings ( )
} ,
onPullDownRefresh ( ) {
Promise . all ( [ this . loadCourses ( ) , this . loadTypes ( ) ] )
Promise . all ( [ this . loadCourses ( ) , this . loadTypes ( ) , this . loadActiveBookings ( ) ] )
. finally ( ( ) => { uni . stopPullDownRefresh ( ) } )
} ,
computed : {
@@ -191,7 +202,7 @@
} ,
filteredCourses ( ) {
let list = this . courses . map ( c => {
const bd = c . baseDifficulty || c . calculatedDifficulty || 5
const bd = this . getTypeDifficulty ( c . courseType )
const st = c . startTime || ''
const et = c . endTime || ''
// 兼容 "2026-07-15T16:45:00" 和 "2026-07-15 16:45:00" 两种格式
@@ -226,14 +237,22 @@
else if ( statusStr !== 0 ) statusLabel = '已关闭'
if ( ! canBook && statusStr === 0 && isFull ) statusLabel = '已约满'
const diffLevel = bd <= 3 ? 'low' : bd <= 6 ? 'mid' : 'high'
const diffLabel = bd <= 3 ? '入门' : bd <= 6 ? '中等' : '困难'
const diffColor = diffLevel === 'low' ? '#00E676' : diffLevel === 'mid' ? '#FFA502' : '#FF5252'
const diffStars = Math . min ( Math . max ( Math . ceil ( bd / 2 ) , 1 ) , 5 )
return {
... c ,
currentMembers : curMembers ,
maxMembers : maxMems ,
isFull : isFull ,
canBook : canBook ,
difficultyLevel : bd <= 3 ? 'low' : bd <= 6 ? 'mid' : 'high' ,
difficultyLabel : bd <= 3 ? '入门' : bd <= 6 ? '中等' : '困难' ,
difficultyLevel : diffLevel ,
difficultyLabel : diffLabel ,
difficultyColor : diffColor ,
difficultyStars : diffStars ,
difficultyPercent : Math . round ( bd * 10 ) ,
baseDifficulty : bd ,
shortDate : md + '-' + dd ,
shortTime : hh + ':' + mm ,
@@ -247,8 +266,8 @@
status : statusStr ,
statusLabel : statusLabel ,
storedValueAmount : c . storedValueAmount != null ? c . storedValueAmount : 0 ,
labels : c . labels || [ ] ,
coverImage : c . coverImage || ''
labels : this . getTypeLabels ( c . courseType ) ,
coverImage : resolveCoverUrl ( c . coverImage ) ,
}
} )
if ( this . searchKeyword ) {
@@ -280,12 +299,26 @@
return ta < tb ? - 1 : ta > tb ? 1 : 0
} )
}
// 只显示可预约的团课
list = list . filter ( c => c . canBook )
// 只显示可预约的团课,且排除用户已主动预约的课程
list = list . filter ( c => c . canBook && ! this . activeBookedCourseIds . has ( c . id ) )
return list
}
} ,
methods : {
// 从已加载的课程类型中匹配标签
getTypeLabels ( courseType ) {
const type = this . getTypeInfo ( courseType )
return type && type . labels ? type . labels : [ ]
} ,
// 从已加载的课程类型中匹配难度
getTypeDifficulty ( courseType ) {
const type = this . getTypeInfo ( courseType )
return type && type . baseDifficulty != null ? type . baseDifficulty : 5
} ,
// 查找课程类型信息
getTypeInfo ( courseType ) {
return this . courseTypes . find ( t => String ( t . id ) === String ( courseType ) )
} ,
// 判断某行是否可见:展开时全部可见,折叠时只有 visibleRowStart 行可见
isRowVisible ( rowIndex ) {
if ( this . typesExpanded ) return true
@@ -313,6 +346,24 @@
console . error ( '加载课程类型失败:' , e )
}
} ,
async loadActiveBookings ( ) {
try {
const memberInfo = store . getMemberInfo ( )
const memberId = memberInfo ? ( memberInfo . memberId || memberInfo . id ) : null
if ( ! memberId ) {
this . activeBookedCourseIds = new Set ( )
return
}
const list = await bookingApi . getMemberBookings ( memberId )
// 只记录状态为"0"(已预约)的课程ID, status=1(已取消)、2(已出席)、3(缺席)不挡
const bookings = Array . isArray ( list ) ? list : ( list . content || [ ] )
this . activeBookedCourseIds = new Set (
bookings . filter ( b => String ( b . status ) === '0' ) . map ( b => b . courseId )
)
} catch ( e ) {
console . error ( '加载预约记录失败:' , e )
}
} ,
onSearchInput ( ) { } ,
clearSearch ( ) { this . searchKeyword = '' } ,
onCoverLoad ( course ) { course . coverLoaded = true } ,
@@ -408,35 +459,56 @@
. sort - item . active { color : # 00 C853 ; font - weight : 600 ; }
. course - list - inner { padding : 12 px 16 px 0 ; }
. course - item { margin - bottom : 12 px ; border - radius : 20 px ; box - shadow : 0 4 px 12 px rgba ( 0 , 0 , 0 , 0.06 ) ; overflow : hidden ; }
. course - card { background : # FFFFFF ; border - radius : 20 px ; padding : 14 px ; display : flex ; }
. course - cover { width : 72 px ; height : 72 px ; border - radius : 12 px ; background : rgba ( 0 , 230 , 118 , 0.12 ) ; display : flex ; align - items : center ; justify - content : center ; flex - shrink : 0 ; margin - right : 14 px ; overflow : hidden ; position : relative ; }
. cover - skeleton { position : absolute ; inset : 0 ; background : linear - gradient ( 90 deg , # E8E8E8 25 % , # F0F0F0 50 % , # E8E8E8 75 % ) ; background - size : 200 % 100 % ; animation : shimmer 1.5 s infinite ; z - index : 1 ; }
. course - item { margin - bottom : 14 px ; border - radius : 16 px ; box - shadow : 0 4 px 16 px rgba ( 0 , 0 , 0 , 0.07 ) ; overflow : hidden ; }
. course - card { background : # FFFFFF ; border - radius : 16 px ; display : flex ; flex - direction : column ; }
/* ---- 封面区域 ---- */
. course - cover { width : 100 % ; height : 170 px ; background : linear - gradient ( 135 deg , # E8F5E9 0 % , # C8E6C9 100 % ) ; display : flex ; align - items : center ; justify - content : center ; position : relative ; overflow : hidden ; }
. cover - skeleton { position : absolute ; inset : 0 ; background : linear - gradient ( 90 deg , # E8E8E8 25 % , # F5F5F5 50 % , # E8E8E8 75 % ) ; background - size : 200 % 100 % ; animation : shimmer 1.5 s infinite ; z - index : 1 ; }
@ keyframes shimmer { 0 % { background - position : 200 % 0 ; } 100 % { background - position : - 200 % 0 ; } }
. cover - img { position : absolute ; inset : 0 ; width : 100 % ; height : 100 % ; opacity : 0 ; transition : opacity 0.3 s ; z - index : 0 ; }
. cover - img . loaded { opacity : 1 ; z - index : 2 ; }
. cover - text { font - size : 22 px ; font - weight : 700 ; color : # 00 C853 ; }
. course - body { flex : 1 ; display : flex ; flex - direction : column ; min - width : 0 ; }
. course - top { display : flex ; justify - content : space - between ; align - items : flex - start ; }
. course - name { font - size : 16 px ; font - weight : 700 ; color : # 1 E1E1E ; flex : 1 ; white - space : nowrap ; overflow : hidden ; text - overflow : ellipsis ; }
. course - tags { display : flex ; flex - shrink : 0 ; margin - left : 6 px ; }
. course - category { font - size : 11 px ; color : # 00 C853 ; background : rgba ( 0 , 230 , 118 , 0.12 ) ; padding : 1 px 8 px ; border - radius : 20 px ; font - weight : 500 ; margin - right : 4 px ; }
. course - difficulty { padding : 1 px 8 px ; border - radius : 20 px ; font - size : 11 px ; font - weight : 600 ; }
. diff - low { background : rgba ( 0 , 230 , 118 , 0.15 ) ; color : # 00 C853 ; }
. diff - mid { background : rgba ( 255 , 165 , 2 , 0.15 ) ; color : # FFA502 ; }
. diff - high { background : rgba ( 255 , 82 , 82 , 0.15 ) ; color : # FF5252 ; }
. course - meta { display : flex ; align - items : center ; font - size : 12 px ; color : # 7 A7E84 ; margin - top : 4 px ; }
/* 无封面时的占位 */
. cover - placeholder { position : absolute ; inset : 0 ; display : flex ; flex - direction : column ; align - items : center ; justify - content : center ; z - index : 0 ; }
. cover - icon { font - size : 36 px ; margin - bottom : 4 px ; }
. cover - type - name { font - size : 16 px ; font - weight : 700 ; color : # 00 C853 ; }
/* 封面覆盖层 - 难度角标 */
. cover - overlay { position : absolute ; top : 10 px ; right : 10 px ; z - index : 5 ; }
. diff - badge { display : flex ; align - items : center ; padding : 4 px 10 px ; border - radius : 20 px ; background : rgba ( 0 , 0 , 0 , 0.55 ) ; backdrop - filter : blur ( 4 px ) ; }
. diff - badge - star { font - size : 11 px ; margin - right : 1 px ; }
. diff - badge - label { font - size : 11 px ; font - weight : 600 ; margin - left : 4 px ; }
. diff - badge . diff - low . diff - badge - star { color : # 00 E676 ; }
. diff - badge . diff - low . diff - badge - label { color : # 69 F0AE ; }
. diff - badge . diff - mid . diff - badge - star { color : # FFA502 ; }
. diff - badge . diff - mid . diff - badge - label { color : # FFBE4D ; }
. diff - badge . diff - high . diff - badge - star { color : # FF5252 ; }
. diff - badge . diff - high . diff - badge - label { color : # FF8A80 ; }
/* ---- 卡片内容 ---- */
. course - body { padding : 14 px 16 px 16 px ; display : flex ; flex - direction : column ; }
. course - top { display : flex ; justify - content : space - between ; align - items : center ; }
. course - name { font - size : 17 px ; font - weight : 700 ; color : # 1 E1E1E ; flex : 1 ; white - space : nowrap ; overflow : hidden ; text - overflow : ellipsis ; }
. course - price { font - size : 20 px ; font - weight : 700 ; color : # 00 C853 ; flex - shrink : 0 ; margin - left : 8 px ; }
. course - price . free { color : # 00 C853 ; }
/* 标签 */
. course - labels { display : flex ; flex - wrap : wrap ; margin - top : 8 px ; }
. course - label { font - size : 11 px ; padding : 2 px 8 px ; border - radius : 10 px ; margin - right : 6 px ; margin - bottom : 4 px ; }
/* 元数据行 */
. course - meta { display : flex ; align - items : center ; font - size : 13 px ; color : # 7 A7E84 ; margin - top : 6 px ; }
. meta - icon { font - size : 12 px ; margin - right : 4 px ; flex - shrink : 0 ; }
. course - date { flex - shrink : 0 ; }
. course - time { margin - left : 8 px ; }
. course - duration { margin - left : 8 px ; color : # 00 C853 ; font - weight : 500 ; }
. course - location { white - space : nowrap ; overflow : hidden ; text - overflow : ellipsis ; }
. course - capacity { color : # 7 A7E84 ; }
. course - labels { display : flex ; flex - wrap : wrap ; margin - top : 6 px ; }
. course - label { font - size : 10 px ; padding : 1 px 6 px ; border - radius : 10 px ; margin - right : 4 px ; margin - bottom : 2 px ; }
. course - bottom { text - align : right ; margin - top : 6 px ; }
. course - price { font - size : 18 px ; font - weight : 700 ; color : # 00 C853 ; display : block ; margin - top : 6 px ; }
. course - price . free { color : # 00 C853 ; }
. btn - book { background : # 00 E676 ; color : # 1 A1A1A ; border : none ; padding : 6 px 16 px ; border - radius : 40 px ; font - weight : 700 ; font - size : 13 px ; line - height : 1.4 ; box - shadow : 0 2 px 8 px rgba ( 0 , 230 , 118 , 0.3 ) ; }
/* 底部预约行 */
. course - bottom { display : flex ; align - items : center ; margin - top : 12 px ; padding - top : 10 px ; border - top : 1 px solid # F0F0F0 ; }
. course - capacity { font - size : 13 px ; color : # 7 A7E84 ; flex : 1 ; }
. btn - book { background : # 00 E676 ; color : # 1 A1A1A ; border : none ; padding : 10 px 28 px ; border - radius : 40 px ; font - weight : 700 ; font - size : 15 px ; line - height : 1.2 ; flex - shrink : 0 ; margin - left : auto ; text - align : center ; box - shadow : 0 2 px 8 px rgba ( 0 , 230 , 118 , 0.3 ) ; }
. btn - book : : after { border : none ; }
. btn - book . disabled { background : # E0E0E0 ; color : # BDBDBD ; box - shadow : none ; }