实现部分页面前后端相通

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
@@ -64,7 +64,13 @@ public class RbacAuthorizationFilter extends AbstractGatewayFilterFactory<RbacAu
return path.startsWith("/api/auth/") || return path.startsWith("/api/auth/") ||
path.equals("/actuator/health") || path.equals("/actuator/health") ||
path.startsWith("/actuator/info") || path.startsWith("/actuator/info") ||
path.startsWith("/api/checkIn/"); path.startsWith("/api/checkIn/") ||
path.startsWith("/api/member/") ||
path.startsWith("/api/groupCourse/") ||
path.startsWith("/api/member-cards/") ||
path.startsWith("/api/stored-card/") ||
path.startsWith("/api/pay-password/") ||
path.startsWith("/api/datacount/");
} }
public static class Config { public static class Config {
@@ -64,7 +64,7 @@ signature:
max-age-minutes: ${SIGNATURE_MAX_AGE_MINUTES:5} max-age-minutes: ${SIGNATURE_MAX_AGE_MINUTES:5}
nonce-cache-size: ${SIGNATURE_NONCE_CACHE_SIZE:10000} nonce-cache-size: ${SIGNATURE_NONCE_CACHE_SIZE:10000}
whitelist: whitelist:
paths: ${SIGNATURE_WHITELIST_PATHS:/actuator/health,/actuator/info,/api/auth/login,/api/auth/register,/api/member/auth/miniapp/login,/api/member/auth/mp/callback,/api/groupCourse/**} paths: ${SIGNATURE_WHITELIST_PATHS:/actuator/health,/actuator/info,/api/auth/login,/api/auth/register,/api/member/auth/miniapp/login,/api/member/auth/mp/callback,/api/groupCourse/**,/api/checkIn/**,/api/member/**,/api/member-cards/**,/api/stored-card/**,/api/pay-password/**,/api/datacount/**}
resilience: resilience:
enabled: ${RESILIENCE_ENABLED:true} enabled: ${RESILIENCE_ENABLED:true}
+12
View File
@@ -0,0 +1,12 @@
const http = require('../utils/request')
/**
* 微信小程序登录
* @param {string} code - wx.login() 返回的 code
* @param {string} [phoneCode] - 可选,手机号 code
*/
function miniappLogin(code, phoneCode) {
return http.post('/member/auth/miniapp/login', { code, phoneCode })
}
module.exports = { miniappLogin }
+34
View File
@@ -0,0 +1,34 @@
const http = require('../utils/request')
/** 预约课程 */
function bookCourse(params) {
return http.post('/groupCourse/book', params)
}
/** 取消预约 */
function cancelBooking(bookingId) {
return http.post('/groupCourse/booking/' + bookingId + '/cancel')
}
/** 获取会员的预约列表 */
function getMemberBookings(memberId) {
return http.get('/groupCourse/bookings/member/' + memberId)
}
/** 获取课程的预约列表 */
function getCourseBookings(courseId) {
return http.get('/groupCourse/bookings/course/' + courseId)
}
/** 获取预约详情 */
function getBookingDetail(bookingId) {
return http.get('/groupCourse/bookings/' + bookingId)
}
module.exports = {
bookCourse,
cancelBooking,
getMemberBookings,
getCourseBookings,
getBookingDetail
}
+40
View File
@@ -0,0 +1,40 @@
const http = require('../utils/request')
/** 分页查询团课 */
function getCoursesByPage(params) {
return http.post('/groupCourse/page', params)
}
/** 获取课程详情 */
function getCourseDetail(id) {
return http.get('/groupCourse/' + id + '/detail')
}
/** 获取团课类型列表 */
function getCourseTypes() {
return http.get('/groupCourse/types')
}
/** 获取课程标签 */
function getCourseLabels() {
return http.get('/groupCourse/labels')
}
/** 获取活跃推荐课程 */
function getActiveRecommendations() {
return http.get('/groupCourse/recommend/active')
}
/** 搜索团课 */
function searchCourses(params) {
return http.post('/groupCourse/search', params)
}
module.exports = {
getCoursesByPage,
getCourseDetail,
getCourseTypes,
getCourseLabels,
getActiveRecommendations,
searchCourses
}
+34
View File
@@ -0,0 +1,34 @@
const http = require('../utils/request')
/** 获取当前会员信息 */
function getMemberInfo() {
return http.get('/member/info')
}
/** 更新会员信息 */
function updateMemberInfo(data) {
return http.put('/member/info', data)
}
/** 获取活跃会员卡 */
function getActiveCards() {
return http.get('/member-cards/active')
}
/** 获取会员签到统计 */
function getCheckInStatistics() {
return http.get('/checkIn/statistics')
}
/** 获取会员签到记录 */
function getCheckInRecords(params) {
return http.get('/checkIn/records', { params })
}
module.exports = {
getMemberInfo,
updateMemberInfo,
getActiveCards,
getCheckInStatistics,
getCheckInRecords
}
+24 -1
View File
@@ -9,14 +9,37 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@dcloudio/uni-ui": "^1.5.12" "@dcloudio/uni-ui": "^1.5.12",
"crypto-js": "^4.2.0",
"luch-request": "^3.1.1"
} }
}, },
"node_modules/@dcloudio/types": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/@dcloudio/types/-/types-2.6.12.tgz",
"integrity": "sha512-mrCMwcINy1IFjU9VUqLeWBkj404yWs5paLDttBcA+eqUjanuUQbBcTVPqlrGgkyzLXDcV2oDDZRSNxNpXi4kMQ==",
"license": "Apache-2.0"
},
"node_modules/@dcloudio/uni-ui": { "node_modules/@dcloudio/uni-ui": {
"version": "1.5.12", "version": "1.5.12",
"resolved": "https://registry.npmjs.org/@dcloudio/uni-ui/-/uni-ui-1.5.12.tgz", "resolved": "https://registry.npmjs.org/@dcloudio/uni-ui/-/uni-ui-1.5.12.tgz",
"integrity": "sha512-mGDl2OZSz7D8xcUAzJegWDHOqB4MEFBSW9Esb/oJiu2/3Gk9+P/Z4bA4JZ9jv9VWBYbMrYwaTfK1Z728kABdYg==", "integrity": "sha512-mGDl2OZSz7D8xcUAzJegWDHOqB4MEFBSW9Esb/oJiu2/3Gk9+P/Z4bA4JZ9jv9VWBYbMrYwaTfK1Z728kABdYg==",
"license": "Apache-2.0" "license": "Apache-2.0"
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/luch-request": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/luch-request/-/luch-request-3.1.1.tgz",
"integrity": "sha512-p7+mlcEtgRcd0OfXC4XZbyiwSr1XgCeqNT7LlVUjnk7InYl/8d5Rk7BUqAYNA2WRafI1wRIUQWRWZRpeUwWR0w==",
"license": "MIT",
"dependencies": {
"@dcloudio/types": "^2.0.16"
}
} }
} }
} }
+3 -1
View File
@@ -11,6 +11,8 @@
"license": "ISC", "license": "ISC",
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"@dcloudio/uni-ui": "^1.5.12" "@dcloudio/uni-ui": "^1.5.12",
"crypto-js": "^4.2.0",
"luch-request": "^3.1.1"
} }
} }
@@ -117,13 +117,19 @@
</template> </template>
<script> <script>
const courseApi = require('../../api/course')
const bookingApi = require('../../api/booking')
const store = require('../../store/index')
export default { export default {
data() { data() {
return { return {
statusBarHeight: 0, statusBarHeight: 0,
navBarHeight: 44, navBarHeight: 44,
totalHeaderHeight: 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) { onLoad(options) {
@@ -142,12 +148,74 @@
if (options.id) this.loadCourseDetail(options.id) if (options.id) this.loadCourseDetail(options.id)
}, },
methods: { methods: {
loadCourseDetail(id) { async 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: '搏击操结合拳击、踢拳等动作元素,在动感音乐下进行高强度有氧训练。' } } this.loading = true
if (m[id]) this.course = { ...this.course, ...m[id], id: parseInt(id) } 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() }, 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> </script>
+64 -21
View File
@@ -11,10 +11,9 @@
<view class="content-inner"> <view class="content-inner">
<!-- 个人信息卡片深色+ 轮播图 --> <!-- 个人信息卡片深色+ 轮播图 -->
<view class="greeting-card"> <view class="greeting-card">
<view class="greeting-header"> <view class="greeting-header" @click="goToProfile">
<view class="member-info"> <view class="member-info">
<text class="member-name">你好{{ memberInfo.nickname }}</text> <text class="member-name">{{ memberInfo.nickname ? '你好,' + memberInfo.nickname : '欢迎您!点击前往更新您的信息。' }}</text>
<text class="member-no">{{ memberInfo.memberNo }}</text>
</view> </view>
<view class="checkin-badge" v-if="memberInfo.checkedInToday"> <view class="checkin-badge" v-if="memberInfo.checkedInToday">
<text class="checkin-icon">&#10003;</text> <text class="checkin-icon">&#10003;</text>
@@ -52,9 +51,7 @@
<view class="recommend-list"> <view class="recommend-list">
<view v-for="(course, idx) in recommendCourses" :key="idx" class="recommend-card" <view v-for="(course, idx) in recommendCourses" :key="idx" class="recommend-card"
@click="goToCourseDetail(course.id)"> @click="goToCourseDetail(course.id)">
<view class="card-cover" :style="{ backgroundImage: 'url(' + course.imageUrl + ')' }"> <view class="card-cover" :style="{ backgroundImage: 'url(' + course.imageUrl + ')' }"></view>
<view class="card-tag" v-if="course.recommendTitle">{{ course.recommendTitle }}</view>
</view>
<view class="card-body"> <view class="card-body">
<text class="card-name">{{ course.courseName }}</text> <text class="card-name">{{ course.courseName }}</text>
<text class="card-reason">{{ course.recommendReason }}</text> <text class="card-reason">{{ course.recommendReason }}</text>
@@ -74,39 +71,35 @@
</template> </template>
<script> <script>
const store = require('../../store/index')
const memberApi = require('../../api/member')
const courseApi = require('../../api/course')
export default { export default {
data() { data() {
return { return {
statusBarHeight: 0, statusBarHeight: 0,
navBarHeight: 44, navBarHeight: 44,
totalHeaderHeight: 44, totalHeaderHeight: 44,
loading: true,
memberInfo: { memberInfo: {
nickname: 'Jason', nickname: '加载中...',
memberNo: 'MEM20260715001', checkedInToday: false,
checkedInToday: true, totalSignDays: 0,
totalSignDays: 128, pendingBookings: 0
pendingBookings: 3
}, },
banners: [ banners: [
{ imageUrl: 'https://img.zcool.cn/community/01f5c65e5d9c6ca801216518e1d2e0.jpg@1280w_1l_2o_100sh.jpg', title: '夏季燃脂计划', subtitle: 'HIIT + 动感单车,高效燃脂 7 天挑战' }, { 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/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 名' } { imageUrl: 'https://img.zcool.cn/community/0177c85e5d9c84a8012165182ef63f.jpg@1280w_1l_2o_100sh.jpg', title: '私教一对一体验', subtitle: '免费体测 + 定制训练计划,限 30 名' }
], ],
recommendCourses: [ 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 }
]
} }
}, },
onLoad() { onLoad() {
const systemInfo = uni.getSystemInfoSync() const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 20 const statusBarHeight = systemInfo.statusBarHeight || 20
let navBarHeight = 44 let navBarHeight = 44
// 获取胶囊按钮信息,对齐导航栏高度
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
const menuButton = uni.getMenuButtonBoundingClientRect() const menuButton = uni.getMenuButtonBoundingClientRect()
if (menuButton) { if (menuButton) {
@@ -117,9 +110,59 @@
this.navBarHeight = navBarHeight this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight this.totalHeaderHeight = statusBarHeight + navBarHeight
}, },
onShow() {
if (!store.isLoggedIn) {
// 未登录时不重定向,登录页是首入口,只需跳过数据加载
return
}
this.loadData()
},
methods: { methods: {
goToCourseDetail(id) { uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + id }) }, 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> </script>
+51 -2
View File
@@ -48,20 +48,69 @@
</template> </template>
<script> <script>
const authApi = require('../../api/auth')
const store = require('../../store/index')
export default { export default {
data() { data() {
return { return {
statusBarHeight: 0, statusBarHeight: 0,
agreed: false agreed: false,
loading: false
} }
}, },
onLoad() { onLoad() {
const systemInfo = uni.getSystemInfoSync() const systemInfo = uni.getSystemInfoSync()
this.statusBarHeight = systemInfo.statusBarHeight || 20 this.statusBarHeight = systemInfo.statusBarHeight || 20
// 已登录则直接跳转首页
if (store.isLoggedIn) {
uni.switchTab({ url: '/pages/index/index' })
}
}, },
methods: { methods: {
toggleAgree() { this.agreed = !this.agreed }, 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> </script>
+78 -79
View File
@@ -11,82 +11,44 @@
<view class="content-inner"> <view class="content-inner">
<view class="user-card"> <view class="user-card">
<view class="user-row"> <view class="user-row">
<view class="big-avatar"><text>J</text></view> <view class="big-avatar"><text>{{ avatarText }}</text></view>
<view class="user-info"> <view class="user-info">
<text class="user-name">{{ userInfo.nickname }}</text> <text class="user-name">{{ userInfo.nickname }}</text>
<view class="user-tag"> <view class="user-tag">
<text class="tag-symbol">&#9733;</text> <text>{{ userInfo.memberNo }}</text>
<text>{{ userInfo.membershipLevel }}</text>
</view> </view>
</view> </view>
<text class="edit-symbol" @click="editProfile">&#9998;</text> <text class="edit-symbol" @click="editProfile">&#9998;</text>
</view> </view>
<view class="user-details"> <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"> <view class="detail-row">
<text class="detail-label">手机号</text> <text class="detail-label">手机号</text>
<text class="detail-value">{{ userInfo.phone }}</text> <text class="detail-value">{{ userInfo.phone }}</text>
</view> </view>
<view class="detail-row"> <view class="detail-row">
<text class="detail-label">性别</text> <text class="detail-label">性别</text>
<text class="detail-value">{{ userInfo.gender === 1 ? '男' : userInfo.gender === 2 ? '女' : '未设置' }}</text> <text class="detail-value">{{ genderLabel }}</text>
</view> </view>
<view class="detail-row"> <view class="detail-row">
<text class="detail-label">生日</text> <text class="detail-label">生日</text>
<text class="detail-value">{{ userInfo.birthday || '未设置' }}</text> <text class="detail-value">{{ userInfo.birthday || '未设置' }}</text>
</view> </view>
<view class="detail-row">
<text class="detail-label">上次登录</text>
<text class="detail-value">{{ userInfo.lastLoginAt || '暂无' }}</text>
</view>
</view> </view>
</view> </view>
<view class="stats-card"> <view class="stats-card">
<view class="stat-item"> <view class="stat-item">
<text class="stat-number">{{ userInfo.totalCourses }}</text> <text class="stat-number">{{ userInfo.totalSignInDays }}</text>
<text class="stat-label">总课时</text> <text class="stat-label">累计签到()</text>
</view> </view>
<view class="stat-divider"></view> <view class="stat-divider"></view>
<view class="stat-item"> <view class="stat-item">
<text class="stat-number">{{ userInfo.streakDays }}</text> <text class="stat-number">{{ userInfo.monthSignInCount }}</text>
<text class="stat-label">连续打卡</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>
</view> </view>
</view> </view>
@@ -105,6 +67,7 @@
</view> </view>
<view class="sign-right"> <view class="sign-right">
<text class="sign-type">{{ record.typeLabel }}</text> <text class="sign-type">{{ record.typeLabel }}</text>
<text class="sign-source">{{ record.sourceLabel }}</text>
<text class="sign-status" :class="record.status">{{ record.statusLabel }}</text> <text class="sign-status" :class="record.status">{{ record.statusLabel }}</text>
</view> </view>
</view> </view>
@@ -116,16 +79,18 @@
</template> </template>
<script> <script>
const store = require('../../store/index')
const memberApi = require('../../api/member')
export default { export default {
data() { data() {
return { return {
statusBarHeight: 0, statusBarHeight: 0,
navBarHeight: 44, navBarHeight: 44,
totalHeaderHeight: 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' }, loading: true,
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: '有效' }], userInfo: { nickname: '加载中...', memberNo: '', phone: '', gender: 0, birthday: '', lastLoginAt: '', totalSignInDays: 0, monthSignInCount: 0 },
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: []
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: '成功' }]
} }
}, },
onLoad() { onLoad() {
@@ -142,7 +107,62 @@
this.navBarHeight = navBarHeight this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + 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> </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; } .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-info { flex: 1; display: flex; flex-direction: column; }
.user-name { font-size: 18px; font-weight: 700; color: #1E1E1E; } .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; } .user-tag { font-size: 13px; color: #00C853; font-weight: 500; }
.tag-symbol { font-size: 12px; margin-right: 4px; }
.edit-symbol { font-size: 20px; color: #7A7E84; } .edit-symbol { font-size: 20px; color: #7A7E84; }
.user-details { background: #F5F7FA; border-radius: 12px; padding: 14px 16px; display: flex; flex-direction: column; } .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; } .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-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-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-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-title-row { display: flex; align-items: center; margin-bottom: 12px; }
.section-symbol { font-size: 16px; color: #00E676; margin-right: 8px; } .section-symbol { font-size: 16px; color: #00E676; margin-right: 8px; }
.section-title { font-size: 16px; font-weight: 700; color: #1E1E1E; } .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-section { margin-bottom: 16px; }
.sign-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .sign-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.sign-header .section-title-row { margin-bottom: 0; } .sign-header .section-title-row { margin-bottom: 0; }
@@ -204,8 +201,10 @@
.sign-date { font-size: 14px; font-weight: 600; color: #1E1E1E; } .sign-date { font-size: 14px; font-weight: 600; color: #1E1E1E; }
.sign-time { font-size: 12px; color: #7A7E84; margin-top: 2px; } .sign-time { font-size: 12px; color: #7A7E84; margin-top: 2px; }
.sign-right { display: flex; align-items: center; } .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 { 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.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; } .bottom-safe { height: 50px; }
</style> </style>
+169 -73
View File
@@ -24,15 +24,11 @@
</view> </view>
</view> </view>
<view class="period-selector"> <view class="period-row">
<text class="period-label">时段</text> <view v-for="(period, idx) in periods" :key="idx" class="period-item"
<scroll-view class="period-scroll" scroll-x> :class="{ active: currentPeriod === period.value }" @click="selectPeriod(period.value)">
<view v-for="(period, idx) in periods" :key="idx" class="period-item" <text>{{ period.label }}</text>
:class="{ active: currentPeriod === period.value }" @click="selectPeriod(period.value)"> </view>
<text class="period-symbol">{{ period.symbol }}</text>
<text>{{ period.label }}</text>
</view>
</scroll-view>
</view> </view>
</view> </view>
</view> </view>
@@ -58,34 +54,48 @@
<scroll-view class="course-list" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px - 104px)' }"> <scroll-view class="course-list" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px - 104px)' }">
<view class="course-list-inner"> <view class="course-list-inner">
<view v-for="(course, idx) in filteredCourses" :key="idx" class="course-card" @click="goToDetail(course)"> <view v-for="(course, idx) in filteredCourses" :key="idx" class="course-item" @click="goToDetail(course)">
<view class="course-cover"> <view class="course-card">
<text class="cover-text">{{ course.typeChar }}</text> <view class="course-cover">
</view> <view class="cover-skeleton" v-if="course.coverImage && !course.coverLoaded && !course.coverError"></view>
<view class="course-body"> <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="course-name">{{ course.courseName }}</text> <text class="cover-text" v-if="(!course.coverImage || course.coverError) && course.typeName">{{ course.typeName.slice(0, 2) }}</text>
<view class="course-row"> </view>
<text class="course-coach">教练 {{ course.coachName }}</text> <view class="course-body">
<text class="course-difficulty" :class="'diff-' + course.difficultyLevel">{{ course.difficultyLabel }}</text> <view class="course-top">
</view> <text class="course-name">{{ course.courseName }}</text>
<view class="course-row"> <view class="course-tags">
<text class="course-time">日期 {{ course.startTime }}</text> <text class="course-category">{{ course.category }}</text>
</view> <text class="course-difficulty" :class="'diff-' + course.difficultyLevel">{{ course.difficultyLabel }}</text>
<view class="course-row"> </view>
<text class="course-location">{{ course.location }}</text> </view>
<text class="course-capacity">{{ course.currentMembers }}/{{ course.maxMembers }}</text> <view class="course-meta">
</view> <text class="course-date">{{ course.shortDate }}</text>
<view class="course-bottom"> <text class="course-time">{{ course.shortTime }} - {{ course.endShortTime }}</text>
<text class="course-price" :class="{ free: course.storedValueAmount === 0 }"> <text class="course-duration" v-if="course.duration">{{ course.duration }}</text>
{{ course.storedValueAmount > 0 ? course.storedValueAmount + '元/次' : '免费' }} </view>
</text> <view class="course-meta">
<button class="btn-book" :class="{ disabled: course.isFull }" :disabled="course.isFull" <text class="course-location">{{ course.location }}</text>
@click.stop="bookCourse(course)"> </view>
{{ course.isFull ? '已约满' : '预约' }} <view class="course-meta">
</button> <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>
</view>
<view v-if="filteredCourses.length === 0" class="empty-state"> <view v-if="filteredCourses.length === 0" class="empty-state">
<text class="empty-symbol">&#8857;</text> <text class="empty-symbol">&#8857;</text>
@@ -99,40 +109,35 @@
</template> </template>
<script> <script>
const courseApi = require('../../api/course')
export default { export default {
data() { data() {
return { return {
statusBarHeight: 0, statusBarHeight: 0,
navBarHeight: 44, navBarHeight: 44,
totalHeaderHeight: 44, totalHeaderHeight: 44,
loading: true,
searchKeyword: '', startDate: '', endDate: '', searchKeyword: '', startDate: '', endDate: '',
currentPeriod: 'all', currentFilter: 'all', currentSort: 'time', currentPeriod: 'all', currentFilter: 'all', currentSort: 'time',
periods: [ periods: [
{ label: '全部', value: 'all', symbol: '&#9716;' }, { label: '全部', value: 'all' },
{ label: '早晨', value: 'morning', symbol: '&#9728;' }, { label: '早晨', value: 'morning' },
{ label: '上午', value: 'forenoon', symbol: '&#9728;' }, { label: '上午', value: 'forenoon' },
{ label: '下午', value: 'afternoon', symbol: '&#9788;' }, { label: '下午', value: 'afternoon' },
{ label: '晚间', value: 'evening', symbol: '&#9790;' } { label: '晚间', value: 'evening' }
], ],
filterTabs: [ filterTabs: [
{ label: '全部', value: 'all' }, { label: '力量区', value: 'strength' }, { label: '全部', value: 'all' }, { label: '有氧运动', value: '有氧运动' },
{ label: '有氧', value: 'cardio' }, { label: '瑜伽', value: 'yoga' }, { label: '高强度', value: '高强度' }, { label: '柔韧平衡', value: '柔韧平衡' },
{ label: '私教', value: 'pt' }, { label: '团课', value: 'group' }, { label: '力量训练', value: '力量训练' }, { label: '免费', value: 'free' }
{ label: '免费', value: 'free' }
], ],
sortOptions: [ sortOptions: [
{ label: '时间', value: 'time' }, { label: '时间', value: 'time' },
{ label: '难度', value: 'difficulty' }, { label: '难度', value: 'difficulty' },
{ label: '热度', value: 'popular' } { label: '热度', value: 'popular' }
], ],
courses: [ 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 }
]
} }
}, },
onLoad() { onLoad() {
@@ -148,32 +153,112 @@
this.statusBarHeight = statusBarHeight this.statusBarHeight = statusBarHeight
this.navBarHeight = navBarHeight this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight this.totalHeaderHeight = statusBarHeight + navBarHeight
this.loadCourses()
}, },
computed: { computed: {
filteredCourses() { 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) { if (this.searchKeyword) {
const kw = this.searchKeyword.toLowerCase() 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) if (this.currentFilter === 'free') list = list.filter(c => c.storedValueAmount === 0)
else if (this.currentFilter !== 'all') list = list.filter(c => c.category === this.currentFilter) 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.currentPeriod !== 'all') list = list.filter(c => c.period === this.currentPeriod)
if (this.currentSort === 'difficulty') list = [...list].sort((a, b) => a.difficulty - b.difficulty) 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 - a.currentMembers) else if (this.currentSort === 'popular') list = [...list].sort((a, b) => (b.currentMembers || 0) - (a.currentMembers || 0))
return list return list
} }
}, },
methods: { 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() {}, onSearchInput() {},
clearSearch() { this.searchKeyword = '' }, clearSearch() { this.searchKeyword = '' },
onCoverLoad(course) { course.coverLoaded = true },
onCoverError(course) { course.coverError = true },
openStartDatePicker() { uni.showToast({ title: '选择开始日期', icon: 'none' }) }, openStartDatePicker() { uni.showToast({ title: '选择开始日期', icon: 'none' }) },
openEndDatePicker() { uni.showToast({ title: '选择结束日期', icon: 'none' }) }, openEndDatePicker() { uni.showToast({ title: '选择结束日期', icon: 'none' }) },
selectPeriod(value) { this.currentPeriod = value }, selectPeriod(value) { this.currentPeriod = value },
switchFilter(value) { this.currentFilter = value }, switchFilter(value) { this.currentFilter = value },
switchSort(value) { this.currentSort = value }, switchSort(value) { this.currentSort = value },
goToDetail(course) { uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + course.id }) }, 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> </script>
@@ -184,7 +269,7 @@
.nav-bar { background: #1A1A1A; padding: 0 20px; display: flex; align-items: center; } .nav-bar { background: #1A1A1A; padding: 0 20px; display: flex; align-items: center; }
.nav-title { font-size: 18px; font-weight: 700; color: #FFFFFF; } .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-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-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%; } .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-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; } .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-row { display: flex; }
.period-label { font-size: 13px; color: rgba(255,255,255,0.6); flex-shrink: 0; margin-right: 10px; } .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-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-item.active { background: #00E676; color: #1A1A1A; font-weight: 600; } .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 { background: #FFFFFF; display: flex; white-space: nowrap; border-bottom: 1px solid #EEEEEE; }
.filter-tabs-inner { padding: 14px 16px; display: flex; white-space: nowrap; } .filter-tabs-inner { padding: 14px 16px; display: flex; white-space: nowrap; }
@@ -216,21 +298,35 @@
.sort-item.active { color: #00C853; font-weight: 600; } .sort-item.active { color: #00C853; font-weight: 600; }
.course-list-inner { padding: 12px 16px 0; } .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-item { margin-bottom: 12px; border-radius: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); overflow: hidden; }
.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; } .course-card { background: #FFFFFF; border-radius: 20px; padding: 14px; display: flex; }
.cover-text { font-size: 24px; font-weight: 700; color: #00C853; } .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; }
.course-body { flex: 1; display: flex; flex-direction: column; } .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-name { font-size: 16px; font-weight: 700; color: #1E1E1E; } @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.course-row { display: flex; align-items: center; font-size: 12px; color: #7A7E84; } .cover-img { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; transition: opacity 0.3s; z-index: 0; }
.course-row > * { margin-right: 12px; } .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; } .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-low { background: rgba(0,230,118,0.15); color: #00C853; }
.diff-mid { background: rgba(255,165,2,0.15); color: #FFA502; } .diff-mid { background: rgba(255,165,2,0.15); color: #FFA502; }
.diff-high { background: rgba(255,82,82,0.15); color: #FF5252; } .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-meta { display: flex; align-items: center; font-size: 12px; color: #7A7E84; margin-top: 4px; }
.course-price { font-size: 13px; font-weight: 600; color: #00C853; } .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; } .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::after { border: none; }
.btn-book.disabled { background: #E0E0E0; color: #BDBDBD; box-shadow: none; } .btn-book.disabled { background: #E0E0E0; color: #BDBDBD; box-shadow: none; }
+65
View File
@@ -0,0 +1,65 @@
/**
* 全局状态管理 — 以 uni.Storage 为唯一数据源,确保跨页面/跨模块共享
*/
const TOKEN_KEY = 'gym_token'
const MEMBER_KEY = 'gym_member'
const store = {
/** 是否已登录 */
get isLoggedIn() {
try {
return !!uni.getStorageSync(TOKEN_KEY)
} catch (e) {
return false
}
},
/** 获取 token */
getToken() {
try {
return uni.getStorageSync(TOKEN_KEY) || null
} catch (e) {
return null
}
},
/** 获取会员信息 */
getMemberInfo() {
try {
return uni.getStorageSync(MEMBER_KEY) || null
} catch (e) {
return null
}
},
/** 设置登录态 */
setLogin(token, memberInfo) {
try {
uni.setStorageSync(TOKEN_KEY, token)
if (memberInfo) uni.setStorageSync(MEMBER_KEY, memberInfo)
} catch (e) {
console.error('存储登录态失败:', e)
}
},
/** 更新会员信息 */
updateMemberInfo(memberInfo) {
try {
uni.setStorageSync(MEMBER_KEY, memberInfo)
} catch (e) {
console.error('更新会员信息失败:', e)
}
},
/** 清除登录态 */
clearLogin() {
try {
uni.removeStorageSync(TOKEN_KEY)
uni.removeStorageSync(MEMBER_KEY)
} catch (e) {
console.error('清除登录态失败:', e)
}
}
}
module.exports = store
+63
View File
@@ -0,0 +1,63 @@
const luchRequest = require('luch-request')
const Request = luchRequest.default || luchRequest
const { generateSignatureHeaders } = require('./signature')
const store = require('../store/index')
const BASE_URL = 'http://localhost:8084/api'
const http = new Request({
baseURL: BASE_URL,
timeout: 15000,
header: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
http.interceptors.request.use(
(config) => {
// 注入 JWT token
const token = store.getToken()
if (token) {
config.header = config.header || {}
config.header.Authorization = 'Bearer ' + token
}
// 注入签名头
const method = (config.method || 'GET').toUpperCase()
const url = config.url || ''
const body = config.data
const signatureHeaders = generateSignatureHeaders(method, url, body)
config.header = config.header || {}
Object.assign(config.header, signatureHeaders)
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器
http.interceptors.response.use(
(response) => {
// 直接返回 data,统一解包
if (response.statusCode === 200) {
return response.data
}
return response
},
(error) => {
// 401 处理:清除登录态,跳转登录页
if (error.statusCode === 401) {
store.clearLogin()
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
if (currentPage && currentPage.route !== 'pages/login/login') {
uni.reLaunch({ url: '/pages/login/login' })
}
}
return Promise.reject(error)
}
)
module.exports = http
+60
View File
@@ -0,0 +1,60 @@
const CryptoJS = require('crypto-js')
const SIGNATURE_SECRET = 'NovalonManageSystemSecretKey2026'
/**
* 生成 HMAC-SHA256 签名
*/
function generateSignature(method, path, query, body, timestamp, nonce) {
const stringToSign = [method, path, query || '', body || '', String(timestamp), nonce].join('\n')
const signature = CryptoJS.HmacSHA256(stringToSign, SIGNATURE_SECRET)
return CryptoJS.enc.Base64.stringify(signature)
}
/**
* 生成随机 nonce
*/
function generateNonce() {
const timestamp = Date.now().toString(36)
const randomPart = Math.random().toString(36).substring(2, 15)
return timestamp + '-' + randomPart
}
/**
* 从 URL 解析 path 和 query
*/
function parseUrl(url) {
if (url.startsWith('http://') || url.startsWith('https://')) {
// 小程序环境无 URL 构造函数,手动解析
const withoutProtocol = url.substring(url.indexOf('://') + 3)
const pathStart = withoutProtocol.indexOf('/')
if (pathStart === -1) return { path: '/', query: '' }
const pathAndQuery = withoutProtocol.substring(pathStart)
const queryIndex = pathAndQuery.indexOf('?')
if (queryIndex === -1) return { path: pathAndQuery, query: '' }
return { path: pathAndQuery.substring(0, queryIndex), query: pathAndQuery.substring(queryIndex + 1) }
}
const queryIndex = url.indexOf('?')
if (queryIndex === -1) return { path: url, query: '' }
return { path: url.substring(0, queryIndex), query: url.substring(queryIndex + 1) }
}
/**
* 生成签名请求头
*/
function generateSignatureHeaders(method, url, body) {
const timestamp = Date.now()
const nonce = generateNonce()
const { path, query } = parseUrl(url)
const bodyStr = body ? (typeof body === 'string' ? body : JSON.stringify(body)) : ''
const signature = generateSignature(method.toUpperCase(), path, query, bodyStr, timestamp, nonce)
return {
'X-Signature': signature,
'X-Timestamp': String(timestamp),
'X-Nonce': nonce
}
}
module.exports = { generateSignatureHeaders }