完善业务闭环,实现对轮播图的管理,优化数据库表文件

This commit is contained in:
2026-07-18 16:21:40 +08:00
parent feaf014b4a
commit 97a5eff678
78 changed files with 3210 additions and 100 deletions
+16
View File
@@ -0,0 +1,16 @@
const http = require('../utils/request')
/** 获取启用的轮播图列表(按 sortOrder 升序) */
function getActiveBanners() {
return http.get('/banners/active').then(res => {
console.log('[Banner API] 响应数据:', JSON.stringify(res))
return res
}).catch(err => {
console.error('[Banner API] 请求失败:', err)
throw err
})
}
module.exports = {
getActiveBanners
}
+2 -2
View File
@@ -6,8 +6,8 @@ function bookCourse(params) {
}
/** 取消预约 */
function cancelBooking(bookingId) {
return http.post('/groupCourse/booking/' + bookingId + '/cancel')
function cancelBooking(bookingId, memberId) {
return http.post('/groupCourse/booking/' + bookingId + '/cancel', { memberId })
}
/** 获取会员的预约列表 */
+2
View File
@@ -58,6 +58,8 @@
"selectedColor": "#00E676",
"backgroundColor": "#1A1A1A",
"borderStyle": "black",
"iconWidth": "24px",
"iconHeight": "24px",
"list": [
{
"pagePath": "pages/index/index",
@@ -128,6 +128,7 @@
const courseApi = require('../../api/course')
const bookingApi = require('../../api/booking')
const store = require('../../store/index')
const { resolveCoverUrl } = require('../../utils/request')
export default {
data() {
@@ -279,7 +280,7 @@
storedValueAmount: detail.storedValueAmount != null ? detail.storedValueAmount : 0,
statusText: statusText,
statusClass: statusClass,
coverImage: detail.coverImage || '',
coverImage: resolveCoverUrl(detail.coverImage),
coverError: false
}
}
+25 -9
View File
@@ -74,6 +74,8 @@
const store = require('../../store/index')
const memberApi = require('../../api/member')
const courseApi = require('../../api/course')
const bannerApi = require('../../api/banner')
const { resolveCoverUrl } = require('../../utils/request')
export default {
data() {
@@ -88,11 +90,7 @@
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 名' }
],
banners: [],
recommendCourses: []
}
},
@@ -128,10 +126,11 @@
async loadData() {
this.loading = true
try {
// 并行获取会员信息推荐课程
const [memberResult, recommendResult] = await Promise.allSettled([
// 并行获取会员信息推荐课程和轮播图
const [memberResult, recommendResult, bannerResult] = await Promise.allSettled([
memberApi.getMemberInfo(),
courseApi.getActiveRecommendations()
courseApi.getActiveRecommendations(),
bannerApi.getActiveBanners()
])
// 处理会员信息
@@ -153,13 +152,30 @@
return {
id: gc.id || r.courseId,
courseName: gc.courseName || '推荐课程',
imageUrl: gc.coverImage || '',
imageUrl: resolveCoverUrl(gc.coverImage),
recommendReason: r.recommendReason || '',
priceLabel: (gc.storedValueAmount > 0 ? '¥' + gc.storedValueAmount : '免费'),
bookedCount: gc.currentMembers || 0
}
})
}
// 处理轮播图
if (bannerResult.status === 'fulfilled') {
const rawBanners = bannerResult.value || []
console.log('[Index] 获取到轮播图原始数据:', rawBanners.length, '条')
this.banners = rawBanners.map(b => {
const resolvedUrl = resolveCoverUrl(b.imageUrl)
console.log('[Index] Banner id=' + b.id + ' imageUrl原始=' + b.imageUrl + ' → 解析后=' + resolvedUrl)
return {
imageUrl: resolvedUrl,
title: b.title || '',
subtitle: b.subtitle || ''
}
})
console.log('[Index] 最终 banners:', JSON.stringify(this.banners))
} else {
console.error('[Index] 轮播图请求失败:', bannerResult.reason)
}
} catch (e) {
console.error('首页数据加载失败:', e)
} finally {
@@ -179,9 +179,15 @@
if (!res.confirm) return
this.cancelling = item.id
try {
await bookingApi.cancelBooking(item.id)
const memberInfo = store.getMemberInfo()
const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null
if (!memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
await bookingApi.cancelBooking(item.id, memberId)
await this.loadBookings()
uni.showToast({ title: '已取消', icon: 'success' })
this.loadBookings()
} catch (e) {
console.error('取消预约失败:', e)
const msg = (e.data && e.data.message) || '取消失败,请重试'
@@ -245,8 +251,8 @@
return
}
await bookingApi.signIn(Number(scannedCourseId), memberId)
await this.loadBookings()
uni.showToast({ title: '签到成功', icon: 'success' })
this.loadBookings()
} catch (e) {
console.error('签到失败:', e)
const msg = (e.data && e.data.message) || '签到失败,请重试'
+122 -50
View File
@@ -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"(已预约)的课程IDstatus=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: #00C853; font-weight: 600; }
.course-list-inner { padding: 12px 16px 0; }
.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; }
.course-item { margin-bottom: 14px; border-radius: 16px; box-shadow: 0 4px 16px rgba(0,0,0,0.07); overflow: hidden; }
.course-card { background: #FFFFFF; border-radius: 16px; display: flex; flex-direction: column; }
/* ---- 封面区域 ---- */
.course-cover { width: 100%; height: 170px; background: linear-gradient(135deg, #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(90deg, #E8E8E8 25%, #F5F5F5 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-meta { display: flex; align-items: center; font-size: 12px; color: #7A7E84; margin-top: 4px; }
/* 无封面时的占位 */
.cover-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 0; }
.cover-icon { font-size: 36px; margin-bottom: 4px; }
.cover-type-name { font-size: 16px; font-weight: 700; color: #00C853; }
/* 封面覆盖层 - 难度角标 */
.cover-overlay { position: absolute; top: 10px; right: 10px; z-index: 5; }
.diff-badge { display: flex; align-items: center; padding: 4px 10px; border-radius: 20px; background: rgba(0,0,0,0.55); backdrop-filter: blur(4px); }
.diff-badge-star { font-size: 11px; margin-right: 1px; }
.diff-badge-label { font-size: 11px; font-weight: 600; margin-left: 4px; }
.diff-badge.diff-low .diff-badge-star { color: #00E676; }
.diff-badge.diff-low .diff-badge-label { color: #69F0AE; }
.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: 14px 16px 16px; display: flex; flex-direction: column; }
.course-top { display: flex; justify-content: space-between; align-items: center; }
.course-name { font-size: 17px; font-weight: 700; color: #1E1E1E; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.course-price { font-size: 20px; font-weight: 700; color: #00C853; flex-shrink: 0; margin-left: 8px; }
.course-price.free { color: #00C853; }
/* 标签 */
.course-labels { display: flex; flex-wrap: wrap; margin-top: 8px; }
.course-label { font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-right: 6px; margin-bottom: 4px; }
/* 元数据行 */
.course-meta { display: flex; align-items: center; font-size: 13px; color: #7A7E84; margin-top: 6px; }
.meta-icon { font-size: 12px; margin-right: 4px; flex-shrink: 0; }
.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 8px rgba(0,230,118,0.3); }
/* 底部预约行 */
.course-bottom { display: flex; align-items: center; margin-top: 12px; padding-top: 10px; border-top: 1px solid #F0F0F0; }
.course-capacity { font-size: 13px; color: #7A7E84; flex: 1; }
.btn-book { background: #00E676; color: #1A1A1A; border: none; padding: 10px 28px; border-radius: 40px; font-weight: 700; font-size: 15px; line-height: 1.2; flex-shrink: 0; margin-left: auto; text-align: center; 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; }
+20
View File
@@ -60,4 +60,24 @@ http.interceptors.response.use(
}
)
/**
* 将 coverImage 字段值解析为完整图片 URL
* - /api/files/{id}/preview → 拼接 baseURL 后返回
* - 纯数字 → 构造 /files/{id}/preview 后拼接 baseURL
* - 其他 → 原样返回
*/
function resolveCoverUrl(coverImage) {
if (!coverImage) return ''
// 新格式:/api/files/{id}/preview
if (/^\/api\/files\/\d+\/preview$/.test(coverImage)) {
return BASE_URL + coverImage.substring(4) // 去掉 /api
}
// 旧格式:纯数字 ID
if (/^\d+$/.test(coverImage)) {
return BASE_URL + '/files/' + coverImage + '/preview'
}
return coverImage
}
module.exports = http
module.exports.resolveCoverUrl = resolveCoverUrl