实现部分页面前后端相通

This commit is contained in:
2026-07-16 17:29:27 +08:00
parent 7e45ecd144
commit a9ccdab421
16 changed files with 778 additions and 184 deletions
@@ -117,13 +117,19 @@
</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,
course: { id: 1, courseName: '综合力量训练', coachName: '张教练', location: '力量区', typeChar: '力', tag: '热门', category: 'strength', categoryLabel: '力量训练', typeName: '自由重量杠铃/哑铃', startTime: '2026-07-15 10:00', endTime: '2026-07-15 10:45', duration: 45, maxMembers: 20, currentMembers: 8, isFull: false, difficulty: 5, difficultyLevel: 'mid', difficultyLabel: '中等', storedValueAmount: 0, description: '综合力量训练课程,涵盖杠铃、哑铃等自由重量训练,适合有一定基础的学员。' }
loading: true,
submitting: false,
course: { id: 0, courseName: '加载中...', coachName: '', location: '', typeChar: '课', tag: '', category: '', categoryLabel: '', typeName: '', startTime: '', endTime: '', duration: 0, maxMembers: 0, currentMembers: 0, isFull: false, difficulty: 0, difficultyLevel: 'mid', difficultyLabel: '', storedValueAmount: 0, description: '' }
}
},
onLoad(options) {
@@ -142,12 +148,74 @@
if (options.id) this.loadCourseDetail(options.id)
},
methods: {
loadCourseDetail(id) {
const m = { 1: { courseName: '综合力量训练', coachName: '张教练', location: '力量区', typeChar: '力', tag: '热门', category: 'strength', categoryLabel: '力量训练', typeName: '自由重量杠铃/哑铃', startTime: '2026-07-15 10:00', endTime: '2026-07-15 10:45', duration: 45, maxMembers: 20, currentMembers: 8, isFull: false, difficulty: 5, difficultyLevel: 'mid', difficultyLabel: '中等', description: '综合力量训练课程,涵盖杠铃、哑铃等自由重量训练,适合有一定基础的学员。课程包括热身、主要力量训练组和拉伸放松三个环节,全面提升力量水平和身体协调性。' }, 2: { courseName: '流瑜伽 · 中级', coachName: '李教练', location: '3号教室', typeChar: '瑜', tag: '瑜伽', category: 'yoga', categoryLabel: '瑜伽', typeName: '柔韧与平衡类', startTime: '2026-07-15 14:00', endTime: '2026-07-15 15:00', duration: 60, maxMembers: 15, currentMembers: 15, isFull: true, difficulty: 5, difficultyLevel: 'mid', difficultyLabel: '中等', description: '流瑜伽中级课程,适合有一定瑜伽基础的学员。通过连贯的体式流动,提升身体柔韧性、核心力量和平衡能力。' }, 3: { courseName: '动感单车 · 冲刺', coachName: '王教练', location: '单车房', typeChar: '车', tag: '有氧', category: 'cardio', categoryLabel: '有氧运动', typeName: '基础有氧与热身', startTime: '2026-07-15 18:00', endTime: '2026-07-15 18:40', duration: 40, maxMembers: 25, currentMembers: 12, isFull: false, difficulty: 4, difficultyLevel: 'low', difficultyLabel: '入门', description: '高强度动感单车课程,结合节奏音乐,在教练的带领下进行冲刺、爬坡、耐力等训练。' }, 4: { courseName: 'HIIT训练', coachName: '赵教练', location: '多功能厅', typeChar: 'HI', tag: '有氧', category: 'cardio', categoryLabel: '有氧运动', typeName: '高强度与爆发力', startTime: '2026-07-15 12:00', endTime: '2026-07-15 12:30', duration: 30, maxMembers: 15, currentMembers: 10, isFull: false, difficulty: 7, difficultyLevel: 'high', difficultyLabel: '困难', description: '高强度间歇训练(HIIT),通过短时间高强度运动和短暂休息交替进行,高效燃脂并提升心肺功能。' }, 5: { courseName: '普拉提', coachName: '李教练', location: '2号教室', typeChar: '普', tag: '瑜伽', category: 'yoga', categoryLabel: '瑜伽', typeName: '柔韧与平衡类', startTime: '2026-07-15 16:00', endTime: '2026-07-15 16:50', duration: 50, maxMembers: 12, currentMembers: 5, isFull: false, difficulty: 4, difficultyLevel: 'low', difficultyLabel: '入门', description: '普拉提课程注重核心力量和身体控制,通过精准的动作改善体态、增强核心稳定性和身体柔韧性。' }, 6: { courseName: '搏击操', coachName: '刘教练', location: '搏击区', typeChar: '搏', tag: '有氧', category: 'cardio', categoryLabel: '有氧运动', typeName: '高强度与爆发力', startTime: '2026-07-15 07:00', endTime: '2026-07-15 07:45', duration: 45, maxMembers: 20, currentMembers: 6, isFull: false, difficulty: 5, difficultyLevel: 'mid', difficultyLabel: '中等', description: '搏击操结合拳击、踢拳等动作元素,在动感音乐下进行高强度有氧训练。' } }
if (m[id]) this.course = { ...this.course, ...m[id], id: parseInt(id) }
async loadCourseDetail(id) {
this.loading = true
try {
const detail = await courseApi.getCourseDetail(id)
if (detail) {
const bd = detail.calculatedDifficulty || detail.baseDifficulty || 5
const st = detail.startTime || ''
const et = detail.endTime || ''
const duration = st && et ? Math.round((new Date(et.replace(/-/g, '/')) - new Date(st.replace(/-/g, '/'))) / 60000) : 45
const tn = detail.typeName || (detail.typeInfo && detail.typeInfo.typeName) || ''
const cat = detail.typeCategory || (detail.typeInfo && detail.typeInfo.category) || ''
this.course = {
id: detail.id || parseInt(id),
courseName: detail.courseName || '未命名课程',
coachName: detail.coachName || '',
location: detail.location || '',
typeChar: tn.charAt(0) || '课',
tag: cat || '团课',
category: cat,
categoryLabel: cat,
typeName: tn,
startTime: st,
endTime: et,
duration: duration,
maxMembers: detail.maxMembers || 0,
currentMembers: detail.currentMembers || 0,
isFull: (detail.currentMembers || 0) >= (detail.maxMembers || 1) && detail.maxMembers > 0,
difficulty: bd,
difficultyLevel: bd <= 3 ? 'low' : bd <= 6 ? 'mid' : 'high',
difficultyLabel: bd <= 3 ? '入门' : bd <= 6 ? '中等' : '困难',
storedValueAmount: detail.storedValueAmount || 0,
description: detail.description || '暂无课程介绍',
labels: detail.labels || [],
coverImage: detail.coverImage || ''
}
}
} catch (e) {
console.error('加载课程详情失败:', e)
uni.showToast({ title: '加载课程详情失败', icon: 'none' })
} finally {
this.loading = false
}
},
goBack() { uni.navigateBack() },
handleBooking() { if (this.course.isFull) return; uni.showToast({ title: '预约成功', icon: 'success' }); setTimeout(() => { uni.navigateBack() }, 1000) }
async handleBooking() {
if (this.course.isFull || this.submitting) return
const memberInfo = store.getMemberInfo()
if (!memberInfo || !memberInfo.memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
this.submitting = true
try {
await bookingApi.bookCourse({
courseId: this.course.id,
memberId: memberInfo.memberId || memberInfo.id
})
uni.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => { uni.navigateBack() }, 1000)
} catch (e) {
console.error('预约失败:', e)
const msg = (e.data && e.data.message) || '预约失败,请重试'
uni.showToast({ title: msg, icon: 'none' })
} finally {
this.submitting = false
}
}
}
}
</script>
+64 -21
View File
@@ -11,10 +11,9 @@
<view class="content-inner">
<!-- 个人信息卡片深色+ 轮播图 -->
<view class="greeting-card">
<view class="greeting-header">
<view class="greeting-header" @click="goToProfile">
<view class="member-info">
<text class="member-name">你好{{ memberInfo.nickname }}</text>
<text class="member-no">{{ memberInfo.memberNo }}</text>
<text class="member-name">{{ memberInfo.nickname ? '你好,' + memberInfo.nickname : '欢迎您!点击前往更新您的信息。' }}</text>
</view>
<view class="checkin-badge" v-if="memberInfo.checkedInToday">
<text class="checkin-icon">&#10003;</text>
@@ -52,9 +51,7 @@
<view class="recommend-list">
<view v-for="(course, idx) in recommendCourses" :key="idx" class="recommend-card"
@click="goToCourseDetail(course.id)">
<view class="card-cover" :style="{ backgroundImage: 'url(' + course.imageUrl + ')' }">
<view class="card-tag" v-if="course.recommendTitle">{{ course.recommendTitle }}</view>
</view>
<view class="card-cover" :style="{ backgroundImage: 'url(' + course.imageUrl + ')' }"></view>
<view class="card-body">
<text class="card-name">{{ course.courseName }}</text>
<text class="card-reason">{{ course.recommendReason }}</text>
@@ -74,39 +71,35 @@
</template>
<script>
const store = require('../../store/index')
const memberApi = require('../../api/member')
const courseApi = require('../../api/course')
export default {
data() {
return {
statusBarHeight: 0,
navBarHeight: 44,
totalHeaderHeight: 44,
loading: true,
memberInfo: {
nickname: 'Jason',
memberNo: 'MEM20260715001',
checkedInToday: true,
totalSignDays: 128,
pendingBookings: 3
nickname: '加载中...',
checkedInToday: false,
totalSignDays: 0,
pendingBookings: 0
},
banners: [
{ imageUrl: 'https://img.zcool.cn/community/01f5c65e5d9c6ca801216518e1d2e0.jpg@1280w_1l_2o_100sh.jpg', title: '夏季燃脂计划', subtitle: 'HIIT + 动感单车,高效燃脂 7 天挑战' },
{ imageUrl: 'https://img.zcool.cn/community/01a5fe5e5d9c4ba80121985ac87c5f.jpg@1280w_1l_2o_100sh.jpg', title: '瑜伽月卡特惠', subtitle: '新人专享 5 折,流瑜伽 / 普拉提任选' },
{ imageUrl: 'https://img.zcool.cn/community/0177c85e5d9c84a8012165182ef63f.jpg@1280w_1l_2o_100sh.jpg', title: '私教一对一体验', subtitle: '免费体测 + 定制训练计划,限 30 名' }
],
recommendCourses: [
{ id: 1, courseName: '极速燃脂单车', recommendTitle: '热门', tagColor: '', imageUrl: 'https://img.zcool.cn/community/01f5c65e5d9c6ca801216518e1d2e0.jpg@1280w_1l_2o_100sh.jpg', recommendReason: '教练专业 · 燃脂效果显著', priceLabel: '¥50', bookedCount: 186 },
{ id: 2, courseName: '清晨流瑜伽', recommendTitle: '新手推荐', tagColor: '', imageUrl: 'https://img.zcool.cn/community/01a5fe5e5d9c4ba80121985ac87c5f.jpg@1280w_1l_2o_100sh.jpg', recommendReason: '教练耐心 · 节奏适中', priceLabel: '¥50', bookedCount: 142 },
{ id: 3, courseName: '核心力量训练', recommendTitle: '塑形', tagColor: '', imageUrl: 'https://img.zcool.cn/community/0165ed5e5d9c6ca80121985a0733a5.jpg@1280w_1l_2o_100sh.jpg', recommendReason: '核心专项 · 适合所有水平', priceLabel: '¥50', bookedCount: 97 },
{ id: 4, courseName: 'HIIT燃脂训练', recommendTitle: '高强度', tagColor: '', imageUrl: 'https://img.zcool.cn/community/0148fa5e5d9c4ba80121985a1b52e4.jpg@1280w_1l_2o_100sh.jpg', recommendReason: '挑战心肺极限 · 快速燃脂', priceLabel: '¥50', bookedCount: 211 },
{ id: 5, courseName: '晚间普拉提', recommendTitle: '新上', tagColor: '', imageUrl: 'https://img.zcool.cn/community/01c3b25e5d9c6ca80121985ab2b7e6.jpg@1280w_1l_2o_100sh.jpg', recommendReason: '核心力量 · 放松一天疲劳', priceLabel: '¥50', bookedCount: 68 },
{ id: 6, courseName: '周末瑜伽', recommendTitle: '进阶', tagColor: '', imageUrl: 'https://img.zcool.cn/community/0177c85e5d9c84a8012165182ef63f.jpg@1280w_1l_2o_100sh.jpg', recommendReason: '有基础的进阶课程', priceLabel: '¥50', bookedCount: 55 }
]
recommendCourses: []
}
},
onLoad() {
const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 20
let navBarHeight = 44
// 获取胶囊按钮信息,对齐导航栏高度
// #ifdef MP-WEIXIN
const menuButton = uni.getMenuButtonBoundingClientRect()
if (menuButton) {
@@ -117,9 +110,59 @@
this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight
},
onShow() {
if (!store.isLoggedIn) {
// 未登录时不重定向,登录页是首入口,只需跳过数据加载
return
}
this.loadData()
},
methods: {
goToCourseDetail(id) { uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + id }) },
goToSearch() { uni.switchTab({ url: '/pages/search/search' }) }
goToSearch() { uni.switchTab({ url: '/pages/search/search' }) },
goToProfile() { uni.switchTab({ url: '/pages/profile/profile' }) },
async loadData() {
this.loading = true
try {
// 并行获取会员信息和推荐课程
const [memberResult, recommendResult] = await Promise.allSettled([
memberApi.getMemberInfo(),
courseApi.getActiveRecommendations()
])
// 处理会员信息
if (memberResult.status === 'fulfilled') {
const info = memberResult.value
store.updateMemberInfo(info)
this.memberInfo = {
nickname: info.nickname || '',
checkedInToday: info.checkedInToday || false,
totalSignDays: info.totalSignInDays || 0,
pendingBookings: info.pendingBookings || 0
}
}
// 处理推荐课程(数据来自 group_course_recommend 表,关联 groupCourse 子对象)
if (recommendResult.status === 'fulfilled') {
this.recommendCourses = (recommendResult.value || []).map(r => {
const gc = r.groupCourse || {}
return {
id: gc.id || r.courseId,
courseName: gc.courseName || '推荐课程',
imageUrl: gc.coverImage || '',
recommendReason: r.recommendReason || '',
priceLabel: (gc.storedValueAmount > 0 ? '¥' + gc.storedValueAmount : '免费'),
bookedCount: gc.currentMembers || 0
}
})
}
} catch (e) {
console.error('首页数据加载失败:', e)
} finally {
this.loading = false
}
}
}
}
</script>
+51 -2
View File
@@ -48,20 +48,69 @@
</template>
<script>
const authApi = require('../../api/auth')
const store = require('../../store/index')
export default {
data() {
return {
statusBarHeight: 0,
agreed: false
agreed: false,
loading: false
}
},
onLoad() {
const systemInfo = uni.getSystemInfoSync()
this.statusBarHeight = systemInfo.statusBarHeight || 20
// 已登录则直接跳转首页
if (store.isLoggedIn) {
uni.switchTab({ url: '/pages/index/index' })
}
},
methods: {
toggleAgree() { this.agreed = !this.agreed },
handleLogin() { uni.switchTab({ url: '/pages/index/index' }) }
handleLogin() {
if (!this.agreed) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
return
}
if (this.loading) return
this.loading = true
// 微信小程序登录
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',
success: (loginRes) => {
authApi.miniappLogin(loginRes.code)
.then((res) => {
store.setLogin(res.accessToken, { memberId: res.memberId, isNewUser: res.isNewUser })
uni.switchTab({ url: '/pages/index/index' })
})
.catch((err) => {
console.error('登录失败:', err)
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
})
.finally(() => { this.loading = false })
},
fail: (err) => {
console.error('wx.login 失败:', err)
uni.showToast({ title: '获取微信授权失败', icon: 'none' })
this.loading = false
}
})
// #endif
// #ifndef MP-WEIXIN
// H5 / App 环境:使用模拟登录(开发调试)
uni.showToast({ title: '非小程序环境,使用模拟登录', icon: 'none' })
setTimeout(() => {
store.setLogin('dev-token-' + Date.now(), { memberId: 1, isNewUser: false })
uni.switchTab({ url: '/pages/index/index' })
this.loading = false
}, 500)
// #endif
}
}
}
</script>
+78 -79
View File
@@ -11,82 +11,44 @@
<view class="content-inner">
<view class="user-card">
<view class="user-row">
<view class="big-avatar"><text>J</text></view>
<view class="big-avatar"><text>{{ avatarText }}</text></view>
<view class="user-info">
<text class="user-name">{{ userInfo.nickname }}</text>
<view class="user-tag">
<text class="tag-symbol">&#9733;</text>
<text>{{ userInfo.membershipLevel }}</text>
<text>{{ userInfo.memberNo }}</text>
</view>
</view>
<text class="edit-symbol" @click="editProfile">&#9998;</text>
</view>
<view class="user-details">
<view class="detail-row">
<text class="detail-label">会员编号</text>
<text class="detail-value">{{ userInfo.memberNo }}</text>
</view>
<view class="detail-row">
<text class="detail-label">手机号</text>
<text class="detail-value">{{ userInfo.phone }}</text>
</view>
<view class="detail-row">
<text class="detail-label">性别</text>
<text class="detail-value">{{ userInfo.gender === 1 ? '男' : userInfo.gender === 2 ? '女' : '未设置' }}</text>
<text class="detail-value">{{ genderLabel }}</text>
</view>
<view class="detail-row">
<text class="detail-label">生日</text>
<text class="detail-value">{{ userInfo.birthday || '未设置' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">上次登录</text>
<text class="detail-value">{{ userInfo.lastLoginAt || '暂无' }}</text>
</view>
</view>
</view>
<view class="stats-card">
<view class="stat-item">
<text class="stat-number">{{ userInfo.totalCourses }}</text>
<text class="stat-label">总课时</text>
<text class="stat-number">{{ userInfo.totalSignInDays }}</text>
<text class="stat-label">累计签到()</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-number">{{ userInfo.streakDays }}</text>
<text class="stat-label">连续打卡</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<view class="stat-rating-row">
<text class="stat-number">{{ userInfo.rating }}</text>
<text class="star-icon">&#9733;</text>
</view>
<text class="stat-label">评分</text>
</view>
</view>
<view class="card-section">
<view class="section-title-row">
<text class="section-symbol">&#9670;</text>
<text class="section-title">我的会员卡</text>
</view>
<scroll-view class="card-scroll" scroll-x>
<view v-for="(card, idx) in memberCards" :key="idx" class="member-card-item"
:class="'card-type-' + card.cardType">
<text class="card-name">{{ card.cardName }}</text>
<text class="card-type-label">{{ card.typeLabel }}</text>
<view class="card-details">
<text v-if="card.remainingTimes !== null" class="card-remain">剩余 <text class="card-remain-num">{{ card.remainingTimes }}</text> </text>
<text v-if="card.remainingAmount !== null" class="card-remain">余额 <text class="card-remain-num">{{ card.remainingAmount }}</text></text>
<text v-if="card.expireTime" class="card-expire">有效期至 {{ card.expireTime }}</text>
</view>
<view class="card-status" :class="card.status">{{ card.statusLabel }}</view>
</view>
</scroll-view>
</view>
<view class="menu-section">
<view class="menu-grid">
<view class="menu-item" v-for="(menu, idx) in menus" :key="idx" @click="handleMenuClick(menu.key)">
<text class="menu-symbol">{{ menu.symbol }}</text>
<text class="menu-label">{{ menu.label }}</text>
</view>
<text class="stat-number">{{ userInfo.monthSignInCount }}</text>
<text class="stat-label">本月签到</text>
</view>
</view>
@@ -105,6 +67,7 @@
</view>
<view class="sign-right">
<text class="sign-type">{{ record.typeLabel }}</text>
<text class="sign-source">{{ record.sourceLabel }}</text>
<text class="sign-status" :class="record.status">{{ record.statusLabel }}</text>
</view>
</view>
@@ -116,16 +79,18 @@
</template>
<script>
const store = require('../../store/index')
const memberApi = require('../../api/member')
export default {
data() {
return {
statusBarHeight: 0,
navBarHeight: 44,
totalHeaderHeight: 44,
userInfo: { nickname: 'Jason Wang', membershipLevel: '高级会员', memberNo: 'MEM2024A7K3', phone: '138****8888', gender: 1, birthday: '1995-06-15', totalCourses: 68, streakDays: 12, rating: '4.8' },
memberCards: [{ cardName: '年卡 · 无限次', cardType: 'time', typeLabel: '时长卡', expireTime: '2027-06-15', status: 'active', statusLabel: '有效' }, { cardName: '储值卡 · 白金', cardType: 'stored', typeLabel: '储值卡', remainingAmount: 2580, expireTime: '2027-06-15', status: 'active', statusLabel: '有效' }, { cardName: '10次体验卡', cardType: 'count', typeLabel: '次卡', remainingTimes: 3, expireTime: '2026-12-31', status: 'active', statusLabel: '有效' }],
menus: [{ symbol: '&#9776;', label: '训练数据', key: 'data' }, { symbol: '&#9733;', label: '成就徽章', key: 'achievement' }, { symbol: '&#9881;', label: '账户设置', key: 'settings' }, { symbol: '&#9636;', label: '我的预约', key: 'booking' }, { symbol: '&#9670;', label: '购卡记录', key: 'cardPurchase' }, { symbol: '&#63;', label: '帮助中心', key: 'help' }],
signInRecords: [{ date: '07-15', time: '09:30', typeLabel: '扫码签到', status: 'success', statusLabel: '成功' }, { date: '07-14', time: '18:00', typeLabel: '扫码签到', status: 'success', statusLabel: '成功' }, { date: '07-13', time: '10:15', typeLabel: '手动签到', status: 'success', statusLabel: '成功' }, { date: '07-12', time: '08:45', typeLabel: '扫码签到', status: 'success', statusLabel: '成功' }]
loading: true,
userInfo: { nickname: '加载中...', memberNo: '', phone: '', gender: 0, birthday: '', lastLoginAt: '', totalSignInDays: 0, monthSignInCount: 0 },
signInRecords: []
}
},
onLoad() {
@@ -142,7 +107,62 @@
this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight
},
methods: { editProfile() { uni.showToast({ title: '编辑资料', icon: 'none' }) }, goToSettings() { uni.showToast({ title: '设置页面', icon: 'none' }) }, handleMenuClick(key) { uni.showToast({ title: '功能: ' + key, icon: 'none' }) }, viewAllSignIn() { uni.showToast({ title: '全部签到记录', icon: 'none' }) } }
onShow() {
if (!store.isLoggedIn) {
return
}
this.loadData()
},
methods: {
async loadData() {
this.loading = true
try {
const [memberResult, signInResult] = await Promise.allSettled([
memberApi.getMemberInfo(),
memberApi.getCheckInRecords({ page: 0, size: 5 })
])
if (memberResult.status === 'fulfilled') {
const info = memberResult.value
this.userInfo = {
nickname: info.nickname || '会员',
memberNo: info.memberNo || ('ID' + (info.id || info.memberId || '')),
phone: info.phone ? info.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '未绑定',
gender: info.gender || 0,
birthday: info.birthday || '',
lastLoginAt: info.lastLoginAt || '',
totalSignInDays: info.totalSignInDays || 0,
monthSignInCount: info.monthSignInCount || 0
}
}
if (signInResult.status === 'fulfilled') {
const records = Array.isArray(signInResult.value)
? signInResult.value
: (signInResult.value.content || [])
this.signInRecords = records.map(r => ({
date: (r.checkInTime || r.createdAt || '').substring(5, 10) || '--',
time: (r.checkInTime || r.createdAt || '').substring(11, 16) || '--',
typeLabel: r.typeLabel || '签到',
sourceLabel: r.sourceLabel || '小程序',
status: r.status === 'SUCCESS' || r.status === 1 ? 'success' : 'fail',
statusLabel: r.status === 'SUCCESS' || r.status === 1 ? '成功' : '失败'
}))
}
} catch (e) {
console.error('个人中心数据加载失败:', e)
} finally {
this.loading = false
}
},
editProfile() { uni.showToast({ title: '编辑资料', icon: 'none' }) },
goToSettings() { uni.showToast({ title: '设置页面', icon: 'none' }) },
viewAllSignIn() { uni.showToast({ title: '全部签到记录', icon: 'none' }) }
},
computed: {
avatarText() { return (this.userInfo.nickname || '?').charAt(0) },
genderLabel() { const g = this.userInfo.gender; return g === 1 ? '男' : g === 2 ? '女' : '未设置' }
}
}
</script>
@@ -158,8 +178,7 @@
.big-avatar { width: 56px; height: 56px; background: linear-gradient(135deg, #00E676, #00BFA5); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 24px; color: #1A1A1A; flex-shrink: 0; margin-right: 14px; }
.user-info { flex: 1; display: flex; flex-direction: column; }
.user-name { font-size: 18px; font-weight: 700; color: #1E1E1E; }
.user-tag { font-size: 13px; color: #00C853; font-weight: 500; display: flex; align-items: center; }
.tag-symbol { font-size: 12px; margin-right: 4px; }
.user-tag { font-size: 13px; color: #00C853; font-weight: 500; }
.edit-symbol { font-size: 20px; color: #7A7E84; }
.user-details { background: #F5F7FA; border-radius: 12px; padding: 14px 16px; display: flex; flex-direction: column; }
.detail-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
@@ -170,31 +189,9 @@
.stat-number { font-size: 24px; font-weight: 700; color: #00E676; }
.stat-label { font-size: 12px; color: rgba(255,255,255,0.6); margin-top: 4px; }
.stat-divider { width: 1px; background: rgba(255,255,255,0.15); }
.stat-rating-row { display: flex; align-items: center; }
.star-icon { font-size: 14px; color: #00E676; }
.card-section { margin-bottom: 16px; }
.section-title-row { display: flex; align-items: center; margin-bottom: 12px; }
.section-symbol { font-size: 16px; color: #00E676; margin-right: 8px; }
.section-title { font-size: 16px; font-weight: 700; color: #1E1E1E; }
.card-scroll { display: flex; white-space: nowrap; }
.member-card-item { display: inline-block; width: 200px; background: #FFFFFF; border-radius: 20px; padding: 16px; margin-right: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); position: relative; overflow: hidden; }
.member-card-item::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 4px; }
.card-type-time::before { background: #00E676; }
.card-type-stored::before { background: linear-gradient(135deg, #FFA502, #FF6B4A); }
.card-type-count::before { background: #4A90D9; }
.card-name { font-size: 15px; font-weight: 700; color: #1E1E1E; display: block; margin-top: 4px; }
.card-type-label { font-size: 11px; color: #7A7E84; background: #F5F7FA; padding: 2px 8px; border-radius: 10px; margin-top: 4px; display: inline-block; }
.card-details { margin-top: 12px; display: flex; flex-direction: column; }
.card-remain { font-size: 13px; color: #7A7E84; }
.card-remain-num { font-size: 16px; font-weight: 700; color: #00C853; margin: 0 2px; }
.card-expire { font-size: 11px; color: #BDBDBD; }
.card-status { position: absolute; top: 12px; right: 12px; font-size: 11px; font-weight: 600; padding: 2px 10px; border-radius: 20px; }
.card-status.active { background: rgba(0,230,118,0.15); color: #00C853; }
.menu-section { margin-bottom: 16px; }
.menu-grid { display: flex; flex-wrap: wrap; }
.menu-item { width: calc(33.33% - 8px); background: #FFFFFF; border-radius: 12px; padding: 16px 0; text-align: center; box-shadow: 0 4px 12px rgba(0,0,0,0.06); display: flex; flex-direction: column; align-items: center; margin-right: 12px; margin-bottom: 12px; }
.menu-symbol { font-size: 22px; color: #7A7E84; }
.menu-label { font-size: 13px; font-weight: 500; color: #7A7E84; }
.sign-section { margin-bottom: 16px; }
.sign-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.sign-header .section-title-row { margin-bottom: 0; }
@@ -204,8 +201,10 @@
.sign-date { font-size: 14px; font-weight: 600; color: #1E1E1E; }
.sign-time { font-size: 12px; color: #7A7E84; margin-top: 2px; }
.sign-right { display: flex; align-items: center; }
.sign-type { font-size: 12px; color: #7A7E84; margin-right: 10px; }
.sign-type { font-size: 12px; color: #7A7E84; margin-right: 6px; }
.sign-source { font-size: 11px; color: #BDBDBD; background: #F5F7FA; padding: 1px 6px; border-radius: 8px; margin-right: 6px; }
.sign-status { font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 20px; }
.sign-status.success { background: rgba(0,230,118,0.15); color: #00C853; }
.sign-status.fail { background: rgba(255,82,82,0.15); color: #FF5252; }
.bottom-safe { height: 50px; }
</style>
+169 -73
View File
@@ -24,15 +24,11 @@
</view>
</view>
<view class="period-selector">
<text class="period-label">时段</text>
<scroll-view class="period-scroll" scroll-x>
<view v-for="(period, idx) in periods" :key="idx" class="period-item"
:class="{ active: currentPeriod === period.value }" @click="selectPeriod(period.value)">
<text class="period-symbol">{{ period.symbol }}</text>
<text>{{ period.label }}</text>
</view>
</scroll-view>
<view class="period-row">
<view v-for="(period, idx) in periods" :key="idx" class="period-item"
:class="{ active: currentPeriod === period.value }" @click="selectPeriod(period.value)">
<text>{{ period.label }}</text>
</view>
</view>
</view>
</view>
@@ -58,34 +54,48 @@
<scroll-view class="course-list" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px - 104px)' }">
<view class="course-list-inner">
<view v-for="(course, idx) in filteredCourses" :key="idx" class="course-card" @click="goToDetail(course)">
<view class="course-cover">
<text class="cover-text">{{ course.typeChar }}</text>
</view>
<view class="course-body">
<text class="course-name">{{ course.courseName }}</text>
<view class="course-row">
<text class="course-coach">教练 {{ course.coachName }}</text>
<text class="course-difficulty" :class="'diff-' + course.difficultyLevel">{{ course.difficultyLabel }}</text>
</view>
<view class="course-row">
<text class="course-time">日期 {{ course.startTime }}</text>
</view>
<view class="course-row">
<text class="course-location">{{ course.location }}</text>
<text class="course-capacity">{{ course.currentMembers }}/{{ course.maxMembers }}</text>
</view>
<view class="course-bottom">
<text class="course-price" :class="{ free: course.storedValueAmount === 0 }">
{{ course.storedValueAmount > 0 ? course.storedValueAmount + '元/次' : '免费' }}
</text>
<button class="btn-book" :class="{ disabled: course.isFull }" :disabled="course.isFull"
@click.stop="bookCourse(course)">
{{ course.isFull ? '已约满' : '预约' }}
</button>
<view v-for="(course, idx) in filteredCourses" :key="idx" class="course-item" @click="goToDetail(course)">
<view class="course-card">
<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>
<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>
</view>
<view class="course-meta">
<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="course-location">{{ course.location }}</text>
</view>
<view class="course-meta">
<text class="course-capacity">已预约 {{ course.currentMembers }}/{{ course.maxMembers }}</text>
</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>
</view>
<view v-if="filteredCourses.length === 0" class="empty-state">
<text class="empty-symbol">&#8857;</text>
@@ -99,40 +109,35 @@
</template>
<script>
const courseApi = require('../../api/course')
export default {
data() {
return {
statusBarHeight: 0,
navBarHeight: 44,
totalHeaderHeight: 44,
loading: true,
searchKeyword: '', startDate: '', endDate: '',
currentPeriod: 'all', currentFilter: 'all', currentSort: 'time',
periods: [
{ label: '全部', value: 'all', symbol: '&#9716;' },
{ label: '早晨', value: 'morning', symbol: '&#9728;' },
{ label: '上午', value: 'forenoon', symbol: '&#9728;' },
{ label: '下午', value: 'afternoon', symbol: '&#9788;' },
{ label: '晚间', value: 'evening', symbol: '&#9790;' }
{ label: '全部', value: 'all' },
{ label: '早晨', value: 'morning' },
{ label: '上午', value: 'forenoon' },
{ label: '下午', value: 'afternoon' },
{ label: '晚间', value: 'evening' }
],
filterTabs: [
{ label: '全部', value: 'all' }, { label: '力量区', value: 'strength' },
{ label: '有氧', value: 'cardio' }, { label: '瑜伽', value: 'yoga' },
{ label: '私教', value: 'pt' }, { label: '团课', value: 'group' },
{ label: '免费', value: 'free' }
{ label: '全部', value: 'all' }, { label: '有氧运动', value: '有氧运动' },
{ label: '高强度', value: '高强度' }, { label: '柔韧平衡', value: '柔韧平衡' },
{ label: '力量训练', value: '力量训练' }, { label: '免费', value: 'free' }
],
sortOptions: [
{ label: '时间', value: 'time' },
{ label: '难度', value: 'difficulty' },
{ label: '热度', value: 'popular' }
],
courses: [
{ id: 1, courseName: '综合力量训练', coachName: '张教练', location: '力量区', category: 'strength', typeChar: '力', startTime: '07-15 10:00', endTime: '07-15 10:45', maxMembers: 20, currentMembers: 8, isFull: false, difficulty: 5, difficultyLevel: 'mid', difficultyLabel: '中等', period: 'forenoon', storedValueAmount: 0 },
{ id: 2, courseName: '流瑜伽 · 中级', coachName: '李教练', location: '3号教室', category: 'yoga', typeChar: '瑜', startTime: '07-15 14:00', endTime: '07-15 15:00', maxMembers: 15, currentMembers: 15, isFull: true, difficulty: 5, difficultyLevel: 'mid', difficultyLabel: '中等', period: 'afternoon', storedValueAmount: 0 },
{ id: 3, courseName: '动感单车 · 冲刺', coachName: '王教练', location: '单车房', category: 'cardio', typeChar: '车', startTime: '07-15 18:00', endTime: '07-15 18:40', maxMembers: 25, currentMembers: 12, isFull: false, difficulty: 4, difficultyLevel: 'low', difficultyLabel: '入门', period: 'evening', storedValueAmount: 0 },
{ id: 4, courseName: 'HIIT训练', coachName: '赵教练', location: '多功能厅', category: 'cardio', typeChar: 'HI', startTime: '07-15 12:00', endTime: '07-15 12:30', maxMembers: 15, currentMembers: 10, isFull: false, difficulty: 7, difficultyLevel: 'high', difficultyLabel: '困难', period: 'forenoon', storedValueAmount: 0 },
{ id: 5, courseName: '普拉提', coachName: '李教练', location: '2号教室', category: 'yoga', typeChar: '普', startTime: '07-15 16:00', endTime: '07-15 16:50', maxMembers: 12, currentMembers: 5, isFull: false, difficulty: 4, difficultyLevel: 'low', difficultyLabel: '入门', period: 'afternoon', storedValueAmount: 0 },
{ id: 6, courseName: '搏击操', coachName: '刘教练', location: '搏击区', category: 'cardio', typeChar: '搏', startTime: '07-15 07:00', endTime: '07-15 07:45', maxMembers: 20, currentMembers: 6, isFull: false, difficulty: 5, difficultyLevel: 'mid', difficultyLabel: '中等', period: 'morning', storedValueAmount: 0 }
]
courses: []
}
},
onLoad() {
@@ -148,32 +153,112 @@
this.statusBarHeight = statusBarHeight
this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight
this.loadCourses()
},
computed: {
filteredCourses() {
let list = this.courses
let list = this.courses.map(c => {
const bd = c.baseDifficulty || c.calculatedDifficulty || 5
const st = c.startTime || ''
const et = c.endTime || ''
// 兼容 "2026-07-15T16:45:00" 和 "2026-07-15 16:45:00" 两种格式
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 md = dt && !isNaN(dt) ? String(dt.getMonth() + 1).padStart(2, '0') : '--'
const dd = dt && !isNaN(dt) ? String(dt.getDate()).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') : '--'
// 计算持续时间(分钟)
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 + '分钟'
}
}
const statusStr = c.status != null ? Number(c.status) : 0
const curMembers = c.currentMembers != null ? c.currentMembers : 0
const maxMems = c.maxMembers != null ? c.maxMembers : 0
const isFull = maxMems > 0 && curMembers >= maxMems
let statusLabel = '正常'
let canBook = statusStr === 0 && !isFull
if (statusStr === 1) statusLabel = '已取消'
else if (statusStr === 2) statusLabel = '已结束'
else if (statusStr !== 0) statusLabel = '已关闭'
if (!canBook && statusStr === 0 && isFull) statusLabel = '已约满'
return {
...c,
currentMembers: curMembers,
maxMembers: maxMems,
isFull: isFull,
canBook: canBook,
difficultyLevel: bd <= 3 ? 'low' : bd <= 6 ? 'mid' : 'high',
difficultyLabel: bd <= 3 ? '入门' : bd <= 6 ? '中等' : '困难',
baseDifficulty: bd,
shortDate: md + '-' + dd,
shortTime: hh + ':' + mm,
endShortTime: ehh + ':' + emm,
duration: durationStr,
period: dt && !isNaN(dt) ? (hh < 8 ? 'morning' : hh < 12 ? 'forenoon' : hh < 17 ? 'afternoon' : 'evening') : 'all',
category: c.category || c.typeCategory || '',
typeName: c.typeName || (c.typeInfo && c.typeInfo.typeName) || '',
coachName: c.coachName || (c.typeInfo && c.typeInfo.coachName) || '',
location: c.location || '',
status: statusStr,
statusLabel: statusLabel,
storedValueAmount: c.storedValueAmount != null ? c.storedValueAmount : 0,
labels: c.labels || [],
coverImage: c.coverImage || ''
}
})
if (this.searchKeyword) {
const kw = this.searchKeyword.toLowerCase()
list = list.filter(c => c.courseName.toLowerCase().includes(kw) || c.coachName.toLowerCase().includes(kw) || c.location.toLowerCase().includes(kw))
list = list.filter(c =>
(c.courseName || '').toLowerCase().includes(kw) ||
(c.coachName || '').toLowerCase().includes(kw) ||
(c.location || '').toLowerCase().includes(kw)
)
}
if (this.currentFilter === 'free') list = list.filter(c => c.storedValueAmount === 0)
else if (this.currentFilter !== 'all') list = list.filter(c => c.category === this.currentFilter)
if (this.currentPeriod !== 'all') list = list.filter(c => c.period === this.currentPeriod)
if (this.currentSort === 'difficulty') list = [...list].sort((a, b) => a.difficulty - b.difficulty)
else if (this.currentSort === 'popular') list = [...list].sort((a, b) => b.currentMembers - a.currentMembers)
if (this.currentSort === 'difficulty') list = [...list].sort((a, b) => a.baseDifficulty - b.baseDifficulty)
else if (this.currentSort === 'popular') list = [...list].sort((a, b) => (b.currentMembers || 0) - (a.currentMembers || 0))
return list
}
},
methods: {
async loadCourses() {
this.loading = true
try {
const res = await courseApi.getCoursesByPage({ page: 0, size: 50 })
// 兼容 PageResponse 格式 { content: [...], totalElements, ... } 或直接数组
const list = Array.isArray(res) ? res : (res.content || [])
this.courses = list
} catch (e) {
console.error('加载课程列表失败:', e)
} finally {
this.loading = false
}
},
onSearchInput() {},
clearSearch() { this.searchKeyword = '' },
onCoverLoad(course) { course.coverLoaded = true },
onCoverError(course) { course.coverError = true },
openStartDatePicker() { uni.showToast({ title: '选择开始日期', icon: 'none' }) },
openEndDatePicker() { uni.showToast({ title: '选择结束日期', icon: 'none' }) },
selectPeriod(value) { this.currentPeriod = value },
switchFilter(value) { this.currentFilter = value },
switchSort(value) { this.currentSort = value },
goToDetail(course) { uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + course.id }) },
bookCourse(course) { if (!course.isFull) uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + course.id }) }
bookCourse(course) { if (course.canBook) uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + course.id }) }
}
}
</script>
@@ -184,7 +269,7 @@
.nav-bar { background: #1A1A1A; padding: 0 20px; display: flex; align-items: center; }
.nav-title { font-size: 18px; font-weight: 700; color: #FFFFFF; }
.search-area { background: #1A1A1A; padding: 0 16px 20px; }
.search-area { background: #1A1A1A; padding: 18px 16px 20px; }
.search-box { background: rgba(255,255,255,0.12); border-radius: 40px; padding: 0 16px; display: flex; align-items: center; height: 42px; margin-bottom: 14px; }
.search-symbol { font-size: 15px; color: rgba(255,255,255,0.5); flex-shrink: 0; margin-right: 8px; }
.search-input { flex: 1; font-size: 14px; color: #FFFFFF; height: 100%; }
@@ -197,12 +282,9 @@
.range-value.placeholder { color: rgba(255,255,255,0.35); font-weight: 400; }
.range-sep { color: rgba(255,255,255,0.5); font-size: 14px; flex-shrink: 0; margin-right: 10px; }
.period-selector { display: flex; align-items: center; }
.period-label { font-size: 13px; color: rgba(255,255,255,0.6); flex-shrink: 0; margin-right: 10px; }
.period-scroll { flex: 1; display: flex; white-space: nowrap; }
.period-item { display: inline-flex; align-items: center; padding: 6px 14px; border-radius: 40px; font-size: 13px; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.08); margin-right: 8px; }
.period-row { display: flex; }
.period-item { flex: 1; text-align: center; padding: 7px 0; border-radius: 40px; font-size: 13px; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.08); margin: 0 3px; }
.period-item.active { background: #00E676; color: #1A1A1A; font-weight: 600; }
.period-symbol { font-size: 13px; margin-right: 4px; }
.filter-tabs { background: #FFFFFF; display: flex; white-space: nowrap; border-bottom: 1px solid #EEEEEE; }
.filter-tabs-inner { padding: 14px 16px; display: flex; white-space: nowrap; }
@@ -216,21 +298,35 @@
.sort-item.active { color: #00C853; font-weight: 600; }
.course-list-inner { padding: 12px 16px 0; }
.course-card { background: #FFFFFF; border-radius: 20px; padding: 14px; margin-bottom: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); display: flex; }
.course-cover { width: 72px; height: 72px; border-radius: 12px; background: rgba(0,230,118,0.15); display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-right: 14px; }
.cover-text { font-size: 24px; font-weight: 700; color: #00C853; }
.course-body { flex: 1; display: flex; flex-direction: column; }
.course-name { font-size: 16px; font-weight: 700; color: #1E1E1E; }
.course-row { display: flex; align-items: center; font-size: 12px; color: #7A7E84; }
.course-row > * { margin-right: 12px; }
.course-item { margin-bottom: 12px; border-radius: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); overflow: hidden; }
.course-card { background: #FFFFFF; border-radius: 20px; padding: 14px; display: flex; }
.course-cover { width: 72px; height: 72px; border-radius: 12px; background: rgba(0,230,118,0.12); display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-right: 14px; overflow: hidden; position: relative; }
.cover-skeleton { position: absolute; inset: 0; background: linear-gradient(90deg, #E8E8E8 25%, #F0F0F0 50%, #E8E8E8 75%); background-size: 200% 100%; animation: shimmer 1.5s 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.3s; z-index: 0; }
.cover-img.loaded { opacity: 1; z-index: 2; }
.cover-text { font-size: 22px; font-weight: 700; color: #00C853; }
.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: 16px; font-weight: 700; color: #1E1E1E; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.course-tags { display: flex; flex-shrink: 0; margin-left: 6px; }
.course-category { font-size: 11px; color: #00C853; background: rgba(0,230,118,0.12); padding: 1px 8px; border-radius: 20px; font-weight: 500; margin-right: 4px; }
.course-difficulty { padding: 1px 8px; border-radius: 20px; font-size: 11px; font-weight: 600; }
.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; }
.course-bottom { display: flex; justify-content: space-between; align-items: center; margin-top: 4px; }
.course-price { font-size: 13px; font-weight: 600; color: #00C853; }
.course-meta { display: flex; align-items: center; font-size: 12px; color: #7A7E84; margin-top: 4px; }
.course-date { flex-shrink: 0; }
.course-time { margin-left: 8px; }
.course-duration { margin-left: 8px; color: #00C853; font-weight: 500; }
.course-location { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.course-capacity { color: #7A7E84; }
.course-labels { display: flex; flex-wrap: wrap; margin-top: 6px; }
.course-label { font-size: 10px; padding: 1px 6px; border-radius: 10px; margin-right: 4px; margin-bottom: 2px; }
.course-bottom { text-align: right; margin-top: 6px; }
.course-price { font-size: 18px; font-weight: 700; color: #00C853; display: block; margin-top: 6px; }
.course-price.free { color: #00C853; }
.btn-book { background: #00E676; color: #1A1A1A; border: none; padding: 6px 16px; border-radius: 40px; font-weight: 700; font-size: 13px; line-height: 1.4; box-shadow: 0 2px 6px rgba(0,230,118,0.3); }
.btn-book { background: #00E676; color: #1A1A1A; border: none; padding: 6px 16px; border-radius: 40px; font-weight: 700; font-size: 13px; line-height: 1.4; box-shadow: 0 2px 8px rgba(0,230,118,0.3); }
.btn-book::after { border: none; }
.btn-book.disabled { background: #E0E0E0; color: #BDBDBD; box-shadow: none; }