修正多处问题

This commit is contained in:
2026-06-29 13:35:29 +08:00
parent 09e587cb65
commit e85f39ab83
37 changed files with 3546 additions and 2539 deletions
+14 -2
View File
@@ -54,6 +54,7 @@ export function searchGroupCourse(params = {}, options = {}) {
timePeriod,
priceSort,
remainingMost,
isRecurring,
page = 0,
size = 10
} = params
@@ -67,6 +68,7 @@ export function searchGroupCourse(params = {}, options = {}) {
if (timePeriod) requestBody.timePeriod = timePeriod
if (priceSort) requestBody.priceSort = priceSort
if (remainingMost !== undefined) requestBody.remainingMost = remainingMost
if (isRecurring !== undefined) requestBody.isRecurring = isRecurring
return request.post('/groupCourse/search', requestBody, options)
}
@@ -91,14 +93,24 @@ export function getMemberBookings(memberId, options = {}) {
return request.get(`/groupCourse/bookings/member/${memberId}`, {}, options)
}
export function getActiveRecommendCourses(options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
export function getActiveRecommendCourses(options = { cache: false }) {
return request.get('/groupCourse/recommend/active', {}, options)
}
export function getGroupCourseRecommendList(params = {}, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
export function getGroupCourseRecommendList(params = {}, options = { cache: false }) {
return request.get('/groupCourse/recommend/list', params, options)
}
/**
* 扫码签到
* 扫描团课二维码后签到,将预约状态更新为已出席
* @param {number} courseId - 团课ID
* @param {number} memberId - 会员ID
*/
export function qrSignInGroupCourse(courseId, memberId) {
return request.post(`/groupCourse/${courseId}/qrsignin`, { memberId })
}
export default {
getGroupCourseList,
getGroupCoursePage,
+23 -1
View File
@@ -328,6 +328,17 @@ export function getStoredCardRechargeRecords(memberId, page = 1, size = 20, opti
return request.get(`/stored-card/${memberId}/recharges?page=${page}&size=${size}`, {}, options)
}
// ========== 系统配置相关API ==========
/**
* 根据配置键获取配置值
* @param {string} configKey - 配置键
* @param {object} options - 请求选项
*/
export function getConfigByKey(configKey, options = {}) {
return request.get(`/config/key/${configKey}`, {}, options)
}
// ========== 支付相关API ==========
/**
@@ -482,6 +493,16 @@ export function getCourseBookings(courseId, options = { cache: false }) {
return request.get(`/groupCourse/bookings/course/${courseId}`, {}, options)
}
// ========== 轮播图相关API ==========
/**
* 获取启用的轮播图列表
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getActiveBanners(options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
return request.get('/banner/active', {}, options)
}
export default {
login,
sendCode,
@@ -520,5 +541,6 @@ export default {
cancelBooking,
signinGroupCourse,
getMemberBookings,
getCourseBookings
getCourseBookings,
getActiveBanners
}
+9 -5
View File
@@ -80,6 +80,7 @@ export function navigateToPage(url) {
// 这种情况应该使用 switchToTabPage(会清空页面栈)
if (TAB_PAGES.has(path)) {
console.warn('[navigateToPage] 不应该用 navigateTo 跳转 TabBar 页面,请使用 switchToTabPage')
uni.hideLoading()
switchToTabPage(path)
return
}
@@ -90,6 +91,7 @@ export function navigateToPage(url) {
url,
fail: (err) => {
console.error('[navigateTo]', url, err)
uni.hideLoading()
// 页面栈满时降级使用 redirectTo
if (err.errMsg && err.errMsg.includes('limit')) {
uni.redirectTo({ url })
@@ -97,10 +99,10 @@ export function navigateToPage(url) {
uni.showToast({ title: '页面跳转失败', icon: 'none' })
}
},
success: () => {
setTimeout(() => {
complete: () => {
// 页面已发起跳转,隐藏 loading
// 目标页面的 onLoad/onReady 也会调用 hideLoading 做兜底
uni.hideLoading()
},3000)
}
})
}
@@ -122,19 +124,21 @@ export function switchToTabPage(url) {
console.log('[switchToTabPage] 跳转到 TabBar:', path)
tabNavigating = true
uni.switchTab({ // ✅ 改用 switchTab,而不是 reLaunch
uni.switchTab({
url: path,
complete: () => {
uni.hideLoading()
setTimeout(() => {
tabNavigating = false
}, 320)
},
fail: (err) => {
console.error('[switchTab]', path, err)
// 降级使用 reLaunch
uni.hideLoading()
uni.reLaunch({
url: path,
complete: () => {
uni.hideLoading()
setTimeout(() => {
tabNavigating = false
}, 320)
+4 -2
View File
@@ -54,7 +54,9 @@ function applyCardInfo(store) {
export function syncStats(store) {
store.stats = {
...store.stats,
pointsBalance: store.stats.pointsBalance ?? 1250
checkInCount: store.stats.checkInCount ?? 0,
courseCount: store.stats.courseCount ?? 0,
pointsBalance: store.stats.pointsBalance ?? 0
}
return store
}
@@ -230,7 +232,7 @@ export function getCenterPageData(store) {
return {
stats: {
checkInCount: store.stats?.checkInCount ?? 0,
trainingHours: store.stats?.trainingHours ?? 0,
courseCount: store.stats?.courseCount ?? 0,
pointsBalance: store.stats?.pointsBalance ?? 0
},
cardInfo: { ...store.cardInfo },
@@ -362,27 +362,29 @@
}
.member-card-section__count {
display: flex;
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 12px;
background-color: rgba(255, 255, 255, 0.2);
gap: 4rpx;
padding: 4rpx 14rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(4px);
flex-shrink: 0;
}
.member-card-section__count-num {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-size: 26rpx;
font-weight: 700;
color: var(--text-inverse);
color: #333;
line-height: 1;
}
.member-card-section__count-text {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-size: 22rpx;
font-weight: 400;
color: rgba(255, 212, 184, 1);
color: #333;
line-height: 1;
}
.member-card-preview__tag--expiring {
-3
View File
@@ -183,9 +183,7 @@ function onTabTap(index) {
emit('tab-change', index)
// 设置导航标志,阻止轮询覆盖状态
isNavigating = true
let timer = setTimeout(() => {
uni.showLoading({ title: '加载中...', mask: true })
}, 50)
isSwitching = true
uni.switchTab({
url: targetPath,
@@ -195,7 +193,6 @@ function onTabTap(index) {
uni.reLaunch({ url: targetPath })
},
complete: () => {
clearTimeout(timer)
uni.hideLoading()
setTimeout(() => {
isSwitching = false
@@ -103,7 +103,7 @@
</view>
<!-- 预约按钮 -->
<view :class="['booking-btn', { disabled: !canBook }]" @click.stop="handleBooking">
<text>{{ canBook ? '立即预约' : (course.currentMembers >= course.maxMembers ? '已满员' : '已结束') }}</text>
<text>{{ buttonText }}</text>
</view>
</view>
</view>
@@ -123,6 +123,10 @@ const props = defineProps({
courseTypeLabels: {
type: Array,
default: () => []
},
booked: {
type: Boolean,
default: false
}
})
@@ -147,7 +151,14 @@ const statusClass = computed(() => {
const canBook = computed(() => {
const status = props.course.status
const isFull = props.course.currentMembers >= props.course.maxMembers
return status === '0' && !isFull
return status === '0' && !isFull && !props.booked
})
const buttonText = computed(() => {
if (props.booked) return '已预约'
if (!canBook.value && props.course.currentMembers >= props.course.maxMembers) return '已满员'
if (!canBook.value && Number(props.course.status) !== 0) return '已结束'
return '立即预约'
})
const formatTime = (dateStr) => {
@@ -191,7 +202,7 @@ const formatDuration = (startStr, endStr) => {
}
const goDetail = () => {
emit('detail', props.course.id)
emit('detail', Number(props.course.id))
}
const handleBooking = () => {
@@ -45,9 +45,11 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { getActiveBanners } from '@/api/main.js'
const banners = [
// 默认静态数据(后端无数据时作为兜底)
const fallbackBanners = [
{
image: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=800&q=80',
title: '突破自我',
@@ -68,9 +70,49 @@ const banners = [
}
]
// 先用回退数据初始化,确保 swiper 立即可见
const banners = ref([...fallbackBanners])
const currentIndex = ref(0)
// 记录每张图片的加载状态
const imageLoaded = ref(banners.map(() => false))
const imageLoaded = ref(fallbackBanners.map(() => false))
const isFetching = ref(false)
async function fetchBanners() {
isFetching.value = true
try {
// 禁用缓存,确保每次获取最新数据
const res = await getActiveBanners({ cache: false })
// 处理后端返回数据:可能是数组或 { data: [...] }
const data = Array.isArray(res) ? res : (res?.data || [])
if (data.length > 0) {
// 映射后端字段到组件字段,替换回退数据
banners.value = data.map(item => ({
image: item.imageUrl || '',
title: item.title || '',
subtitle: item.subtitle || '',
desc: item.description || ''
}))
// 更新 imageLoaded 状态
imageLoaded.value = banners.value.map(() => false)
console.log('[BannerSwiper] 轮播图数据加载成功,共', data.length, '条')
} else {
console.log('[BannerSwiper] 后端无轮播图数据,使用回退数据')
// 保持已有的 fallbackBanners,无需重新赋值
imageLoaded.value = banners.value.map(() => false)
}
} catch (err) {
console.error('[BannerSwiper] 轮播图数据加载失败,使用回退数据:', err)
// 保持已有的 fallbackBanners,无需重新赋值
imageLoaded.value = banners.value.map(() => false)
} finally {
isFetching.value = false
}
}
onMounted(() => {
fetchBanners()
})
const onSwiperChange = (e) => {
currentIndex.value = e.detail.current
@@ -86,30 +128,28 @@ const previewImage = (index) => {
})
}
// 图片加载成功回调
const onImageLoad = (index) => {
imageLoaded.value[index] = true
console.log(`图片 ${index} 加载完成`)
}
// 图片加载失败回调
const onImageError = (index) => {
console.error(`图片 ${index} 加载失败`)
// 可选:设置默认占位图
// banners[index].image = '默认图片URL'
}
</script>
<style lang="scss" scoped>
.banner-container {
position: relative;
z-index: 2;
width: 100%;
padding: 0 24rpx;
box-sizing: border-box;
}
.banner-swiper {
width: 100%;
height: 480rpx;
height: 360rpx;
border-radius: 20rpx;
overflow: hidden;
}
.banner-content {
@@ -124,7 +164,6 @@ const onImageError = (index) => {
height: 100%;
}
/* 添加图片占位符样式 */
.image-placeholder {
position: absolute;
top: 0;
@@ -158,10 +197,10 @@ const onImageError = (index) => {
right: 0;
top: 0;
bottom: 0;
z-index: 2; /* 确保遮罩层在占位符上面 */
background: linear-gradient(135deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.05) 50%, rgba(0,0,0,0.25) 100%);
z-index: 2;
}
/* 其余样式保持不变 */
.banner-text {
position: absolute;
left: 36rpx;
@@ -198,9 +237,7 @@ const onImageError = (index) => {
display: flex;
justify-content: center;
gap: 16rpx;
margin-top: -100rpx;
position: relative;
z-index: 3;
padding: 20rpx 0 8rpx;
}
.dot {
@@ -1,17 +1,23 @@
<template>
<view class="tab-page__header" :style="{ padding: `${statusBarHeight}rpx`, height: `${statusBarHeight + 80}rpx` }">
<!-- 状态栏占位区 App 端生效H5/小程序无状态栏概念 -->
<view class="tab-page__status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<!-- 导航栏主体 -->
<view class="tab-page__header">
<view v-if="showBack" :class="['tab-page__back-btn', { 'tab-page__back-btn--animate': isAnimating }]" @tap="goBack">
<text class="tab-page__back-icon"></text>
</view>
<view class="tab-page__title-wrap">
<text class="tab-page__title">{{ title }}</text>
<text class="tab-page__subtitle">{{ subtitle }}</text>
<text v-if="subtitle" class="tab-page__subtitle">{{ subtitle }}</text>
</view>
<view v-if="$slots.right" class="tab-page__right">
<slot name="right"></slot>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
const props = defineProps({
title: {
@@ -29,11 +35,19 @@ const props = defineProps({
})
const isAnimating = ref(false)
const statusBarHeight = ref(20)
const statusBarHeight = ref(0)
onMounted(() => {
// #ifdef APP-PLUS
// App 端获取真实状态栏高度(px)
const sysInfo = uni.getSystemInfoSync()
statusBarHeight.value = sysInfo.statusBarHeight || 20
statusBarHeight.value = sysInfo.statusBarHeight || 0
// #endif
// #ifndef APP-PLUS
// H5/小程序:状态栏由浏览器或框架处理,不额外占位
statusBarHeight.value = 0
// #endif
if (props.showBack) {
isAnimating.value = true
}
@@ -51,10 +65,16 @@ function goBack() {
</script>
<style lang="scss" scoped>
/* 状态栏占位:仅在 App 端有高度,H5/小程序为 0,背景继承页面 */
.tab-page__status-bar {
width: 100%;
}
.tab-page__header {
padding: 0 32rpx;
padding: 0 32rpx 24rpx 32rpx;
display: flex;
align-items: center;
height: 88rpx;
position: relative;
}
@@ -114,4 +134,11 @@ function goBack() {
font-size: 24rpx;
color: $text-muted;
}
.tab-page__right {
margin-left: 16rpx;
flex-shrink: 0;
display: flex;
align-items: center;
}
</style>
+51 -131
View File
@@ -12,26 +12,12 @@
<text class="entry-title">{{ item.title }}</text>
<text class="entry-desc">{{ item.desc }}</text>
</view>
<view v-if="showCheckInMenu" class="checkin-menu-overlay" @tap="showCheckInMenu = false">
<view class="checkin-menu" @tap.stop>
<view class="menu-item" @tap="handleStoreCheckIn">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/checkIn.png" mode="aspectFit" class="menu-icon" />
<text class="menu-text">到店签到</text>
</view>
<view class="menu-item" @tap="handleGroupCourseCheckIn">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/course.png" mode="aspectFit" class="menu-icon" />
<text class="menu-text">团课签到</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
const showCheckInMenu = ref(false)
import { qrSignInGroupCourse } from '@/api/groupCourse.js'
import { getMemberId } from '@/utils/request.js'
const handleEntryClick = (item) => {
if (item.title === '签到') {
@@ -53,7 +39,8 @@ const handleEntryClick = (item) => {
})
return
}
showCheckInMenu.value = !showCheckInMenu.value
// 直接调用扫码签到
qrScanSignIn()
} else if (item.path) {
if (item.isTabBar) {
uni.switchTab({
@@ -67,30 +54,57 @@ const handleEntryClick = (item) => {
}
}
const handleStoreCheckIn = () => {
showCheckInMenu.value = false
uni.navigateTo({
url: '/pages/checkIn/checkIn'
})
}
const handleGroupCourseCheckIn = () => {
showCheckInMenu.value = false
const qrScanSignIn = () => {
uni.scanCode({
onlyFromCamera: true,
success: (res) => {
success: async (res) => {
console.log('扫码结果:', res)
uni.showToast({
title: '扫码成功',
icon: 'success'
})
const qrContent = res.result
// 解析二维码内容获取 courseId
let courseId = null
try {
const parsed = JSON.parse(qrContent)
courseId = Number(parsed.courseId)
} catch {
const num = Number(qrContent)
if (!isNaN(num)) {
courseId = num
}
}
if (!courseId) {
uni.showToast({ title: '无效的签到二维码', icon: 'none' })
return
}
const memberId = getMemberId()
if (!memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
uni.showLoading({ title: '签到中...' })
try {
const result = await qrSignInGroupCourse(courseId, memberId)
uni.hideLoading()
if (result.success) {
uni.showToast({ title: '签到成功', icon: 'success' })
setTimeout(() => {
uni.navigateTo({ url: '/pages/memberInfo/myCourses' })
}, 1200)
} else {
uni.showToast({ title: result.message || '签到失败', icon: 'none', duration: 3000 })
}
} catch (e) {
uni.hideLoading()
console.error('团课签到失败:', e)
const errMsg = e?.message || e?.data?.message || '签到失败'
uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
}
},
fail: (err) => {
console.error('扫码失败:', err)
uni.showToast({
title: '扫码失败',
icon: 'none'
})
}
})
}
@@ -118,15 +132,10 @@ const entries = [
display: flex;
justify-content: space-between;
padding: 32rpx 24rpx;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
background: #FFFFFF;
margin: 24rpx;
border-radius: 28rpx;
box-shadow: 0 8rpx 32rpx var(--shadow-blue-light);
border: 1rpx solid rgba(255, 255, 255, 0.7);
position: relative;
z-index: 3;
box-shadow: 0 8rpx 32rpx rgba(45,74,90,0.08);
}
.entry-item {
@@ -182,93 +191,4 @@ const entries = [
font-size: 22rpx;
color: var(--tabbar-text-inactive);
}
.checkin-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding-top: 280rpx;
padding-right: 60rpx;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.checkin-menu {
position: absolute;
right: 0;
top: 140rpx;
background: white;
border-radius: 24rpx;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.15);
padding: 16rpx;
min-width: 320rpx;
animation: slideDown 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&::before {
content: '';
position: absolute;
top: -24rpx;
right: 60rpx;
width: 0;
height: 0;
border-left: 24rpx solid transparent;
border-right: 24rpx solid transparent;
border-bottom: 24rpx solid white;
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-40rpx) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.menu-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
border-radius: 16rpx;
transition: all 0.2s ease;
&:active {
background: rgba(0, 0, 0, 0.04);
transform: scale(0.98);
}
& + .menu-item {
margin-top: 8rpx;
}
}
.menu-icon {
width: 48rpx;
height: 48rpx;
margin-right: 20rpx;
}
.menu-text {
font-size: 30rpx;
font-weight: 600;
color: #2D4A5A;
}
</style>
@@ -72,7 +72,10 @@
<script setup>
import { ref, onMounted } from 'vue'
import { getGroupCoursePage } from '@/api/main.js'
import { onShow } from '@dcloudio/uni-app'
import { getActiveRecommendCourses } from '@/api/groupCourse.js'
console.log('[RecommendCourses] 组件脚本已加载')
// 推荐课程数据列表
const courses = ref([])
@@ -105,18 +108,23 @@ const getImageUrl = (coverImage) => {
// 获取推荐课程
const fetchRecommendCourses = async () => {
console.log('[RecommendCourses] fetchRecommendCourses 开始请求')
try {
const res = await getGroupCoursePage({
page: 0, size: 10, sort: 'current_members', order: 'desc'
}, { cache: true, cacheTime: 5 * 60 * 1000 })
if (res && res.content && Array.isArray(res.content)) {
// 筛选掉已结束和已满员的课程
const filteredCourses = res.content.filter(course => {
return course.status !== '2' && course.currentMembers < course.maxMembers
const res = await getActiveRecommendCourses()
console.log('[RecommendCourses] API 返回:', JSON.stringify(res).substring(0, 200))
if (res && Array.isArray(res)) {
// 提取推荐中的 groupCourse,只排除已满员的课程(已结束的仍展示,点击时拦截)
const filteredCourses = res.filter(recommend => {
const course = recommend.groupCourse
if (!course) return false
const current = Number(course.currentMembers) || 0
const max = Number(course.maxMembers) || 999
return current < max
})
// 只取前3个符合条件的课程
courses.value = filteredCourses.slice(0, 3).map(course => {
// 只取前3个符合条件的推荐课程
courses.value = filteredCourses.slice(0, 3).map(recommend => {
const course = recommend.groupCourse
return {
id: course.id,
image: getImageUrl(course.coverImage),
@@ -127,6 +135,9 @@ const fetchRecommendCourses = async () => {
rawData: course
}
})
console.log('[RecommendCourses] 最终展示 courses.value 数量:', courses.value.length)
} else {
console.warn('[RecommendCourses] API 返回格式异常:', res)
}
} catch (err) {
console.error('获取推荐课程失败:', err)
@@ -143,7 +154,14 @@ const handleViewMore = () => {
uni.navigateTo({ url: '/pages/recommendCourses/index' })
}
onMounted(() => { fetchRecommendCourses() })
onMounted(() => { console.log('[RecommendCourses] onMounted 触发'); fetchRecommendCourses() })
onShow(() => {
console.log('[RecommendCourses] onShow 触发,刷新推荐课程')
fetchRecommendCourses()
})
defineExpose({ fetchRecommendCourses })
</script>
<style lang="scss">
@@ -45,7 +45,10 @@
<script setup>
import { ref, onMounted } from 'vue'
import { getGroupCoursePage } from '@/api/main.js'
import { onShow } from '@dcloudio/uni-app'
import { getActiveRecommendCourses } from '@/api/groupCourse.js'
console.log('[TodayRecommend] 组件脚本已加载')
// 今日推荐数据列表
const recommends = ref([])
@@ -78,16 +81,22 @@ const getImageUrl = (coverImage) => {
// 获取今日推荐
const fetchTodayRecommend = async () => {
console.log('[TodayRecommend] fetchTodayRecommend 开始请求')
try {
const res = await getGroupCoursePage({
page: 0, size: 10, sort: 'current_members', order: 'desc'
}, { cache: true, cacheTime: 5 * 60 * 1000 })
if (res && res.content && Array.isArray(res.content)) {
const filteredCourses = res.content.filter(course => {
return course.status !== '2' && course.currentMembers < course.maxMembers
const res = await getActiveRecommendCourses()
console.log('[TodayRecommend] API 返回:', JSON.stringify(res).substring(0, 200))
if (res && Array.isArray(res)) {
// 提取推荐中的 groupCourse,只排除已满员的课程
const filteredCourses = res.filter(recommend => {
const course = recommend.groupCourse
if (!course) return false
const current = Number(course.currentMembers) || 0
const max = Number(course.maxMembers) || 999
return current < max
})
recommends.value = filteredCourses.slice(0, 3).map(course => {
recommends.value = filteredCourses.slice(0, 3).map(recommend => {
const course = recommend.groupCourse
return {
id: course.id,
image: getImageUrl(course.coverImage),
@@ -97,6 +106,9 @@ const fetchTodayRecommend = async () => {
participants: String(course.currentMembers || 0)
}
})
console.log('[TodayRecommend] 最终展示 recommends.value 数量:', recommends.value.length)
} else {
console.warn('[TodayRecommend] API 返回格式异常:', res)
}
} catch (err) {
console.error('获取今日推荐失败:', err)
@@ -108,7 +120,14 @@ const handleItemClick = (item) => {
uni.navigateTo({ url: `/pages/groupCourse/detail?id=${item.id}` })
}
onMounted(() => { fetchTodayRecommend() })
onMounted(() => { console.log('[TodayRecommend] onMounted 触发'); fetchTodayRecommend() })
onShow(() => {
console.log('[TodayRecommend] onShow 触发,刷新今日推荐')
fetchTodayRecommend()
})
defineExpose({ fetchTodayRecommend })
</script>
<style lang="scss" scoped>
@@ -10,59 +10,51 @@
:hover-stay-time="150"
@tap="$emit('view-all')"
>
<text class="booking-section__view-all">预约记录</text>
<text class="booking-section__view-all">查看全部</text>
<image
class="booking-section__link-arrow"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright4.png"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright2.png"
mode="aspectFit"
/>
</view>
</view>
</view>
<view class="booking-section__list">
<!-- 无数据 -->
<view v-if="!displayItems || displayItems.length === 0" class="booking-section__empty">
<text class="booking-section__empty-text">暂无预约记录</text>
</view>
<view v-else class="booking-section__list-inner">
<view
v-for="item in previewItems"
v-for="(item, index) in displayItems"
:key="item.id"
class="booking-section__row"
>
<view v-if="index > 0" class="booking-section__divider"></view>
<view
class="booking-section__item"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="$emit('item-tap', item)"
>
<view class="booking-section__item-inner">
<view class="booking-section__date">
<view class="booking-section__date-inner">
<text class="booking-section__num">{{ item.dateDay }}</text>
<text class="booking-section__date-sub">{{ item.dateMonth }}</text>
</view>
</view>
<view
class="booking-section__dot"
:class="'booking-section__dot--' + item.status"
></view>
<view class="booking-section__content">
<view class="booking-section__content-inner">
<text class="booking-section__desc">{{ item.desc }}</text>
<view class="booking-section__meta">
<view class="booking-section__meta-inner">
<image
class="booking-section__icon-coach"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user2.png"
mode="aspectFit"
/>
<text class="booking-section__coach">教练{{ item.coach }}</text>
<image
class="booking-section__icon-location"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/mappin1.png"
mode="aspectFit"
/>
<text class="booking-section__text">{{ item.location }}</text>
<text class="booking-section__desc">{{ item.title }}</text>
<text class="booking-section__text">{{ item.time }}</text>
</view>
</view>
</view>
</view>
<view class="booking-section__status-wrap">
<view
class="booking-section__status-badge"
:class="'booking-section__status-badge--' + item.status"
class="booking-section__tag-badge"
:class="'booking-section__tag-badge--' + item.status"
>
<text
class="booking-section__status-text"
:class="{ 'booking-section__status-text--pending': item.status === 'pending' }"
class="booking-section__tag-text"
:class="'booking-section__tag-text--' + item.status"
>
{{ item.statusLabel }}
</text>
@@ -70,8 +62,7 @@
</view>
</view>
</view>
<view v-if="!previewItems.length" class="booking-section__empty">
<text class="booking-section__empty-text">暂无进行中的预约</text>
</view>
</view>
</view>
</view>
@@ -89,25 +80,186 @@ const props = defineProps({
defineEmits(['view-all', 'item-tap'])
const previewItems = computed(() => {
return props.items
const displayItems = computed(() => {
return props.items || []
})
</script>
<style lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-booking-list.css';
@import '@/common/style/memberInfo/member-info-tap.css';
.booking-section {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.booking-section__inner {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
width: 100%;
position: relative;
}
.booking-section__header {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.booking-section__header-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.booking-section__title {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: var(--font-weight-bold);
color: var(--text-dark);
}
.booking-section__list {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 14px;
box-shadow: var(--shadow-sm);
background-color: var(--bg-white);
border: 1px solid var(--border-light);
overflow: hidden;
}
.booking-section__list-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.booking-section__empty {
padding: 48rpx 32rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.booking-section__empty-text {
font-size: 14px;
color: #8A99B4;
font-size: 26rpx;
color: #999;
}
.booking-section__row {
width: 100%;
}
.booking-section__divider {
height: 1px;
background: var(--border-light);
margin: 0 16rpx;
}
.booking-section__item {
padding: 24rpx 16rpx;
}
.booking-section__item-inner {
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
}
.booking-section__dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
flex-shrink: 0;
}
.booking-section__dot--ongoing {
background: #4CAF50;
}
.booking-section__dot--completed {
background: #2196F3;
}
.booking-section__dot--cancelled {
background: var(--text-muted);
}
.booking-section__content {
flex: 1;
min-width: 0;
}
.booking-section__content-inner {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.booking-section__desc {
font-size: var(--font-size-md);
font-family: var(--font-family);
color: var(--text-dark);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.booking-section__text {
font-size: var(--font-size-sm);
font-family: var(--font-family);
color: var(--text-muted);
}
.booking-section__tag-badge {
padding: 4rpx 14rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
.booking-section__tag-badge--ongoing {
background: rgba(76, 175, 80, 0.1);
}
.booking-section__tag-badge--completed {
background: rgba(33, 150, 243, 0.1);
}
.booking-section__tag-badge--cancelled {
background: rgba(153, 153, 153, 0.1);
}
.booking-section__tag-text {
font-size: var(--font-size-xs);
font-family: var(--font-family);
}
.booking-section__tag-text--ongoing {
color: #4CAF50;
}
.booking-section__tag-text--completed {
color: #2196F3;
}
.booking-section__tag-text--cancelled {
color: var(--text-muted);
}
</style>
@@ -20,7 +20,11 @@
</view>
</view>
<view class="checkin-section__list">
<view class="checkin-section__list-inner">
<!-- 无数据 -->
<view v-if="!items || items.length === 0" class="checkin-section__empty">
<text class="checkin-section__empty-text">暂无签到记录</text>
</view>
<view v-else class="checkin-section__list-inner">
<view
v-for="(item, index) in items"
:key="item.id"
@@ -79,4 +83,16 @@ defineEmits(['view-all', 'item-tap'])
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-check-in-list.css';
@import '@/common/style/memberInfo/member-info-tap.css';
.checkin-section__empty {
padding: 48rpx 32rpx;
display: flex;
align-items: center;
justify-content: center;
}
.checkin-section__empty-text {
font-size: 26rpx;
color: #999;
}
</style>
@@ -1,87 +1,48 @@
<template>
<view class="profile-header">
<!-- 用户信息区深蓝渐变 -->
<view class="profile-header__hero">
<view class="profile-header__inner" v-if="isLogin">
<view class="profile-header__user" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="handleUserTap">
<view class="profile-header__user-inner">
<view class="profile-header__avatar-wrap">
<view class="profile-header__avatar-ring">
<!-- 登录状态 -->
<view class="profile-card" v-if="isLogin" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="handleUserTap">
<view class="profile-card__main">
<view class="profile-avatar">
<image
class="profile-header__avatar"
class="profile-avatar__img"
:key="userInfo.avatar"
:src="displayAvatar"
mode="aspectFill"
/>
</view>
<view class="profile-header__avatar-badge">
<image
class="profile-header__avatar-badge-icon"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/camera.png"
mode="aspectFit"
/>
<view class="profile-info">
<view class="profile-info__row">
<text class="profile-info__name">{{ userInfo.name || "活氧舱用户" }}</text>
<view class="profile-info__badge">
<image class="profile-info__badge-icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/crown0.png" mode="aspectFit" />
<text class="profile-info__level">{{ userInfo.memberLevel }}</text>
</view>
</view>
<view class="profile-header__user-meta">
<view class="profile-header__user-meta-inner">
<text class="profile-header__name">{{ userInfo.name || "活氧舱用户" }}</text>
<text class="profile-header__phone">{{ userInfo.phone }}</text>
<view class="profile-header__badge">
<image
class="profile-header__badge-icon"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/crown0.png"
mode="aspectFit"
/>
<text class="profile-header__level">{{ userInfo.memberLevel }}</text>
<text class="profile-info__phone">{{ userInfo.phone }}</text>
</view>
</view>
<view class="profile-card__stats">
<view class="profile-stat">
<text class="profile-stat__value">{{ stats.checkInCount ?? 0 }}</text>
<text class="profile-stat__label">累计签到</text>
</view>
</view>
</view>
<view class="profile-header__stats">
<view class="profile-header__stats-inner">
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.checkInCount }}</text>
<text class="profile-header__stat-label">累计签到</text>
</view>
</view>
<view class="profile-header__stat-divider"></view>
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.trainingHours }}</text>
<text class="profile-header__stat-label">训练时长</text>
</view>
</view>
<view class="profile-header__stat-divider"></view>
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.pointsBalance }}</text>
<text class="profile-header__stat-label">累计积分</text>
</view>
<view class="profile-stat">
<text class="profile-stat__value">{{ stats.courseCount ?? 0 }}</text>
<text class="profile-stat__label">报课次数</text>
</view>
</view>
</view>
<!-- 未登录状态 -->
<view class="profile-card profile-card--guest" v-else @tap="handleGuestLogin">
<view class="profile-card__main">
<view class="profile-avatar">
<image class="profile-avatar__img" src="/static/OIP-C.png" mode="aspectFill" />
</view>
<view class="profile-header__inner" v-else>
<view class="profile-header__user" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="handleUserTap">
<view
class="profile-header__user-inner--guest"
@tap="handleGuestLogin"
>
<view class="profile-header__avatar-wrap--guest">
<image
class="profile-header__avatar--guest"
src="/static/OIP-C.png"
mode="aspectFill"
/>
</view>
<view class="profile-header__user-meta--guest">
<text class="profile-header__name--guest">小伙伴点我登录</text>
<text class="profile-header__phone--guest">陪你遇见更好的自己 ~</text>
</view>
</view>
<view class="profile-info">
<text class="profile-info__name">小伙伴点我登录</text>
<text class="profile-info__phone">陪你遇见更好的自己 ~</text>
</view>
</view>
</view>
@@ -97,7 +58,6 @@ const props = defineProps({
isLogin: { type: Boolean }
})
const emit = defineEmits(['user-info', 'guest-login'])
const displayAvatar = computed(() => {
@@ -115,8 +75,112 @@ function handleGuestLogin() {
}
</script>
<style lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-header.css';
@import '@/common/style/memberInfo/member-info-tap.css';
<style lang="scss" scoped>
.profile-header {
padding: 24rpx 24rpx 0;
}
.profile-card {
background: #FFFFFF;
border-radius: 20rpx;
padding: 28rpx 28rpx 24rpx;
box-shadow: 0 4rpx 20rpx rgba(45, 74, 90, 0.06);
display: flex;
flex-direction: column;
gap: 24rpx;
&--guest {
cursor: pointer;
}
}
.profile-card__main {
display: flex;
align-items: center;
gap: 20rpx;
}
.profile-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
border: 3rpx solid #E8F4F8;
}
.profile-avatar__img {
width: 100%;
height: 100%;
border-radius: 50%;
}
.profile-info {
flex: 1;
min-width: 0;
}
.profile-info__row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 6rpx;
}
.profile-info__name {
font-size: 32rpx;
font-weight: 700;
color: #1A202C;
line-height: 1.2;
}
.profile-info__badge {
display: flex;
align-items: center;
gap: 4rpx;
background: linear-gradient(135deg, #FFF7ED, #FFF1E6);
border-radius: 20rpx;
padding: 4rpx 14rpx;
}
.profile-info__badge-icon {
width: 24rpx;
height: 24rpx;
}
.profile-info__level {
font-size: 20rpx;
font-weight: 600;
color: #ED8936;
}
.profile-info__phone {
font-size: 24rpx;
color: #A0AEC0;
}
.profile-card__stats {
display: flex;
justify-content: space-around;
padding-top: 20rpx;
border-top: 1rpx solid #F0F4F8;
}
.profile-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.profile-stat__value {
font-size: 36rpx;
font-weight: 700;
color: #2D4A5A;
}
.profile-stat__label {
font-size: 22rpx;
color: #A0AEC0;
}
</style>
@@ -1,98 +1,82 @@
<template>
<view class="member-card-section">
<view class="member-card-section__inner">
<!-- 标题栏 -->
<view class="member-card-section__head">
<view class="member-card-section__head-inner">
<text class="member-card-section__title">
我的会员卡
</text>
<view
class="member-card-section__count"
>
<text class="member-card-section__count-num">{{ cardInfo.activeCount || 0 }}</text>
<text class="member-card-section__title">我的会员卡</text>
<view class="member-card-section__count" v-if="activeCount > 0">
<text class="member-card-section__count-num">{{ activeCount }}</text>
<text class="member-card-section__count-text">张有效</text>
</view>
</view>
</view>
<!-- 三张卡展示 -->
<view class="card-three-row">
<!-- 储值卡 -->
<view
class="member-card-preview"
class="card-three-item"
:class="{ 'card-three-item--inactive': !storedCard.hasCard }"
hover-class="mi-tap-card--hover"
:hover-stay-time="150"
@tap="$emit('stored-card-tap')"
>
<view class="card-three-item__icon-wrap card-three-item__icon-wrap--stored">
<uni-icons type="wallet" size="28rpx" :color="storedCard.hasCard ? '#667eea' : '#B0BEC5'" />
</view>
<text class="card-three-item__label">储值卡</text>
<text class="card-three-item__value" v-if="storedCard.hasCard">¥{{ storedCard.balance }}</text>
<text class="card-three-item__value card-three-item__value--empty" v-else>未购买</text>
<text class="card-three-item__sub" v-if="storedCard.hasCard">余额</text>
</view>
<!-- 次数卡 -->
<view
class="card-three-item"
:class="{ 'card-three-item--inactive': !countCard.hasCard }"
>
<view class="card-three-item__icon-wrap card-three-item__icon-wrap--count">
<uni-icons type="loop" size="28rpx" :color="countCard.hasCard ? '#4CAF50' : '#B0BEC5'" />
</view>
<text class="card-three-item__label">次数卡</text>
<text class="card-three-item__value" v-if="countCard.hasCard">{{ countCard.remainingTimes }}</text>
<text class="card-three-item__value card-three-item__value--empty" v-else>未购买</text>
<text class="card-three-item__sub" v-if="countCard.hasCard">剩余</text>
</view>
<!-- 时长卡 -->
<view
class="card-three-item"
:class="{ 'card-three-item--inactive': !timeCard.hasCard }"
>
<view class="card-three-item__icon-wrap card-three-item__icon-wrap--time">
<uni-icons type="calendar" size="28rpx" :color="timeCard.hasCard ? '#FF6B35' : '#B0BEC5'" />
</view>
<text class="card-three-item__label">时长卡</text>
<text class="card-three-item__value" v-if="timeCard.hasCard">{{ timeCard.remainingDays }}</text>
<text class="card-three-item__value card-three-item__value--empty" v-else>未购买</text>
<text class="card-three-item__sub" v-if="timeCard.hasCard">剩余</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="card-actions">
<view
class="card-actions__btn card-actions__btn--primary"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="$emit('view-all')"
>
<view class="member-card-preview__inner">
<view class="member-card-preview__head">
<view class="member-card-preview__head-inner">
<view
class="member-card-preview__type-row"
>
<view
class="member-card-preview__icon-wrap"
>
<view
class="member-card-preview__icon-border"
>
<view
class="member-card-preview__icon-bg"
></view>
<view
class="member-card-preview__icon-stroke"
></view>
</view>
<image class="member-card-preview__icon-line" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Line_2_468.png" mode="aspectFill" />
</view>
<text
class="member-card-preview__name"
>
{{ cardInfo.hasActiveCard ? cardInfo.name : '暂无会员卡' }}
</text>
<text class="card-actions__btn-text">查看卡片</text>
</view>
<view
class="member-card-preview__tag"
:class="getTagClass()"
v-if="cardInfo.hasActiveCard"
>
<text class="member-card-preview__tag-text">
{{ getTagText() }}
</text>
</view>
</view>
</view>
<text class="member-card-preview__expire" v-if="cardInfo.hasActiveCard && cardInfo.expireDate">
有效期至 {{ cardInfo.expireDate }}
</text>
<view class="member-card-preview__footer">
<view class="member-card-preview__footer-inner">
<view
class="member-card-preview__days"
v-if="cardInfo.hasActiveCard"
>
<text
class="member-card-preview__days-num"
:class="getDaysClass()"
>
{{ getDisplayDays() }}
</text>
<text
class="member-card-preview__days-unit"
>
{{ getDaysUnit() }}
</text>
</view>
<view
class="member-card-preview__purchase"
class="card-actions__btn card-actions__btn--ghost"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap.stop="$emit('purchase')"
>
<text
class="member-card-preview__purchase-text"
>
购买新卡
</text>
</view>
</view>
</view>
<text class="card-actions__btn-text card-actions__btn-text--ghost">购买新卡</text>
</view>
</view>
</view>
@@ -100,176 +84,260 @@
</template>
<script setup>
import { reactive, onMounted } from 'vue'
import { getPrimaryMemberCard, getMyMemberCardsWithStatus } from '@/api/main.js'
import { reactive, ref, onMounted } from 'vue'
import { getStoredCardInfo, getMyMemberCardsWithStatus } from '@/api/main.js'
import { getMemberId } from '@/utils/request.js'
defineEmits(['view-all', 'purchase'])
const cardInfo = reactive({
name: '',
type: '',
remainingTimes: 0,
remainingDays: 0,
expireDate: '',
activeCount: 0,
isExpiring: false,
isExpired: false,
isUsedUp: false,
hasActiveCard: false
})
defineEmits(['view-all', 'purchase', 'stored-card-tap'])
const storedCard = reactive({ hasCard: false, balance: 0 })
const countCard = reactive({ hasCard: false, remainingTimes: 0 })
const timeCard = reactive({ hasCard: false, remainingDays: 0 })
const activeCount = ref(0)
let isLoading = false
// 格式化日期
function formatDate(dateStr) {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
} catch (e) {
return dateStr
}
}
// 加载会员卡数据
async function loadMemberCard() {
if (isLoading) {
console.log('[MemberInfoMemberCard] 正在加载中,跳过重复请求')
return
}
if (isLoading) return
isLoading = true
try {
// 使用统一的 getMemberId 方法获取会员ID
const memberId = getMemberId()
console.log('[MemberInfoMemberCard] memberId:', memberId)
if (!memberId) return
if (!memberId) {
console.log('[MemberInfoMemberCard] 未登录,无会员ID')
return
}
console.log('[MemberInfoMemberCard] 加载会员卡数据, memberId:', memberId)
// 并行获取主要会员卡和有效卡列表
const [primaryCardRes, activeCardsRes] = await Promise.all([
getPrimaryMemberCard(memberId),
getMyMemberCardsWithStatus(memberId, 'active')
// 并行获取储值卡和所有有效卡
const [storedRes, activeCardsRes] = await Promise.all([
getStoredCardInfo(memberId).catch(() => null),
getMyMemberCardsWithStatus(memberId, 'active').catch(() => null)
])
console.log('[MemberInfoMemberCard] getPrimaryMemberCard 返回:', JSON.stringify(primaryCardRes, null, 2))
console.log('[MemberInfoMemberCard] getMyMemberCardsWithStatus(active) 返回:', JSON.stringify(activeCardsRes, null, 2))
// 解析主要会员卡数据
let displayCard = null
if (primaryCardRes?.data) {
displayCard = primaryCardRes.data
} else if (primaryCardRes?.value) {
displayCard = primaryCardRes.value
} else if (primaryCardRes && typeof primaryCardRes === 'object') {
displayCard = primaryCardRes
// 解析储值卡
if (storedRes) {
const data = storedRes.data || storedRes.value || storedRes
if (data && (data.balance > 0 || data.totalAmount > 0 || data.id)) {
storedCard.hasCard = true
storedCard.balance = data.balance || data.totalAmount || 0
}
}
// 计算有效会员卡数量(过滤储值卡)
let activeCount = 0
let allActiveCards = []
// 解析有效卡列表(排除储值卡)
let allActive = []
if (activeCardsRes?.value) {
allActiveCards = activeCardsRes.value
allActive = activeCardsRes.value
} else if (Array.isArray(activeCardsRes)) {
allActiveCards = activeCardsRes
allActive = activeCardsRes
} else if (activeCardsRes?.data) {
allActiveCards = Array.isArray(activeCardsRes.data) ? activeCardsRes.data : (activeCardsRes.data.value || [])
allActive = Array.isArray(activeCardsRes.data) ? activeCardsRes.data : (activeCardsRes.data.value || [])
}
// 过滤储值卡
allActiveCards = allActiveCards.filter(card => card.memberCardType !== 'STORED_VALUE_CARD')
activeCount = allActiveCards.length
allActive = allActive.filter(card => card.memberCardType !== 'STORED_VALUE_CARD')
activeCount.value = allActive.length
// 计算卡片状态
let diffDays = 0
let isExpiring = false
let isExpired = false
let isUsedUp = false
let displayDays = 0
// 查找次数卡
const countCardData = allActive.find(c => c.memberCardType === 'COUNT_CARD')
if (countCardData) {
countCard.hasCard = true
countCard.remainingTimes = countCardData.remainingTimes || countCardData.remainingAmount || 0
}
if (displayCard) {
if (displayCard.status === 'USED_UP') {
isUsedUp = true
displayDays = displayCard.remainingTimes || 0
} else if (displayCard.expireTime) {
const expireTime = new Date(displayCard.expireTime)
// 查找时长卡(优先找有效期最长的)
const timeCards = allActive.filter(c => c.memberCardType === 'TIME_CARD')
if (timeCards.length > 0) {
timeCard.hasCard = true
// 计算剩余天数
let maxDays = 0
timeCards.forEach(c => {
if (c.expireTime) {
const now = new Date()
diffDays = Math.ceil((expireTime - now) / (1000 * 60 * 60 * 24))
isExpiring = diffDays > 0 && diffDays <= 3
isExpired = diffDays <= 0
displayDays = Math.abs(diffDays)
const expire = new Date(c.expireTime)
const days = Math.max(0, Math.ceil((expire - now) / (1000 * 60 * 60 * 24)))
if (days > maxDays) maxDays = days
}
})
timeCard.remainingDays = maxDays
}
console.log(displayCard)
// 更新数据
cardInfo.name = displayCard?.memberCardName || '暂无会员卡'
cardInfo.type = displayCard?.memberCardType || ''
cardInfo.remainingTimes = displayCard?.remainingTimes || displayCard?.remainingAmount || 0
cardInfo.remainingDays = displayDays
cardInfo.expireDate = displayCard?.expireTime ? formatDate(displayCard.expireTime) : ''
cardInfo.activeCount = activeCount
cardInfo.isExpiring = isExpiring
cardInfo.isExpired = isExpired
cardInfo.isUsedUp = isUsedUp
cardInfo.hasActiveCard = activeCount > 0
console.log('[MemberInfoMemberCard] 更新后的 cardInfo:', JSON.stringify(cardInfo, null, 2))
} catch (e) {
console.error('[MemberInfoMemberCard] 加载会员卡数据失败:', e)
cardInfo.hasActiveCard = false
console.error('[MemberInfoMemberCard] 加载失败:', e)
} finally {
isLoading = false
}
}
// 组件挂载时加载数据
onMounted(() => {
console.log('[MemberInfoMemberCard] 组件已挂载,开始加载数据')
loadMemberCard()
})
function getTagClass() {
if (cardInfo.isExpiring) return 'member-card-preview__tag--expiring'
if (cardInfo.isExpired) return 'member-card-preview__tag--expired'
if (cardInfo.isUsedUp) return 'member-card-preview__tag--expired'
return 'member-card-preview__tag--active'
}
function getTagText() {
if (cardInfo.isUsedUp) return '已用完'
if (cardInfo.isExpired) return '已失效'
if (cardInfo.isExpiring) return '临期'
return '有效'
}
function getDaysClass() {
if (cardInfo.isExpiring) return 'member-card-preview__days-num--expiring'
if (cardInfo.isExpired) return 'member-card-preview__days-num--expired'
if (cardInfo.isUsedUp) return 'member-card-preview__days-num--expired'
return ''
}
function getDisplayDays() {
if (cardInfo.isUsedUp) return cardInfo.remainingTimes || 0
return cardInfo.remainingDays || 0
}
function getDaysUnit() {
if (cardInfo.isUsedUp) return '次剩余'
return '天剩余'
}
defineExpose({ loadMemberCard })
</script>
<style lang="scss">
<style lang="scss" scoped>
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-member-card.css';
@import '@/common/style/memberInfo/member-info-tap.css';
// ========== 容器 ==========
.member-card-section {
width: 100%;
flex-shrink: 0;
}
.member-card-section__inner {
display: flex;
flex-direction: column;
gap: 20rpx;
width: 100%;
}
// ========== 标题栏 ==========
.member-card-section__head {
width: 100%;
}
.member-card-section__head-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.member-card-section__title {
font-size: var(--font-size-md);
font-weight: 700;
color: var(--text-dark);
}
.member-card-section__count {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 4rpx;
padding: 4rpx 14rpx;
border-radius: 20rpx;
background: rgba(255, 107, 53, 0.1);
flex-shrink: 0;
}
.member-card-section__count-num {
font-size: 26rpx;
font-weight: 700;
color: #FF6B35;
line-height: 1;
}
.member-card-section__count-text {
font-size: 22rpx;
font-weight: 400;
color: #FF6B35;
line-height: 1;
}
// ========== 三张卡片行 ==========
.card-three-row {
display: flex;
gap: 16rpx;
width: 100%;
}
.card-three-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
padding: 24rpx 12rpx;
border-radius: 16rpx;
background: #FFFFFF;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
transition: all 0.2s;
}
.card-three-item--inactive {
opacity: 0.55;
background: #F5F7FA;
box-shadow: none;
}
.card-three-item__icon-wrap {
width: 56rpx;
height: 56rpx;
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.card-three-item__icon-wrap--stored {
background: rgba(102, 126, 234, 0.1);
}
.card-three-item__icon-wrap--count {
background: rgba(76, 175, 80, 0.1);
}
.card-three-item__icon-wrap--time {
background: rgba(255, 107, 53, 0.1);
}
.card-three-item--inactive .card-three-item__icon-wrap {
background: rgba(0, 0, 0, 0.04);
}
.card-three-item__label {
font-size: 24rpx;
font-weight: 500;
color: #5E6F8D;
}
.card-three-item__value {
font-size: 26rpx;
font-weight: 700;
color: #1E2A3A;
line-height: 1;
}
.card-three-item__value--empty {
font-size: 24rpx;
font-weight: 400;
color: #B0BEC5;
}
.card-three-item__sub {
font-size: 20rpx;
color: #A0AEC0;
}
// ========== 操作按钮 ==========
.card-actions {
display: flex;
gap: 16rpx;
width: 100%;
}
.card-actions__btn {
flex: 1;
height: 72rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
}
.card-actions__btn--primary {
background: linear-gradient(135deg, #FF6B35, #FF8C5A);
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.3);
}
.card-actions__btn--ghost {
background: #FFFFFF;
border: 2rpx solid #FF6B35;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.card-actions__btn-text {
font-size: 28rpx;
font-weight: 600;
color: #FFFFFF;
}
.card-actions__btn-text--ghost {
color: #FF6B35;
}
</style>
@@ -33,6 +33,8 @@ export function useGroupCourseList() {
])
const timePeriodIndex = ref(0)
const isRecurring = ref(false) // 常态化团课筛选
const showTimePicker = ref(false)
const startDate = ref('')
const endDate = ref('')
@@ -138,9 +140,8 @@ export function useGroupCourseList() {
const handleBooking = (course) => {
console.log('[useGroupCourseList] 预约课程:', course)
uni.showToast({
title: `预约课程:${course.courseName}`,
icon: 'success'
uni.navigateTo({
url: `/pages/groupCourse/detail?id=${course.id}`
})
}
@@ -180,6 +181,10 @@ export function useGroupCourseList() {
params.timePeriod = timePeriod.value
}
if (isRecurring.value) {
params.isRecurring = true
}
if (sortOption.priceSort) {
params.priceSort = sortOption.priceSort
}
@@ -291,6 +296,7 @@ export function useGroupCourseList() {
sortIndex,
timePeriodOptions,
timePeriodIndex,
isRecurring,
showTimePicker,
startDate,
endDate,
@@ -0,0 +1,530 @@
# 团课推荐模块 API 文档
> **文档版本**: v1.0
> **创建日期**: 2026-06-15
> **作者**: 张翔
> **状态**: 正式发布
---
## 📋 目录
1. [概述](#概述)
2. [基础路径](#基础路径)
3. [团课推荐管理接口](#团课推荐管理接口)
- [获取所有团课推荐](#获取所有团课推荐)
- [获取所有启用的团课推荐](#获取所有启用的团课推荐)
- [根据ID获取团课推荐](#根据ID获取团课推荐)
- [根据团课ID获取推荐](#根据团课ID获取推荐)
- [创建团课推荐](#创建团课推荐)
- [更新团课推荐](#更新团课推荐)
- [删除团课推荐](#删除团课推荐)
- [启用团课推荐](#启用团课推荐)
- [禁用团课推荐](#禁用团课推荐)
4. [数据模型](#数据模型)
- [GroupCourseRecommend(团课推荐)](#GroupCourseRecommend团课推荐)
5. [状态码说明](#状态码说明)
6. [业务规则](#业务规则)
---
## 概述
团课推荐模块提供团课推荐信息的创建、编辑、查询、删除和状态管理功能。推荐信息包含团课ID、推荐标题、推荐内容、推荐理由、优先级等必要信息,支持按优先级排序展示。
## 基础路径
所有接口的基础路径为: `http://{host}:{port}/api/groupCourse/recommend`
---
## 团课推荐管理接口
### 获取所有团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/list` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| sortBy | string | 否 | priority | 排序字段(支持:priority、createdAt、updatedAt |
| sortOrder | string | 否 | desc | 排序方式(asc-升序,desc-降序) |
**成功响应** (200 OK):
```json
[
{
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"groupCourse": {
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-15T09:00:00",
"endTime": "2026-06-15T10:00:00",
"maxMembers": 20,
"currentMembers": 15,
"status": 0,
"location": "健身房A区",
"coverImage": "https://example.com/yoga.jpg",
"description": "适合初学者的瑜伽课程"
},
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
]
```
---
### 获取所有启用的团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/active` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**功能说明**: 获取系统中所有已启用的团课推荐列表,按优先级从高到低排序。
**成功响应** (200 OK):
```json
[
{
"id": 2,
"courseId": 3,
"recommendTitle": "新学员推荐",
"recommendContent": "专为新学员设计的入门课程,轻松上手",
"recommendReason": "零基础友好,教练耐心指导",
"priority": 20,
"isActive": true,
"groupCourse": {
"id": 3,
"courseName": "基础有氧",
"coachId": 2,
"courseType": 2,
"startTime": "2026-06-16T18:00:00",
"endTime": "2026-06-16T19:00:00",
"maxMembers": 30,
"currentMembers": 8,
"status": 0,
"location": "健身房B区",
"coverImage": "https://example.com/aerobic.jpg",
"description": "适合所有健身水平的有氧课程"
},
"createdAt": "2026-06-15T11:00:00",
"updatedAt": "2026-06-15T11:00:00"
}
]
```
---
### 根据ID获取团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/{id}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"groupCourse": {
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-15T09:00:00",
"endTime": "2026-06-15T10:00:00",
"maxMembers": 20,
"currentMembers": 15,
"status": 0,
"location": "健身房A区",
"coverImage": "https://example.com/yoga.jpg",
"description": "适合初学者的瑜伽课程"
},
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
```
**失败响应** (404 Not Found):
```json
{}
```
---
### 根据团课ID获取推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/course/{courseId}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| courseId | Long | 是 | 团课ID |
**成功响应** (200 OK):
```json
[
{
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"groupCourse": {
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-15T09:00:00",
"endTime": "2026-06-15T10:00:00",
"maxMembers": 20,
"currentMembers": 15,
"status": 0,
"location": "健身房A区"
},
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
]
```
---
### 创建团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/recommend` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**请求体**:
```json
{
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true
}
```
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| courseId | Long | **是** | - | 团课ID(必须是有效的团课) |
| recommendTitle | String | 否 | - | 推荐标题 |
| recommendContent | String | 否 | - | 推荐内容 |
| recommendReason | String | 否 | - | 推荐理由 |
| priority | Integer | 否 | 0 | 优先级(数字越大优先级越高) |
| isActive | Boolean | 否 | true | 是否启用 |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐创建成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课ID不能为空"
}
```
---
### 更新团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | PUT |
| **接口路径** | `/api/groupCourse/recommend/{id}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**请求体**:
```json
{
"recommendTitle": "本周热门课程(更新)",
"recommendContent": "更新后的推荐内容",
"recommendReason": "更新后的推荐理由",
"priority": 15,
"isActive": true
}
```
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| courseId | Long | 否 | 团课ID |
| recommendTitle | String | 否 | 推荐标题 |
| recommendContent | String | 否 | 推荐内容 |
| recommendReason | String | 否 | 推荐理由 |
| priority | Integer | 否 | 优先级 |
| isActive | Boolean | 否 | 是否启用 |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐更新成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程(更新)",
"recommendContent": "更新后的推荐内容",
"recommendReason": "更新后的推荐理由",
"priority": 15,
"isActive": true,
"updatedAt": "2026-06-15T12:00:00"
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
### 删除团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | DELETE |
| **接口路径** | `/api/groupCourse/recommend/{id}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐删除成功"
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
### 启用团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/recommend/{id}/enable` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐启用成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"isActive": true
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
### 禁用团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/recommend/{id}/disable` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐禁用成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"isActive": false
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
## 数据模型
### GroupCourseRecommend(团课推荐)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | Long | 主键ID |
| courseId | Long | 团课ID(关联group_course.id |
| recommendTitle | String | 推荐标题 |
| recommendContent | String | 推荐内容 |
| recommendReason | String | 推荐理由 |
| priority | Integer | 优先级(数字越大优先级越高),默认0 |
| isActive | Boolean | 是否启用,默认true |
| groupCourse | GroupCourse | 关联的团课信息(查询时自动填充) |
| createdBy | String | 创建人 |
| updatedBy | String | 更新人 |
| createdAt | LocalDateTime | 创建时间 |
| updatedAt | LocalDateTime | 更新时间 |
| deletedAt | LocalDateTime | 删除时间(软删除) |
---
## 状态码说明
### 推荐状态
| 状态值 | 含义 |
|--------|------|
| true | 启用 |
| false | 禁用 |
---
## 业务规则
### 团课推荐管理
1. **创建推荐**:团课ID为必填项,且必须是有效的团课
2. **优先级排序**:获取启用的推荐列表时,按优先级从高到低排序
3. **删除推荐**:采用软删除机制,数据保留可恢复
4. **状态管理**:支持启用/禁用推荐状态,禁用的推荐不会在推荐列表中显示
---
## 附录:错误响应格式
所有接口的错误响应统一格式:
```json
{
"success": false,
"message": "错误描述信息"
}
```
---
*文档结束*
+1
View File
@@ -39,6 +39,7 @@
{
"path": "pages/memberInfo/memberInfo",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的",
"app-plus": {
"animationType": "fade-in",
+1 -1
View File
@@ -110,7 +110,7 @@ import { getMemberId } from '@/utils/request.js'
const STQRC = ref(false)//是否扫码
const isCheckIn = ref(false)
const isHaveCard = ref(true)
const webSoketURL = "ws://localhost:8084/webSocket/checkIn"
const webSoketURL = "ws://192.168.5.15:8084/webSocket/checkIn"
const qrcode = ref("")
+2
View File
@@ -130,6 +130,7 @@ function handleTabActive(index) {
}
onLoad(() => {
uni.hideLoading()
// 优先显示缓存
const hasCache = loadFromCache()
if (!hasCache) {
@@ -143,6 +144,7 @@ onLoad(() => {
})
onShow(() => {
uni.hideLoading()
// 每次显示时尝试刷新数据(后台静默更新)
if (!loading.value) {
loadFromNetwork()
+87 -25
View File
@@ -228,16 +228,36 @@
</view>
</view>
</view>
<!-- 支付密码弹窗 -->
<PayPasswordModal
:visible="showPayPwd"
title="支付验证"
:subtitle="course.courseName"
:amount="course.storedValueAmount || 0"
amount-label="课程金额"
@confirm="onPayPasswordConfirm"
@cancel="onPayPasswordCancel"
/>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getGroupCourseDetail, bookGroupCourse } from '@/api/groupCourse.js'
import { getPrimaryMemberCard } from '@/api/main.js'
import { getMemberId } from '@/utils/request.js'
import PageHeader from '@/components/index/PageHeader.vue'
import PayPasswordModal from '@/components/global/PayPasswordModal.vue'
// 加载状态
const loading = ref(true)
// 支付密码弹窗状态
const showPayPwd = ref(false)
const payPwdLoading = ref(false)
let payPwdResolve = null
// 课程详情数据
const course = ref({
id: '',
@@ -373,20 +393,41 @@ const formatDuration = (startStr, endStr) => {
}
// 预约处理
const handleBooking = () => {
const handleBooking = async () => {
if (!canBook.value) return
const memberId = getMemberId()
if (!memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
uni.showModal({
title: '确认预约',
content: `确定要预约「${course.value.courseName}」吗?`,
success: (res) => {
if (res.confirm) {
content: `确定要预约「${course.value.courseName}」吗?${course.value.storedValueAmount > 0 ? '\n金额: ¥' + course.value.storedValueAmount : ''}`,
success: async (res) => {
if (!res.confirm) return
// 弹出支付密码输入
const payPassword = await requestPayPassword()
if (!payPassword) return
uni.showLoading({ title: '预约中...' })
bookGroupCourse({
courseId: course.value.id,
memberId: '1', // 模拟会员ID
memberCardRecordId: '1' // 模拟会员卡记录ID
}).then((result) => {
try {
const cardRes = await getPrimaryMemberCard(memberId)
const memberCardRecordId = cardRes?.id || cardRes?.data?.id
if (!memberCardRecordId) {
uni.hideLoading()
uni.showToast({ title: '请先购买会员卡', icon: 'none' })
return
}
const result = await bookGroupCourse({
courseId: Number(course.value.id),
memberId: Number(memberId),
memberCardRecordId: Number(memberCardRecordId),
payPassword: payPassword
})
uni.hideLoading()
if (result.success) {
uni.showToast({
@@ -394,27 +435,47 @@ const handleBooking = () => {
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
uni.redirectTo({ url: '/pages/memberInfo/myCourses' })
}, 1500)
} else {
uni.showToast({
title: result.message || '预约失败',
icon: 'none'
icon: 'none',
duration: 3000
})
}
}).catch((error) => {
} catch (error) {
uni.hideLoading()
console.error('[detail.vue] 预约失败:', error)
const errMsg = error?.message || error?.data?.message || '预约失败'
uni.showToast({
title: '预约失败',
icon: 'none'
})
title: errMsg,
icon: 'none',
duration: 3000
})
}
}
})
}
// 弹出支付密码输入框,返回 Promise<string|null>
function requestPayPassword() {
return new Promise((resolve) => {
payPwdResolve = resolve
showPayPwd.value = true
})
}
function onPayPasswordConfirm(password) {
payPwdResolve?.(password)
showPayPwd.value = false
}
function onPayPasswordCancel() {
payPwdResolve?.(null)
showPayPwd.value = false
}
// 获取课程详情
const fetchCourseDetail = async (id) => {
try {
@@ -439,15 +500,13 @@ const fetchCourseDetail = async (id) => {
}
}
// 页面载时获取课程详情
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || {}
const courseId = options.id || '1'
console.log('[detail.vue] 页面挂载,课程ID:', courseId)
// 页面载时获取课程详情
onLoad((options) => {
const courseId = options.id
console.log('[detail.vue] onLoad,课程ID:', courseId)
if (courseId) {
fetchCourseDetail(courseId)
}
})
</script>
@@ -963,7 +1022,10 @@ onMounted(() => {
box-shadow: none;
}
/* 骨架屏样式 */
.booking-btn.disabled text {
color: #c8c8c8;
}
.skeleton {
padding-bottom: calc(140rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
+92 -3
View File
@@ -32,6 +32,18 @@
@change="onTimePeriodChange"
ref="timePeriodRef"
/>
<!-- 常态化团课筛选按钮 -->
<view class="recurring-filter">
<view
class="recurring-btn"
:class="{ 'recurring-btn--active': isRecurring }"
@tap="toggleRecurring"
>
<text class="recurring-icon">{{ isRecurring ? '✓' : '' }}</text>
<text class="recurring-text">常态化团课</text>
</view>
</view>
</view>
<!-- 团课列表 -->
@@ -46,6 +58,7 @@
:key="course.id"
:course="course"
:courseTypeLabels="getCourseLabels(course)"
:booked="isCourseBooked(course.id)"
@booking="handleBooking"
@detail="goDetail"
></GroupCourseCard>
@@ -74,6 +87,7 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import TabBar from '@/components/TabBar.vue'
import GroupCourseCard from '@/components/groupCourse/CourseCard.vue'
import SearchBar from '@/components/groupCourse/SearchBar.vue'
@@ -82,7 +96,8 @@ import TimePeriodSelector from '@/components/groupCourse/TimePeriodSelector.vue'
import TimeRangePicker from '@/components/groupCourse/TimeRangePicker.vue'
import PageHeader from '@/components/index/PageHeader.vue'
import { useGroupCourseList } from '@/composables/useGroupCourseList.js'
import { getTypeLabels, getGroupCourseTypes } from '@/api/groupCourse.js'
import { getTypeLabels, getGroupCourseTypes, getMemberBookings } from '@/api/groupCourse.js'
import { getMemberId } from '@/utils/request.js'
// 组件引用
const searchBarRef = ref(null)
@@ -93,6 +108,9 @@ const timeRangePickerRef = ref(null)
// 课程类型标签缓存
const courseTypeLabelsCache = ref({})
// 已预约课程ID集合
const bookedCourseIds = ref(new Set())
// 使用组合式函数
const {
// 状态
@@ -106,6 +124,7 @@ const {
sortIndex,
timePeriodOptions,
timePeriodIndex,
isRecurring,
showTimePicker,
startDate,
endDate,
@@ -126,13 +145,20 @@ const {
onScrollToLower
} = useGroupCourseList()
// 切换常态化团课筛选
const toggleRecurring = () => {
isRecurring.value = !isRecurring.value
fetchCourseList()
}
// 组件挂载时调用接口获取团课列表
onMounted(async () => {
console.log('[list.vue] 页面组件已挂载,开始获取团课列表')
// 并行获取课程类型列表课程列表
// 并行获取课程类型列表课程列表和已预约课程
await Promise.all([
fetchCourseTypes(),
fetchCourseList()
fetchCourseList(),
fetchBookedCourses()
])
// 获取所有团课的类型标签
await fetchAllCourseTypeLabels()
@@ -144,6 +170,12 @@ onMounted(async () => {
console.log(' - getAllSearchParams() 获取所有参数')
})
// 页面显示时刷新已预约状态(从其他页面返回时)
onShow(async () => {
console.log('[list.vue] onShow 刷新已预约状态')
await fetchBookedCourses()
})
const fetchCourseTypes = async () => {
try {
const result = await getGroupCourseTypes()
@@ -159,6 +191,33 @@ const fetchCourseTypes = async () => {
}
}
const fetchBookedCourses = async () => {
const memberId = getMemberId()
if (!memberId) {
console.log('[list.vue] 未登录,跳过获取已预约课程')
return
}
try {
const res = await getMemberBookings(memberId)
const bookings = Array.isArray(res) ? res : (res?.data || res?.content || [])
if (Array.isArray(bookings)) {
const ids = new Set()
bookings.forEach(b => {
// 只统计状态为正常的预约(status='0'),排除已取消(status='1'
if (b.courseId && String(b.status) === '0') ids.add(Number(b.courseId))
})
bookedCourseIds.value = ids
console.log('[list.vue] 已预约课程ID:', [...ids])
}
} catch (error) {
console.error('[list.vue] 获取已预约课程失败:', error)
}
}
const isCourseBooked = (courseId) => {
return bookedCourseIds.value.has(Number(courseId))
}
const fetchAllCourseTypeLabels = async () => {
const types = [...new Set(filteredCourseList.value.map(c => c.courseType))]
for (const type of types) {
@@ -227,4 +286,34 @@ defineExpose({
.load-more-text {
color: #1890ff;
}
/* 常态化团课筛选按钮 */
.recurring-filter {
padding-top: 8rpx;
}
.recurring-btn {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 28rpx;
border-radius: 32rpx;
border: 2rpx solid #e0e0e0;
background: #fff;
font-size: 26rpx;
color: #666;
transition: all 0.2s;
}
.recurring-btn--active {
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
border-color: #66bb6a;
color: #2e7d32;
font-weight: 600;
}
.recurring-icon {
font-size: 22rpx;
font-weight: 700;
}
</style>
+11 -103
View File
@@ -1,14 +1,10 @@
<template>
<!-- 固定白色块滚动时显示 -->
<view class="hand" :style="{height : handHeight + 50 + 'rpx',lineHeight: handHeight + 70 + 'rpx'}" v-show="isShow">活氧舱</view>
<!-- 页面头部 -->
<PageHeader title="活氧舱" subtitle="科学训练 · 遇见更好的自己" />
<!-- 滚动内容区域 -->
<scroll-view
scroll-y
refresher-enabled
:refresher-triggered="isRefreshing"
refresher-default-style="none"
@refresherrefresh="onRefresh"
@scroll="handleScroll"
class="scroll-container"
>
@@ -18,8 +14,8 @@
<template v-else>
<BannerSwiper />
<QuickEntry />
<RecommendCourses />
<TodayRecommend />
<RecommendCourses ref="recommendCoursesRef" />
<TodayRecommend ref="todayRecommendRef" />
<view class="bottom-placeholder"></view>
</template>
</view>
@@ -40,60 +36,19 @@ import RecommendCourses from '@/components/index/RecommendCourses.vue'
import TodayRecommend from '@/components/index/TodayRecommend.vue'
import TabBar from '@/components/TabBar.vue'
import HomeSkeleton from '@/components/Skeleton/HomeSkeleton.vue'
import PageHeader from '@/components/index/PageHeader.vue'
const loading = ref(true)
const isShow = ref(false)
const handHeight = ref(0)
const scrollDistance = ref(0)
const isRefreshing = ref(false)
const recommendCoursesRef = ref(null)
const todayRecommendRef = ref(null)
// 获取可视窗口高度
const windowHeight = ref(0)
// 获取整个滚动内容的高度
const scrollContentHeight = ref(0)
// 滚动距离
const scrollDistance = ref(0)
// 滚动监听
const handleScroll = (e) => {
const distance = e.detail.scrollTop
scrollDistance.value = distance
console.log(`滚动距离: ${distance}`)
// 当滚动超过一定距离时显示白色块(200px = 400rpx
const SHOW_THRESHOLD = 50 // 滚动超过200px时显示白条
isShow.value = distance > SHOW_THRESHOLD
}
// 下拉刷新处理
const onRefresh = async () => {
console.log('开始下拉刷新')
isRefreshing.value = true
try {
await refreshData()
isRefreshing.value = false
uni.showToast({
title: '刷新成功',
icon: 'success'
})
} catch (error) {
console.error('刷新失败', error)
isRefreshing.value = false
uni.showToast({
title: '刷新失败',
icon: 'error'
})
}
}
// 刷新数据
const refreshData = () => {
return new Promise((resolve) => {
setTimeout(() => {
console.log('数据已刷新')
resolve()
}, 1500)
})
scrollDistance.value = e.detail.scrollTop
console.log(`滚动距离: ${scrollDistance.value}`)
}
onShow(() => {
@@ -114,41 +69,6 @@ onMounted(() => {
setTimeout(() => {
loading.value = false
}, 1500)
// 获取系统信息
uni.getSystemInfo({
success: (res) => {
windowHeight.value = res.windowHeight
console.log('可视窗口高度:', windowHeight.value)
console.log('平台:', res.platform)
// #ifdef MP-WEIXIN
// 微信小程序使用胶囊按钮高度(保持原有逻辑)
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
const navTotalHeight = menuButtonInfo.top + menuButtonInfo.height
handHeight.value = navTotalHeight * 2.5
console.log('微信小程序胶囊按钮高度:', handHeight.value)
// #endif
// #ifndef MP-WEIXIN
// H5和安卓App只显示状态栏高度
const statusBarHeight = res.statusBarHeight || 0
handHeight.value = statusBarHeight * 2 // 转换为rpx1px = 2rpx
console.log('非微信小程序状态栏高度:', handHeight.value, 'rpx')
// #endif
}
})
// 延迟获取滚动内容高度(确保DOM已渲染)
setTimeout(() => {
const query = uni.createSelectorQuery().in(this)
query.select('.home-page').boundingClientRect(data => {
if (data) {
scrollContentHeight.value = data.height
console.log('内容总高度:', scrollContentHeight.value)
}
}).exec()
}, 500)
})
</script>
@@ -167,18 +87,6 @@ onMounted(() => {
padding-bottom: 160rpx;
}
/* 固定白色块 */
.hand {
position: fixed;
top: 0;
left: 0;
z-index: 100;
background-color: white;
width: 100%;
text-align: center;
font-size: 34rpx;
}
/* 固定 TabBar */
.tabbar-fixed {
position: fixed;
+108 -75
View File
@@ -1,7 +1,7 @@
<template>
<view class="scroll-container theme-light">
<view class="booking-page">
<MemberInfoSubNav title="我的预约" @back="goBack" />
<PageHeader title="我的预约" subtitle="" :show-back="true" />
<view class="booking-page__tabs">
<view
@@ -11,7 +11,7 @@
:class="{ 'booking-page__tab--active': activeTab === tab.key }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="setActiveTab(tab.key)"
@tap="activeTab = tab.key"
>
<text
class="booking-page__tab-text"
@@ -26,31 +26,15 @@
</view>
</view>
<view class="bt-page__action-bar bt-page__action-bar--end">
<text
class="bt-page__action-link bt-page__action-link--primary"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="goCourseList"
>
预约课程
<view class="booking-page__body">
<!-- 无数据 -->
<view v-if="!displayedBookings.length && !loading" class="booking-page__empty">
<text class="booking-page__empty-text">
{{ activeTab === 'ongoing' ? '暂无进行中的预约' : '暂无历史预约' }}
</text>
</view>
<view class="booking-page__body">
<view
v-if="activeTab === 'ongoing' && upcomingAlert"
class="booking-page__alert"
>
<image
class="booking-page__alert-icon"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/clock1.png"
mode="aspectFit"
/>
<text class="booking-page__alert-text">{{ upcomingAlert }}</text>
</view>
<view
<view v-else
v-for="item in displayedBookings"
:key="item.id"
class="bk-card"
@@ -59,7 +43,7 @@
>
<image
class="bk-card__banner"
:src="item.banner"
:src="item.coverImage || item.banner || 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/default-course.png'"
mode="aspectFill"
/>
<view class="bk-card__content">
@@ -97,7 +81,7 @@
</view>
</view>
<view class="bk-card__footer">
<view class="bk-card__footer" v-if="item.footerText">
<text class="bk-card__footer-info">{{ item.footerText }}</text>
<view class="bk-card__actions">
<view
@@ -122,33 +106,25 @@
</view>
</view>
</view>
<view v-if="!displayedBookings.length" class="booking-page__empty">
<text class="booking-page__empty-text">
{{ activeTab === 'ongoing' ? '暂无进行中的预约' : '暂无历史预约' }}
</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, backToMemberCenter } from '@/common/constants/routes.js'
import {
cancelOngoingBooking,
formatUpcomingAlert
} from '@/common/memberInfo/store.js'
import { ref, computed, onMounted } from 'vue'
import PageHeader from '@/components/index/PageHeader.vue'
import { getMemberId } from '@/utils/request.js'
import { canCancelBooking, canSigninCourse } from '@/common/memberInfo/bookingStore.js'
import { signinGroupCourse, cancelBooking as cancelBookingApi } from '@/api/main.js'
import { getMemberBookings, signinGroupCourse, cancelBooking as cancelBookingApi } from '@/api/main.js'
const tabs = ref([])
const tabs = ref([
{ key: 'ongoing', label: '进行中' },
{ key: 'history', label: '历史记录' }
])
const activeTab = ref('ongoing')
const ongoingBookings = ref([])
const historyBookings = ref([])
const activeTab = ref('ongoing')
const loading = ref(false)
const displayedBookings = computed(() => {
return activeTab.value === 'ongoing'
@@ -156,30 +132,97 @@ const displayedBookings = computed(() => {
: historyBookings.value
})
const upcomingAlert = computed(() => {
return formatUpcomingAlert(ongoingBookings.value[0])
})
async function fetchBookings() {
if (loading.value) return
loading.value = true
console.log('[booking] 开始获取预约记录')
function refreshFromStore() {
const store = loadMemberStore()
ongoingBookings.value = store.ongoingBookings.map((item) => ({
...item,
canCancel: canCancelBooking(item),
canSignin: canSigninCourse(item)
}))
historyBookings.value = store.historyBookings.map((item) => ({ ...item }))
const memberId = getMemberId()
if (!memberId) {
console.warn('[booking] 未登录,无法获取预约记录')
loading.value = false
return
}
try {
const res = await getMemberBookings(memberId)
console.log('[booking] getMemberBookings 返回:', JSON.stringify(res, null, 2))
const bookings = res?.data || res?.content || []
if (Array.isArray(bookings) && bookings.length > 0) {
const mapped = bookings.map(mapBooking)
ongoingBookings.value = mapped.filter((b) => b.status === 'ongoing')
historyBookings.value = mapped.filter((b) => b.status !== 'ongoing')
} else {
ongoingBookings.value = []
historyBookings.value = []
}
} catch (e) {
console.error('[booking] 获取预约记录失败:', e)
ongoingBookings.value = []
historyBookings.value = []
} finally {
loading.value = false
}
}
function goBack() {
backToMemberCenter()
function mapBooking(b) {
const status = getBookingStatus(b)
return {
id: b.id,
courseId: b.courseId,
title: b.courseName || b.courseTitle || '预约课程',
status: status,
statusLabel: getBookingStatusLabel(b),
schedule: formatBookingTime(b.courseStartTime || b.startTime || b.bookingTime),
banner: b.coverImage || '',
coach: b.coachName || b.coach || '教练',
footerText: status === 'ongoing' ? '请准时参加课程' : '',
canSignin: status === 'ongoing',
canCancel: status === 'ongoing'
}
}
function goCourseList() {
navigateToPage(PAGE.COURSE_LIST)
function getBookingStatus(b) {
if (b.status) {
const s = String(b.status).toLowerCase()
if (s === 'cancelled' || s === 'canceled') return 'cancelled'
if (s === 'completed' || s === 'finished') return 'completed'
}
const startTime = b.courseStartTime || b.startTime
if (startTime) {
const now = new Date()
const start = new Date(startTime)
if (now > start) return 'completed'
}
return 'ongoing'
}
function setActiveTab(tab) {
activeTab.value = tab
function getBookingStatusLabel(b) {
if (b.status) {
const s = String(b.status).toLowerCase()
if (s === 'cancelled' || s === 'canceled') return '已取消'
if (s === 'completed' || s === 'finished') return '已完成'
if (s === 'ongoing' || s === 'confirmed') return '进行中'
}
const status = getBookingStatus(b)
if (status === 'cancelled') return '已取消'
if (status === 'completed') return '已完成'
return '进行中'
}
function formatBookingTime(timeStr) {
if (!timeStr) return ''
try {
const d = new Date(timeStr)
const month = d.getMonth() + 1
const day = d.getDate()
const hour = d.getHours().toString().padStart(2, '0')
const minute = d.getMinutes().toString().padStart(2, '0')
return `${month}${day}${hour}:${minute}`
} catch {
return timeStr
}
}
// 签到团课
@@ -203,8 +246,7 @@ async function signinCourse(item) {
if (result.code === 200 || result.success) {
uni.showToast({ title: '签到成功', icon: 'success' })
// 更新本地状态
refreshFromStore()
fetchBookings()
} else {
uni.showToast({ title: result.message || '签到失败', icon: 'none' })
}
@@ -219,10 +261,6 @@ async function signinCourse(item) {
// 取消预约
async function cancelBooking(item) {
if (!canCancelBooking(item)) {
uni.showToast({ title: '距开课不足2小时,无法取消', icon: 'none' })
return
}
uni.showModal({
title: '取消预约',
content: `确定要取消「${item.title}」吗?`,
@@ -231,18 +269,12 @@ async function cancelBooking(item) {
uni.showLoading({ title: '取消中...', mask: true })
try {
// 调用后端API取消预约
const result = await cancelBookingApi(Number(item.id))
uni.hideLoading()
if (result.code === 200 || result.success) {
// 更新本地状态
const store = loadMemberStore()
const localResult = cancelOngoingBooking(store, item.id)
if (localResult.ok) {
refreshFromStore()
}
uni.showToast({ title: '已取消', icon: 'success' })
fetchBookings()
} else {
uni.showToast({ title: result.message || '取消失败', icon: 'none' })
}
@@ -255,7 +287,9 @@ async function cancelBooking(item) {
})
}
refreshFromStore()
onMounted(() => {
fetchBookings()
})
</script>
<style lang="scss">
@@ -263,7 +297,6 @@ refreshFromStore()
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/booking-page.css';
</style>
@@ -1,7 +1,7 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="签到记录" @back="goBack" />
<PageHeader title="签到记录" subtitle="" :show-back="true" />
<view class="mi-mod-tabs">
<view
v-for="tab in tabs"
@@ -17,7 +17,7 @@
</view>
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title"> {{ filteredList.length }} 条记录</text>
<text class="bt-card__title" v-if="filteredList.length > 0"> {{ filteredList.length }} 条记录</text>
<view
v-for="item in filteredList"
:key="item.id"
@@ -37,7 +37,7 @@
<text class="mi-mod-checkin-row__tag-text">{{ item.tag }}</text>
</view>
</view>
<view v-if="!filteredList.length" class="bt-empty">
<view v-if="filteredList.length === 0 && !loading" class="bt-empty">
<text class="bt-empty__text">暂无签到记录</text>
</view>
</view>
@@ -47,44 +47,89 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { getCheckInHistory } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { backToMemberCenter } from '@/common/constants/routes.js'
import { ref, computed, onMounted } from 'vue'
import PageHeader from '@/components/index/PageHeader.vue'
import { getCheckInRecords } from '@/api/main.js'
const tabs = ref([
{ key: 'all', label: '全部' },
{ key: 'signin', label: '签到' },
{ key: 'trial', label: '体验' },
{ key: 'event', label: '活动' }
{ key: 'signin', label: '签到' }
])
const activeFilter = ref('all')
const list = ref([])
const loading = ref(false)
const filteredList = computed(() => {
if (activeFilter.value === 'all') return list.value
// "签到" tab: 筛选普通签到记录(非团课/私教类)
if (activeFilter.value === 'signin') {
return list.value.filter((i) => i.tagTheme === 'default')
}
return list.value.filter((i) => i.tagTheme === activeFilter.value)
})
function refresh() {
const store = loadMemberStore()
list.value = getCheckInHistory(store, 'all')
async function fetchCheckInHistory() {
if (loading.value) return
loading.value = true
console.log('[checkInHistory] 开始获取签到记录')
try {
const res = await getCheckInRecords({ page: 0, size: 50 })
console.log('[checkInHistory] getCheckInRecords 返回:', JSON.stringify(res, null, 2))
const records = res?.data?.content || res?.content || []
if (Array.isArray(records) && records.length > 0) {
list.value = records.map((record) => ({
id: record.id,
title: record.courseName || record.name || '签到',
time: formatCheckInTime(record.checkInTime || record.createTime),
location: record.location || record.address || '',
tag: record.courseType || record.type || '签到',
tagTheme: getTagTheme(record.courseType || record.type)
}))
} else {
list.value = []
}
} catch (e) {
console.error('[checkInHistory] 获取签到记录失败:', e)
list.value = []
} finally {
loading.value = false
}
}
function goBack() {
backToMemberCenter()
function formatCheckInTime(timeStr) {
if (!timeStr) return ''
try {
const d = new Date(timeStr)
const month = d.getMonth() + 1
const day = d.getDate()
const hour = d.getHours().toString().padStart(2, '0')
const minute = d.getMinutes().toString().padStart(2, '0')
return `${month}${day}${hour}:${minute}`
} catch {
return timeStr
}
}
function getTagTheme(type) {
if (!type) return 'default'
const t = type.toLowerCase()
if (t.includes('团课') || t.includes('group')) return 'primary'
if (t.includes('私教') || t.includes('personal')) return 'accent'
if (t.includes('线上') || t.includes('online')) return 'info'
return 'default'
}
function showDetail(item) {
uni.showModal({
title: item.title,
content: `${item.time}\n地点:${item.location}\n类型:${item.tag}`,
content: `${item.time}\n${item.location ? '地点:' + item.location + '\n' : ''}类型:${item.tag}`,
showCancel: false
})
}
refresh()
onMounted(() => {
fetchCheckInHistory()
})
</script>
<style lang="scss">
@@ -92,7 +137,6 @@ refresh()
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
File diff suppressed because it is too large Load Diff
+278 -175
View File
@@ -1,7 +1,13 @@
<template>
<view class="scroll-container theme-light">
<view class="member-card-page">
<MemberInfoSubNav title="我的会员卡" @back="goBack" />
<!-- 固定页面头部 -->
<view class="member-card-page-header">
<PageHeader title="我的会员卡" subtitle="" :show-back="true" />
</view>
<!-- 滚动内容区域 -->
<view class="member-card-scroll-wrap">
<scroll-view scroll-y class="member-card-scroll">
<view class="member-card-page__body">
<!-- 卡类型切换 -->
<view class="mc-type-tabs">
@@ -10,7 +16,6 @@
:class="{ 'mc-type-tab--active': currentCardType === 'stored' }"
@tap="switchCardType('stored')"
>
<image class="mc-type-tab__icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/wallet.png" mode="aspectFit" />
<text>储值卡</text>
</view>
<view
@@ -18,7 +23,6 @@
:class="{ 'mc-type-tab--active': currentCardType === 'other' }"
@tap="switchCardType('other')"
>
<image class="mc-type-tab__icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/ticket.png" mode="aspectFit" />
<text>非储值卡</text>
<text class="mc-type-tab__count" v-if="otherCards.length > 0">{{ otherCards.length }}</text>
</view>
@@ -31,11 +35,7 @@
<view class="mc-stored-hero__inner">
<view class="mc-stored-hero__header">
<view class="mc-stored-hero__title-row">
<image
class="mc-stored-hero__crown"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/crown.png"
mode="aspectFit"
/>
<uni-icons type="star-filled" size="40rpx" color="#FFFFFF" />
<text class="mc-stored-hero__name">{{ displayStoredCard.name }}</text>
</view>
<view class="mc-stored-hero__badge">
@@ -50,19 +50,7 @@
</view>
</view>
<view class="mc-stored-hero__footer">
<view
class="mc-stored-hero__recharge"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="goRecharge"
>
<image
class="mc-stored-hero__recharge-icon"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/plus.png"
mode="aspectFit"
/>
<text>充值</text>
</view>
<view class="mc-stored-hero__recharge" hover-class="mi-tap-btn--hover" :hover-stay-time="150" @tap="goRecharge">充值</view>
</view>
</view>
</view>
@@ -348,18 +336,8 @@
</scroll-view>
</view>
<view class="mc-rules">
<text class="mc-rules__title">使用规则</text>
<view
v-for="(rule, index) in rules"
:key="index"
class="mc-rules__item"
>
<view class="mc-rules__bullet"></view>
<text class="mc-rules__text">{{ rule }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</template>
@@ -367,14 +345,14 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { onShow, onLoad } from '@dcloudio/uni-app'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import PageHeader from '@/components/index/PageHeader.vue'
import {
loadMemberStore,
computeRemainingDays,
renewMemberCard,
saveMemberStore
} from '@/common/memberInfo/store.js'
import { backToMemberCenter, PAGE, navigateToPage } from '@/common/constants/routes.js'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { getMyMemberCards, getMyMemberCardsWithStatus, getMemberCardTransactions, getMemberCardTransactionsByRecordId, checkPayPasswordSet, createPayment, getStoredCardInfo, getStoredCardRechargeRecords } from '@/api/main.js'
import { getMemberId } from '@/utils/request.js'
@@ -394,7 +372,6 @@ const recordTabs = ref([
{ key: 'CHECK_IN', label: '到店健身' }
])
const records = ref([])
const rules = ref([])
const activeFilter = ref('all')
const activeSection = ref('records')
const transactions = ref([])
@@ -866,10 +843,6 @@ async function refreshFromServer() {
}
}
function goBack() {
backToMemberCenter()
}
function switchFilter(filter) {
activeFilter.value = filter
}
@@ -1055,21 +1028,50 @@ onShow(() => {
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/member-card-page.css';
//
// ================== ==================
.member-card-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: #F5F7FA;
}
.member-card-page-header {
flex-shrink: 0;
position: relative;
z-index: 10;
background: #F5F7FA;
}
.member-card-scroll-wrap {
flex: 1;
overflow: hidden;
}
.member-card-scroll {
height: 100%;
overflow-y: auto;
}
.member-card-page__body {
padding: 24rpx 32rpx;
}
// ================== ==================
.mc-type-tabs {
display: flex;
margin: 0 16px 16px;
margin-bottom: 24rpx;
background: #FFFFFF;
border-radius: 12px;
padding: 8px;
gap: 8px;
border-radius: 16rpx;
padding: 8rpx;
gap: 8rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.mc-type-tab {
@@ -1077,51 +1079,43 @@ onShow(() => {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 0;
border-radius: 8px;
font-size: 14px;
gap: 8rpx;
padding: 20rpx 0;
border-radius: 12rpx;
font-size: 28rpx;
color: #718096;
background: #F7FAFC;
transition: all 0.2s;
&--active {
background: #1677FF;
color: #FFFFFF;
font-weight: 600;
}
}
.mc-type-tab__icon {
width: 18px;
height: 18px;
.mc-type-tab--active {
background: linear-gradient(135deg, #1677FF, #4096FF);
color: #FFFFFF;
font-weight: 600;
}
.mc-type-tab__count {
font-size: 12px;
padding: 2px 6px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.1);
font-size: 22rpx;
padding: 4rpx 12rpx;
border-radius: 20rpx;
background: rgba(0, 0, 0, 0.08);
}
.mc-type-tab--active .mc-type-tab__count {
background: rgba(255, 255, 255, 0.3);
}
//
// ================== ==================
.mc-stored-section {
margin-bottom: 20px;
}
.mc-stored-hero {
margin: 0 16px;
margin-bottom: 24rpx;
}
.mc-stored-hero__inner {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 20px;
border-radius: 20rpx;
padding: 32rpx;
color: #FFFFFF;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.25);
}
.mc-stored-hero__header {
@@ -1137,11 +1131,6 @@ onShow(() => {
gap: 8px;
}
.mc-stored-hero__crown {
width: 24px;
height: 24px;
}
.mc-stored-hero__name {
font-size: 18px;
font-weight: 600;
@@ -1182,7 +1171,7 @@ onShow(() => {
.mc-stored-hero__footer {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
}
.mc-stored-hero__validity {
@@ -1201,20 +1190,14 @@ onShow(() => {
}
.mc-stored-hero__recharge {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border-radius: 20px;
padding: 16rpx 36rpx;
border-radius: 32rpx;
background: #FFFFFF;
color: #667eea;
font-size: 14px;
font-size: 28rpx;
font-weight: 600;
}
.mc-stored-hero__recharge-icon {
width: 16px;
height: 16px;
color: #667eea;
text-align: center;
line-height: 1;
}
//
@@ -1223,10 +1206,10 @@ onShow(() => {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 16px;
margin: 0 16px;
padding: 64rpx 32rpx;
background: #FFFFFF;
border-radius: 16px;
border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.mc-stored-empty__icon {
@@ -1305,11 +1288,12 @@ onShow(() => {
//
.mc-filter-tabs {
display: flex;
margin: 0 16px 16px;
margin-bottom: 24rpx;
background: #FFFFFF;
border-radius: 12px;
padding: 8px;
gap: 8px;
border-radius: 16rpx;
padding: 8rpx;
gap: 8rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.mc-filter-tab {
@@ -1345,8 +1329,8 @@ onShow(() => {
//
.mc-swiper-cards {
margin: 0 16px 20px;
padding-bottom: 16px;
margin-bottom: 24rpx;
padding-bottom: 16rpx;
}
.mc-swiper {
@@ -1584,52 +1568,56 @@ onShow(() => {
.mc-section-tabs {
display: flex;
margin: 0 16px;
margin-bottom: 24rpx;
background: #FFFFFF;
border-radius: 12px;
padding: 4px;
border-radius: 16rpx;
padding: 8rpx;
gap: 8rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.mc-section-tab {
flex: 1;
padding: 12px 0;
padding: 20rpx 0;
text-align: center;
border-radius: 8px;
font-size: 15px;
border-radius: 12rpx;
font-size: 28rpx;
color: #718096;
background: #F7FAFC;
transition: all 0.2s;
}
&--active {
background: #1677FF;
.mc-section-tab--active {
background: linear-gradient(135deg, #1677FF, #4096FF);
color: #FFFFFF;
font-weight: 600;
}
}
.mc-transactions {
margin: 0 16px;
background: #FFFFFF;
border-radius: 16px;
overflow: hidden;
border-radius: 16rpx;
padding: 24rpx 32rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
margin-bottom: 24rpx;
}
.mc-transactions__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 16px;
border-bottom: 1px solid #F0F0F0;
margin-bottom: 20rpx;
}
.mc-transactions__title {
font-size: 17px;
font-size: 28rpx;
font-weight: 600;
color: #1A202C;
color: #1E2A3A;
}
.mc-transactions__refresh {
font-size: 13px;
font-size: 24rpx;
color: #1677FF;
padding: 8rpx 16rpx;
}
.mc-transactions__scroll {
@@ -1642,91 +1630,91 @@ onShow(() => {
}
.mc-transactions__divider {
height: 1px;
height: 1rpx;
background: #F0F0F0;
margin: 0 16px;
}
.mc-transactions__item-inner {
display: flex;
align-items: center;
padding: 16px;
gap: 16px;
padding: 24rpx 0;
gap: 20rpx;
}
.mc-transactions__icon-wrap {
width: 48px;
height: 48px;
border-radius: 12px;
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&--purchase {
background: #F0FFF4;
}
&--deduct {
background: #FFF5F5;
}
&--renew {
background: #EBF8FF;
}
&--refund {
background: #FEF3E2;
}
&--expire {
background: #F7FAFC;
}
&--default {
background: #F7FAFC;
}
.mc-transactions__icon-wrap--purchase {
background: rgba(76, 175, 80, 0.1);
}
.mc-transactions__icon-wrap--deduct {
background: rgba(242, 153, 74, 0.1);
}
.mc-transactions__icon-wrap--renew {
background: rgba(33, 150, 243, 0.1);
}
.mc-transactions__icon-wrap--refund {
background: rgba(244, 67, 54, 0.1);
}
.mc-transactions__icon-wrap--expire {
background: rgba(158, 158, 158, 0.1);
}
.mc-transactions__icon-wrap--default {
background: rgba(22, 119, 255, 0.1);
}
.mc-transactions__icon-text {
font-size: 24px;
font-size: 32rpx;
}
.mc-transactions__info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.mc-transactions__item-title {
font-size: 15px;
font-weight: 500;
color: #1A202C;
font-size: 28rpx;
color: #1E2A3A;
display: block;
margin-bottom: 6rpx;
}
.mc-transactions__item-time {
font-size: 12px;
color: #A0AEC0;
font-size: 24rpx;
color: #999;
}
.mc-transactions__value {
font-size: 16px;
font-size: 28rpx;
font-weight: 600;
flex-shrink: 0;
}
&--positive {
color: #059669;
}
&--negative {
color: #DC2626;
}
.mc-transactions__value--positive {
color: #4CAF50;
}
.mc-transactions__value--negative {
color: #333;
}
.mc-transactions__empty {
padding: 64rpx 32rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 16px;
}
.mc-transactions__empty-text {
font-size: 14px;
color: #8A99B4;
font-size: 26rpx;
color: #999;
}
.mc-transactions__loading {
@@ -1736,27 +1724,142 @@ onShow(() => {
padding: 32px 16px;
}
.mc-records {
background: #FFFFFF;
border-radius: 16rpx;
padding: 24rpx 32rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
margin-bottom: 24rpx;
}
.mc-records__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.mc-records__title {
font-size: 28rpx;
font-weight: 600;
color: #1E2A3A;
}
.mc-records__tabs {
display: flex;
gap: 8rpx;
}
.mc-records__tab {
padding: 12rpx 20rpx;
border-radius: 12rpx;
font-size: 24rpx;
color: #718096;
background: #F7FAFC;
transition: all 0.2s;
}
.mc-records__tab--active {
background: #1677FF;
color: #FFFFFF;
}
.mc-records__divider {
height: 1rpx;
background: #F0F0F0;
}
.mc-records__scroll {
height: 400rpx;
overflow: hidden;
}
.mc-records__value {
font-size: 16px;
font-weight: 600;
.mc-records__item-inner {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx 0;
}
&--positive {
color: #059669;
}
&--negative {
color: #DC2626;
}
&--pending {
color: #F59E0B;
}
.mc-records__icon-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mc-records__icon-wrap--orange {
background: rgba(242, 153, 74, 0.1);
}
.mc-records__icon-wrap--green {
background: rgba(76, 175, 80, 0.1);
}
.mc-records__icon-wrap--purple {
background: rgba(156, 39, 176, 0.1);
}
.mc-records__icon-wrap--default {
background: rgba(22, 119, 255, 0.1);
}
.mc-records__icon {
width: 36rpx;
height: 36rpx;
}
.mc-records__icon-text {
font-size: 24px;
font-size: 32rpx;
}
.mc-records__info {
flex: 1;
min-width: 0;
}
.mc-records__item-title {
font-size: 28rpx;
color: #1E2A3A;
display: block;
margin-bottom: 6rpx;
}
.mc-records__item-time {
font-size: 24rpx;
color: #999;
}
.mc-records__value {
font-size: 28rpx;
font-weight: 600;
flex-shrink: 0;
}
.mc-records__value--positive {
color: #4CAF50;
}
.mc-records__value--negative {
color: #333;
}
.mc-records__value--pending {
color: #F2994A;
}
.mc-records__empty {
padding: 64rpx 32rpx;
display: flex;
align-items: center;
justify-content: center;
}
.mc-records__empty-text {
font-size: 26rpx;
color: #999;
}
</style>
@@ -3,85 +3,43 @@
<view class="member-page">
<!-- Header: 用户信息区 -->
<view class="profile-header">
<view class="profile-header__toolbar" :style="toolbarStyle">
<view class="profile-header__nav">
<view class="profile-header__nav-left">
<image
class="profile-header__icon-bell"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/bell.png"
mode="aspectFit"
/>
<image
class="profile-header__icon-settings"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/settings.png"
mode="aspectFit"
/>
<!-- 使用公共页面头部背景色 #F5F7FA -->
<view class="member-center-page-header">
<PageHeader title="个人中心" subtitle="" />
</view>
<text class="profile-header__title">个人中心</text>
<view class="profile-header__nav-right" :style="navRightStyle"></view>
</view>
</view>
<view class="profile-header__toolbar-spacer" :style="toolbarSpacerStyle"></view>
<view class="profile-header__hero">
<view class="profile-header__inner">
<view class="profile-header__user" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="goUserInfo">
<view class="profile-header__user-inner">
<view class="profile-header__avatar-wrap">
<view class="profile-header__avatar-ring">
<view class="profile-card" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="goUserInfo">
<view class="profile-card__main">
<view class="profile-avatar">
<image
class="profile-header__avatar"
class="profile-avatar__img"
:key="userInfo.avatar"
:src="displayAvatar"
mode="aspectFill"
/>
</view>
<view class="profile-header__avatar-badge">
<image
class="profile-header__avatar-badge-icon"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/camera.png"
mode="aspectFit"
/>
<view class="profile-info">
<view class="profile-info__row">
<text class="profile-info__name">{{ userInfo.name }}</text>
<view class="profile-info__badge">
<image class="profile-info__badge-icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/crown0.png" mode="aspectFit" />
<text class="profile-info__level">{{ userInfo.memberLevel }}</text>
</view>
</view>
<view class="profile-header__user-meta">
<view class="profile-header__user-meta-inner">
<text class="profile-header__name">{{ userInfo.name }}</text>
<text class="profile-header__phone">{{ userInfo.phone }}</text>
<view class="profile-header__badge">
<image
class="profile-header__badge-icon"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/crown0.png"
mode="aspectFit"
/>
<text class="profile-header__level">{{ userInfo.memberLevel }}</text>
<text class="profile-info__phone">{{ userInfo.phone }}</text>
</view>
</view>
<view class="profile-card__stats">
<view class="profile-stat">
<text class="profile-stat__value">{{ stats.checkInCount ?? 0 }}</text>
<text class="profile-stat__label">累计签到</text>
</view>
<view class="profile-stat">
<text class="profile-stat__value">{{ stats.courseCount ?? 0 }}</text>
<text class="profile-stat__label">报课次数</text>
</view>
</view>
<view class="profile-header__stats">
<view class="profile-header__stats-inner">
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.checkInCount }}</text>
<text class="profile-header__stat-label">累计签到</text>
</view>
</view>
<view class="profile-header__stat-divider"></view>
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.trainingHours }}</text>
<text class="profile-header__stat-label">训练时长</text>
</view>
</view>
<view class="profile-header__stat-divider"></view>
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.pointsBalance }}</text>
<text class="profile-header__stat-label">累计积分</text>
</view>
</view>
</view>
<view class="profile-stat">
<text class="profile-stat__value">{{ stats.pointsBalance ?? 0 }}</text>
<text class="profile-stat__label">累计积分</text>
</view>
</view>
</view>
@@ -233,88 +191,6 @@
</view>
</view>
<!-- 预约课程区域 -->
<view class="booking-section">
<view class="booking-section__inner">
<view class="booking-section__header">
<view class="booking-section__header-inner">
<text class="booking-section__title">我的预约</text>
<view class="booking-section__link" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="goBooking">
<text class="booking-section__view-all">预约记录</text>
<image class="booking-section__link-arrow" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright4.png" mode="aspectFit" />
</view>
</view>
</view>
<view v-for="item in bookingPreview" :key="item.id" class="booking-section__item" hover-class="mi-tap-row--hover" :hover-stay-time="150" @tap="goBooking">
<view class="booking-section__item-inner">
<view class="booking-section__date">
<view class="booking-section__date-inner">
<text class="booking-section__num">{{ item.dateDay }}</text>
<text class="booking-section__date-sub">{{ item.dateMonth }}</text>
</view>
</view>
<view class="booking-section__content">
<view class="booking-section__content-inner">
<text class="booking-section__desc">{{ item.desc }}</text>
<view class="booking-section__meta">
<view class="booking-section__meta-inner">
<image class="booking-section__icon-coach" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user2.png" mode="aspectFit" />
<text class="booking-section__coach">教练{{ item.coach }}</text>
<image class="booking-section__icon-location" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/mappin1.png" mode="aspectFit" />
<text class="booking-section__text">{{ item.location }}</text>
</view>
</view>
</view>
</view>
<view class="booking-section__status-wrap">
<view class="booking-section__status-badge" :class="'booking-section__status-badge--' + item.status">
<text class="booking-section__status-text" :class="{ 'booking-section__status-text--pending': item.status === 'pending' }">{{ item.statusLabel }}</text>
</view>
</view>
</view>
</view>
<view v-if="!bookingPreview.length" class="booking-section__empty">
<text class="booking-section__empty-text">暂无进行中的预约</text>
</view>
</view>
</view>
<!-- 签到记录区域 -->
<view class="checkin-section">
<view class="checkin-section__inner">
<view class="checkin-section__header">
<view class="checkin-section__header-inner">
<text class="checkin-section__title">签到记录</text>
<view class="checkin-section__link" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="goCheckInHistory">
<text class="checkin-section__view-all">查看全部</text>
<image class="checkin-section__link-arrow" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright2.png" mode="aspectFit" />
</view>
</view>
</view>
<view class="checkin-section__list">
<view class="checkin-section__list-inner">
<view v-for="(item, index) in checkIns" :key="item.id" class="checkin-section__row">
<view v-if="index > 0" class="checkin-section__divider"></view>
<view class="checkin-section__item" hover-class="mi-tap-row--hover" :hover-stay-time="150" @tap="onCheckInTap(item)">
<view class="checkin-section__item-inner">
<view class="checkin-section__dot" :class="'checkin-section__dot--' + item.tagTheme"></view>
<view class="checkin-section__content">
<view class="checkin-section__content-inner">
<text class="checkin-section__desc">{{ item.title }}</text>
<text class="checkin-section__text">{{ item.time }}</text>
</view>
</view>
<view class="checkin-section__tag-badge" :class="'checkin-section__tag-badge--' + item.tagTheme">
<text class="checkin-section__tag-text" :class="'checkin-section__tag-text--' + item.tagTheme">{{ item.tag }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 体测报告区域 -->
<view class="body-report-section">
<view class="body-report-section__inner">
@@ -514,27 +390,22 @@
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { getCenterPageData, renewMemberCard, computeRemainingDays, saveMemberStore } from '@/common/memberInfo/store.js'
import { getCenterPageData, renewMemberCard, computeRemainingDays, saveMemberStore, loadMemberStore } from '@/common/memberInfo/store.js'
import { getMemberId } from '@/utils/request.js'
import { getMyMemberCards } from '@/api/main.js'
import { getMyMemberCards, getCheckInStats, getMemberDetail } from '@/api/main.js'
import { getMemberBookings } from '@/api/groupCourse.js'
import TabBar from '@/components/TabBar.vue'
import PageHeader from '@/components/index/PageHeader.vue'
//
const userInfo = ref({})
const stats = ref({})
const cardInfo = ref({})
const expiringCards = ref([])
const bookingPreview = ref([])
const checkIns = ref([])
const bodyReport = ref({})
const couponPoints = ref({})
const referral = ref({})
//
const toolbarStyle = ref({})
const toolbarSpacerStyle = ref({})
const navRightStyle = ref({})
//
const quickActionsRow1 = ref([
{ key: 'booking', label: '预约课程', textClass: 'quick-actions__title', icon: '' },
@@ -592,33 +463,6 @@ const displayAvatar = computed(() => {
})
//
function syncNavSafeArea() {
try {
const sys = uni.getSystemInfoSync()
const statusBarHeight = sys.statusBarHeight || 0
const navHeight = 44
const menu = uni.getMenuButtonBoundingClientRect?.()
toolbarStyle.value = {
paddingTop: `${statusBarHeight}px`
}
toolbarSpacerStyle.value = {
height: `${statusBarHeight + navHeight}px`
}
if (menu && menu.width) {
const capsuleGap = sys.windowWidth - menu.left + 8
navRightStyle.value = {
width: `${capsuleGap}px`,
minWidth: `${capsuleGap}px`
}
}
} catch (e) {
toolbarSpacerStyle.value = { height: '44px' }
}
}
function refreshFromStore() {
const store = loadMemberStore()
const pageData = getCenterPageData(store)
@@ -626,13 +470,94 @@ function refreshFromStore() {
stats.value = pageData.stats
cardInfo.value = pageData.cardInfo
expiringCards.value = pageData.expiringCards || []
bookingPreview.value = pageData.bookingPreview
checkIns.value = pageData.checkIns
bodyReport.value = pageData.bodyReport
couponPoints.value = pageData.couponPoints
referral.value = pageData.referral
}
async function refreshStatsFromServer() {
const store = loadMemberStore()
let changed = false
// =
try {
const statsRes = await getCheckInStats()
console.log('[MemberCenter] getCheckInStats 原始响应:', JSON.stringify(statsRes))
//
const statsData = statsRes?.data || statsRes || {}
const totalCount = Number(statsData.totalCount ?? statsData.total ?? 0)
console.log('[MemberCenter] 解析后 totalCount:', totalCount)
if (store.stats.checkInCount !== totalCount) {
store.stats.checkInCount = totalCount
changed = true
}
} catch (e) {
console.error('[MemberCenter] 获取签到统计失败:', e)
}
//
try {
const memberId = getMemberId()
console.log('[MemberCenter] getMemberId 结果:', memberId)
if (memberId) {
const bookingsRes = await getMemberBookings(memberId)
console.log('[MemberCenter] getMemberBookings 原始响应:', JSON.stringify(bookingsRes))
//
// 1. [{...}, {...}]
// 2. { data: [{...}] } data
// 3. { data: { content: [...] } } Spring
// 4. { content: [...] } Spring Page
let bookings = []
const raw = bookingsRes || {}
if (Array.isArray(raw)) {
bookings = raw
} else if (Array.isArray(raw.data)) {
bookings = raw.data
} else if (raw.data && Array.isArray(raw.data.content)) {
bookings = raw.data.content
} else if (Array.isArray(raw.content)) {
bookings = raw.content
} else if (raw.data && typeof raw.data === 'object' && raw.data.data && Array.isArray(raw.data.data)) {
bookings = raw.data.data
} else if (raw.data && typeof raw.data === 'object' && raw.data.data && raw.data.data.content && Array.isArray(raw.data.data.content)) {
bookings = raw.data.data.content
}
const bookingCount = bookings.length
console.log('[MemberCenter] 解析后 bookingCount:', bookingCount, 'bookings:', JSON.stringify(bookings?.slice?.(0, 2)))
if (store.stats.courseCount !== bookingCount) {
store.stats.courseCount = bookingCount
changed = true
}
} else {
console.warn('[MemberCenter] getMemberId 返回 null/undefined,无法获取报课次数')
}
} catch (e) {
console.error('[MemberCenter] 获取报课次数失败:', e)
}
//
try {
const detailRes = await getMemberDetail()
console.log('[MemberCenter] getMemberDetail 原始响应:', JSON.stringify(detailRes))
const detailData = detailRes?.data || detailRes || {}
const points = Number(detailData.pointsBalance ?? detailData.points ?? 0)
console.log('[MemberCenter] 解析后 points:', points)
if (store.stats.pointsBalance !== points) {
store.stats.pointsBalance = points
changed = true
}
} catch (e) {
console.error('[MemberCenter] 获取积分失败:', e)
}
console.log('[MemberCenter] 最终 stats:', JSON.stringify(store.stats))
if (changed) {
saveMemberStore(store)
refreshFromStore()
}
}
async function refreshCardsFromServer() {
const memberId = getMemberId()
if (!memberId) return
@@ -699,14 +624,6 @@ function goMemberCard() {
navigateToPage(PAGE.MEMBER_CARD)
}
function goBooking() {
navigateToPage(PAGE.BOOKING)
}
function goCheckInHistory() {
navigateToPage(PAGE.CHECK_IN_HISTORY)
}
function goBodyTestHistory() {
navigateToPage(PAGE.BODY_TEST_HISTORY)
}
@@ -759,14 +676,6 @@ function onQuickAction(type) {
}
}
function onCheckInTap(item) {
uni.showModal({
title: item.title,
content: item.time,
showCancel: false
})
}
function copyReferralCode() {
uni.setClipboardData({
data: referral.value.code,
@@ -819,11 +728,11 @@ function handleLogout() {
}
onMounted(() => {
syncNavSafeArea()
refreshFromStore()
})
onShow(() => {
onShow(async () => {
await refreshStatsFromServer()
refreshCardsFromServer()
})
</script>
@@ -833,17 +742,121 @@ onShow(() => {
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/member-info-page.css';
@import '@/common/style/memberInfo/member-info-header.css';
@import '@/common/style/memberInfo/member-info-member-card.css';
@import '@/common/style/memberInfo/member-info-quick-actions.css';
@import '@/common/style/memberInfo/member-info-booking-list.css';
@import '@/common/style/memberInfo/member-info-check-in-list.css';
@import '@/common/style/memberInfo/member-info-body-report.css';
@import '@/common/style/memberInfo/member-info-coupon-points.css';
@import '@/common/style/memberInfo/member-info-referral.css';
@import '@/common/style/memberInfo/member-info-settings.css';
@import '@/common/style/memberInfo/member-info-logout.css';
/* 个人中心页面头部背景 */
.member-center-page-header {
background: #F5F7FA;
}
/* 简约个人信息卡片 */
.profile-card {
background: #FFFFFF;
border-radius: 20rpx;
padding: 28rpx 28rpx 24rpx;
box-shadow: 0 4rpx 20rpx rgba(45, 74, 90, 0.06);
display: flex;
flex-direction: column;
gap: 24rpx;
margin: 0 24rpx;
}
.profile-card__main {
display: flex;
align-items: center;
gap: 20rpx;
}
.profile-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
border: 3rpx solid #E8F4F8;
}
.profile-avatar__img {
width: 100%;
height: 100%;
border-radius: 50%;
}
.profile-info {
flex: 1;
min-width: 0;
}
.profile-info__row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 6rpx;
}
.profile-info__name {
font-size: 32rpx;
font-weight: 700;
color: #1A202C;
line-height: 1.2;
}
.profile-info__badge {
display: flex;
align-items: center;
gap: 4rpx;
background: linear-gradient(135deg, #FFF7ED, #FFF1E6);
border-radius: 20rpx;
padding: 4rpx 14rpx;
}
.profile-info__badge-icon {
width: 24rpx;
height: 24rpx;
}
.profile-info__level {
font-size: 20rpx;
font-weight: 600;
color: #ED8936;
}
.profile-info__phone {
font-size: 24rpx;
color: #A0AEC0;
}
.profile-card__stats {
display: flex;
justify-content: space-around;
padding-top: 20rpx;
border-top: 1rpx solid #F0F4F8;
}
.profile-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.profile-stat__value {
font-size: 36rpx;
font-weight: 700;
color: #2D4A5A;
}
.profile-stat__label {
font-size: 22rpx;
color: #A0AEC0;
}
.tabbar-fixed {
position: fixed;
bottom: 0;
@@ -853,18 +866,6 @@ onShow(() => {
background-color: transparent;
}
.booking-section__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.booking-section__empty-text {
font-size: 14px;
color: #8A99B4;
}
.expiring-cards-section {
margin: 0 16px 12px;
background: linear-gradient(135deg, #FFF7E6 0%, #FFE4CC 100%);
+75 -108
View File
@@ -1,5 +1,10 @@
<template>
<view class="member-info-page">
<!-- 固定页面头部 -->
<view class="member-info-page-header">
<PageHeader title="个人中心" subtitle="" />
</view>
<!-- 滚动内容区域 -->
<view class="member-scroll-wrap">
<scroll-view scroll-y class="member-scroll">
@@ -15,32 +20,13 @@
<view class="member-page__sections">
<MemberInfoMemberCard
v-if="isLogin"
ref="memberCardRef"
:card-info="cardInfo"
@view-all="goMemberCard"
@renew="onRenewCard"
@purchase="goPurchaseCard"
@stored-card-tap="goRechargeStoredCard"
/>
<MemberInfoQuickActions @action="onQuickAction" />
<MemberInfoBookingList
v-if="isLogin"
:items="bookingPreview"
@view-all="goBooking"
@item-tap="goBooking"
/>
<MemberInfoCheckInList
v-if="isLogin"
:items="checkIns"
@view-all="onCheckInViewAll"
@item-tap="onCheckInTap"
/>
<MemberInfoCouponPoints
v-if="isLogin"
:data="couponPoints"
@view-all="onCouponViewAll"
@use-coupon="onUseCoupon"
@redeem-points="onRedeemPoints"
/>
<MemberInfoSettings v-if="isLogin" @setting="onSetting" />
<MemberInfoLogout v-if="isLogin" @logout="handleLogout" />
</view>
</view>
@@ -63,31 +49,27 @@ import {
getCenterPageData,
renewMemberCard,
saveMemberStore,
getDefaultStore
getDefaultStore,
loadMemberStore
} from '@/common/memberInfo/store.js'
import { getMemberId } from '@/utils/request.js'
import { login as miniappLogin, getUserInfo, getMemberDetail, getCheckInStats, updateUserInfo } from '@/api/main.js'
import { login as miniappLogin, getUserInfo, getMemberDetail, getCheckInStats, getMemberBookings, updateUserInfo } from '@/api/main.js'
import { setToken, getToken, clearToken } from '@/utils/request.js'
import MemberInfoHeader from '@/components/memberInfo/MemberInfoHeader.vue'
import MemberInfoMemberCard from '@/components/memberInfo/MemberInfoMemberCard.vue'
import MemberInfoQuickActions from '@/components/memberInfo/MemberInfoQuickActions.vue'
import MemberInfoBookingList from '@/components/memberInfo/MemberInfoBookingList.vue'
import MemberInfoCheckInList from '@/components/memberInfo/MemberInfoCheckInList.vue'
import MemberInfoCouponPoints from '@/components/memberInfo/MemberInfoCouponPoints.vue'
import MemberInfoSettings from '@/components/memberInfo/MemberInfoSettings.vue'
import MemberInfoLogout from '@/components/memberInfo/MemberInfoLogout.vue'
import TabBar from '@/components/TabBar.vue'
import PageHeader from '@/components/index/PageHeader.vue'
const userInfo = ref({})
const stats = ref({})
const cardInfo = ref({})
const bookingPreview = ref([])
const checkIns = ref([])
const couponPoints = ref({})
const loading = ref(false)
const hasActiveCard = ref(false)
const isLogin = ref(false)
const memberCardRef = ref(null)
onMounted(() => {
const token = uni.getStorageSync('token')
const loginMember = uni.getStorageSync('loginMemberInfo')
@@ -147,6 +129,50 @@ async function fetchMemberInfo() {
stats.value.checkInCount = 0
}
//
try {
const memberIdForBooking = getMemberId()
console.log('[memberInfo] getMemberId 结果:', memberIdForBooking)
if (memberIdForBooking) {
const bookingsRes = await getMemberBookings(memberIdForBooking)
console.log('[memberInfo] getMemberBookings 原始响应:', JSON.stringify(bookingsRes))
// / data / data.content / content / data.data.content
let bookings = []
const raw = bookingsRes || {}
if (Array.isArray(raw)) {
bookings = raw
} else if (Array.isArray(raw.data)) {
bookings = raw.data
} else if (raw.data && Array.isArray(raw.data.content)) {
bookings = raw.data.content
} else if (Array.isArray(raw.content)) {
bookings = raw.content
} else if (raw.data && typeof raw.data === 'object' && raw.data.data && Array.isArray(raw.data.data)) {
bookings = raw.data.data
} else if (raw.data && typeof raw.data === 'object' && raw.data.data && Array.isArray(raw.data.data.content)) {
bookings = raw.data.data.content
}
stats.value.courseCount = bookings.length
console.log('[memberInfo] 解析后 courseCount:', stats.value.courseCount)
} else {
stats.value.courseCount = 0
console.warn('[memberInfo] getMemberId 返回 null/undefined')
}
} catch (e) {
console.error('[memberInfo] 获取报课次数失败:', e)
stats.value.courseCount = 0
}
// storememberCenter使
const store = loadMemberStore()
store.stats = {
...store.stats,
checkInCount: stats.value.checkInCount ?? 0,
courseCount: stats.value.courseCount ?? 0,
pointsBalance: stats.value.pointsBalance ?? 0
}
saveMemberStore(store)
// store
refreshFromStore()
console.log('[memberInfo] fetchMemberInfo 执行完成')
@@ -158,14 +184,11 @@ async function fetchMemberInfo() {
}
function refreshFromStore() {
const store = getDefaultStore()
const store = loadMemberStore()
const pageData = getCenterPageData(store)
console.log('[memberInfo] refreshFromStore - pageData:', JSON.stringify(pageData))
stats.value = pageData.stats
cardInfo.value = pageData.cardInfo
bookingPreview.value = pageData.bookingPreview
checkIns.value = pageData.checkIns
couponPoints.value = pageData.couponPoints
console.log('[memberInfo] refreshFromStore - userInfo.value:', JSON.stringify(userInfo.value))
}
@@ -181,8 +204,8 @@ function goPurchaseCard() {
navigateToPage(PAGE.PURCHASE_CARD)
}
function goBooking() {
navigateToPage(PAGE.BOOKING)
function goRechargeStoredCard() {
navigateToPage(PAGE.RECHARGE_STORED_CARD)
}
function onRenewCard() {
@@ -196,68 +219,6 @@ function onRenewCard() {
})
}
function onQuickAction(type) {
const routes = {
coupon: PAGE.COUPONS,
points: PAGE.POINTS
}
if (routes[type]) {
navigateToPage(routes[type])
}
}
function onCheckInViewAll() {
navigateToPage(PAGE.CHECK_IN_HISTORY)
}
function onCheckInTap(item) {
uni.showModal({
title: item.title,
content: item.time,
showCancel: false
})
}
function onCouponViewAll() {
navigateToPage(PAGE.COUPONS)
}
function onUseCoupon() {
navigateToPage(PAGE.COUPONS)
}
function onRedeemPoints() {
navigateToPage(PAGE.POINTS)
}
function onSetting(key) {
const labels = {
password: '修改密码',
privacy: '隐私政策',
delete: '注销账户'
}
if (key === 'delete') {
uni.showModal({
title: '注销账户',
content: '注销后数据将无法恢复,确定继续吗?',
confirmColor: '#E74C3C'
})
return
}
if (key === 'privacy') {
uni.showModal({
title: '隐私政策',
content: '我们重视您的隐私,详细政策请前往官网查看。',
showCancel: false
})
return
}
uni.showToast({
title: `${labels[key] || '设置'}开发中`,
icon: 'none'
})
}
function handleLogout() {
uni.showModal({
title: '退出登录',
@@ -275,9 +236,6 @@ function handleLogout() {
userInfo.value = {}
stats.value = {}
cardInfo.value = {}
bookingPreview.value = []
checkIns.value = []
couponPoints.value = {}
hasActiveCard.value = false
refreshFromStore()
uni.showToast({ title: '已退出', icon: 'success' })
@@ -300,7 +258,13 @@ onShow(() => {
isLogin.value = false
}
// loading
loading.value = false
fetchMemberInfo()
//
if (memberCardRef.value && typeof memberCardRef.value.loadMemberCard === 'function') {
memberCardRef.value.loadMemberCard()
}
})
</script>
@@ -310,11 +274,6 @@ onShow(() => {
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/member-info-page.css';
@import '@/common/style/memberInfo/member-info-header.css';
@import '@/common/style/memberInfo/member-info-member-card.css';
@import '@/common/style/memberInfo/member-info-quick-actions.css';
@import '@/common/style/memberInfo/member-info-booking-list.css';
@import '@/common/style/memberInfo/member-info-check-in-list.css';
@import '@/common/style/memberInfo/member-info-coupon-points.css';
@import '@/common/style/memberInfo/member-info-settings.css';
@import '@/common/style/memberInfo/member-info-logout.css';
@@ -324,7 +283,15 @@ onShow(() => {
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--gradient-sky);
background: #F5F7FA;
}
/* 固定页面头部 */
.member-info-page-header {
flex-shrink: 0;
position: relative;
z-index: 10;
background: #F5F7FA;
}
.member-scroll-wrap {
+366 -155
View File
@@ -1,189 +1,334 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="我的课程" @back="goBack" />
<view class="mi-mod-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="bt-tab"
:class="{ 'bt-tab--active': activeTab === tab.key }"
hover-class="mi-tap-tab--hover"
@tap="activeTab = tab.key"
>
<text class="bt-tab__text">{{ tab.label }}</text>
</view>
<PageHeader title="我的课程" subtitle="" :show-back="true">
<template #right>
<view class="header-scan-btn" @click="handleScan">
<text class="header-scan-icon">📷</text>
</view>
</template>
</PageHeader>
<view class="bt-page__body">
<!-- 团课 -->
<template v-if="activeTab === 'group'">
<!-- 子标签进行中 / 已完成 -->
<view class="mi-mod-subtabs">
<text
class="mi-mod-subtab"
:class="{ 'mi-mod-subtab--on': groupSub === 'ongoing' }"
@tap="groupSub = 'ongoing'"
:class="{ 'mi-mod-subtab--on': activeTab === 'ongoing' }"
@tap="activeTab = 'ongoing'"
>进行中</text>
<text
class="mi-mod-subtab"
:class="{ 'mi-mod-subtab--on': groupSub === 'completed' }"
@tap="groupSub = 'completed'"
:class="{ 'mi-mod-subtab--on': activeTab === 'completed' }"
@tap="activeTab = 'completed'"
>已完成</text>
</view>
<!-- 预约列表 -->
<view
v-for="item in groupList"
v-for="item in displayedBookings"
:key="item.id"
class="mi-mod-course-card"
hover-class="mi-tap-card--hover"
@tap="onGroupCourse(item)"
>
<image class="mi-mod-course-card__banner" :src="item.banner" mode="aspectFill" />
<image
class="mi-mod-course-card__banner"
:src="item.coverImage || 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/default-course.png'"
mode="aspectFill"
/>
<view class="mi-mod-course-card__content">
<view class="mi-mod-course-card__header">
<text class="mi-mod-course-card__title">{{ item.title }}</text>
<text class="mi-mod-course-card__coach">{{ item.coach }}</text>
<view class="mi-mod-course-card__progress">
<view class="mi-mod-course-card__progress-bar">
<view class="mi-mod-course-card__progress-fill" :style="{ width: pct(item) + '%' }"></view>
</view>
<text class="mi-mod-course-card__progress-text">{{ item.progress }}/{{ item.total }}</text>
<text class="mi-mod-course-card__status" :class="'mi-mod-course-card__status--' + item.statusClass">{{ item.statusLabel }}</text>
</view>
<text class="mi-mod-course-card__meta">{{ item.schedule }} · {{ item.location }}</text>
<view v-if="groupSub === 'ongoing' && item.canCancel" class="mi-mod-course-card__action" @tap.stop="goBooking">
<!-- 仅已预约状态显示取消按钮 -->
<view v-if="activeTab === 'ongoing' && item.statusClass === 'booked'" class="mi-mod-course-card__cancel" @tap.stop="handleCancel(item)">
<text>取消预约</text>
</view>
<view v-if="groupSub === 'completed' && item.canEvaluate" class="mi-mod-course-card__action" @tap.stop="evaluate(item)">
<text>去评价</text>
</view>
</view>
</view>
</template>
<!-- 私教 -->
<template v-else-if="activeTab === 'private'">
<view class="bt-card">
<text class="bt-card__title">剩余课时 {{ privateData.remaining }} </text>
<view class="mi-detail-coach">
<image class="mi-detail-coach__avatar" :src="privateData.coachAvatar" mode="aspectFill" />
<view>
<text class="mi-detail-coach__name">{{ privateData.coach }}</text>
<text class="mi-detail-coach__rating">下次 {{ privateData.nextClass }}</text>
<!-- 无数据 -->
<view v-if="!displayedBookings.length" class="bt-empty">
<text class="bt-empty__text">
{{ activeTab === 'ongoing' ? '暂无进行中的预约' : '暂无历史预约' }}
</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">已约课程</text>
<view v-for="b in privateData.bookings" :key="b.id" class="mi-mod-session">
<text class="mi-mod-session__title">{{ b.title }}</text>
<text class="mi-mod-session__meta">{{ b.time }} · {{ b.location }} · {{ b.status }}</text>
</view>
</view>
</template>
<!-- 线上课 -->
<template v-else-if="activeTab === 'online'">
<view
v-for="item in onlineList"
:key="item.id"
class="mi-mod-course-card"
hover-class="mi-tap-card--hover"
@tap="goOnline(item)"
>
<image class="mi-mod-course-card__banner" :src="item.cover" mode="aspectFill" />
<view class="mi-mod-course-card__content">
<text class="mi-mod-course-card__title">{{ item.title }}</text>
<text class="mi-mod-course-card__meta">{{ item.duration }} · 进度 {{ item.progress }}%</text>
<text v-if="item.type === 'live'" class="mi-mod-course-card__next">直播 {{ item.liveTime }}</text>
</view>
</view>
</template>
<!-- 训练营 -->
<template v-else>
<view
v-for="item in packageList"
:key="item.id"
class="mi-mod-course-card"
>
<image class="mi-mod-course-card__banner" :src="item.banner" mode="aspectFill" />
<view class="mi-mod-course-card__content">
<text class="mi-mod-course-card__title">{{ item.title }}</text>
<text class="mi-mod-course-card__coach">{{ item.coach }}</text>
<view class="mi-mod-course-card__progress">
<view class="mi-mod-course-card__progress-bar">
<view class="mi-mod-course-card__progress-fill" :style="{ width: pct(item) + '%' }"></view>
</view>
<text class="mi-mod-course-card__progress-text">{{ item.progress }}/{{ item.total }} </text>
</view>
<text class="mi-mod-course-card__meta">{{ item.schedule }}</text>
</view>
</view>
</template>
<view v-if="isEmpty" class="bt-empty">
<text class="bt-empty__text">暂无课程</text>
</view>
</view>
</view>
</view>
<!-- 支付密码弹窗 -->
<PayPasswordModal
:visible="showPayPwd"
:title="payPwdTitle"
:subtitle="payPwdSubtitle"
:cancel-notice="payPwdNotice"
@confirm="onPayPasswordConfirm"
@cancel="onPayPasswordCancel"
/>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { moduleMock, getMyCoursesData } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
<script setup>
import { ref, computed, onMounted } from 'vue'
import PageHeader from '@/components/index/PageHeader.vue'
import PayPasswordModal from '@/components/global/PayPasswordModal.vue'
import { getMemberId } from '@/utils/request.js'
import { getMemberBookings, qrSignInGroupCourse } from '@/api/groupCourse.js'
import { cancelBooking as cancelBookingApi } from '@/api/groupCourse.js'
import { getConfigByKey } from '@/api/main.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
tabs: moduleMock.myCourseTabs,
activeTab: 'group',
groupSub: 'ongoing',
privateData: {},
onlineList: [],
packageList: [],
groupData: { ongoing: [], completed: [] }
}
},
computed: {
groupList() {
return this.groupData[this.groupSub] || []
},
isEmpty() {
if (this.activeTab === 'group') return !this.groupList.length
if (this.activeTab === 'private') return !this.privateData.bookings?.length
if (this.activeTab === 'online') return !this.onlineList.length
return !this.packageList.length
}
},
onShow() { this.refresh() },
methods: {
refresh() {
const store = loadMemberStore()
this.groupData = getMyCoursesData(store, 'group')
this.privateData = getMyCoursesData(store, 'private')
this.onlineList = getMyCoursesData(store, 'online').list || []
this.packageList = getMyCoursesData(store, 'package').list || []
},
pct(item) {
return item.total ? Math.min(100, Math.round((item.progress / item.total) * 100)) : 0
},
goBooking() { navigateToPage(PAGE.BOOKING) },
goOnline(item) { navigateToPage(`${PAGE.ONLINE_COURSE}?id=${item.id}`) },
evaluate(item) {
navigateToPage(`${PAGE.COURSE_EVALUATE}?title=${encodeURIComponent(item.title)}`)
},
onGroupCourse(item) {
uni.showModal({
title: item.title,
content: `${item.coach}\n${item.schedule}\n${item.location}`,
showCancel: false
})
}
const activeTab = ref('ongoing')
const ongoingBookings = ref([])
const completedBookings = ref([])
const loading = ref(false)
//
const showPayPwd = ref(false)
const payPwdTitle = ref('')
const payPwdSubtitle = ref('')
const payPwdNotice = ref('')
let payPwdResolve = null
let cancelTarget = null
//
const cancelCount = ref(0)
const freeCancelLimit = ref(3) // API
const freeCampaignInitialized = ref(false)
const freeCancelsLeft = computed(() => Math.max(0, freeCancelLimit.value - cancelCount.value))
async function fetchFreeLimit() {
try {
const res = await getConfigByKey('group_course.cancel_free_limit')
const value = res?.configValue || res?.data?.configValue || '3'
freeCancelLimit.value = parseInt(value) || 3
freeCampaignInitialized.value = true
console.log('[myCourses] 免费取消次数阈值:', freeCancelLimit.value, '已取消次数:', cancelCount.value, '剩余:', freeCancelsLeft.value)
} catch (e) {
console.warn('[myCourses] 获取免费取消次数配置失败,使用默认值3:', e)
freeCampaignInitialized.value = true
}
}
const displayedBookings = computed(() => {
return activeTab.value === 'ongoing'
? ongoingBookings.value
: completedBookings.value
})
async function fetchBookings() {
if (loading.value) return
loading.value = true
console.log('[myCourses] 开始获取预约记录')
const memberId = getMemberId()
if (!memberId) {
console.warn('[myCourses] 未登录')
loading.value = false
return
}
try {
const res = await getMemberBookings(memberId)
const bookings = Array.isArray(res) ? res : (res?.data || res?.content || [])
console.log('[myCourses] 预约记录:', bookings.length, '条', JSON.stringify(bookings))
if (Array.isArray(bookings)) {
// status='1'
cancelCount.value = bookings.filter(b => String(b.status) === '1').length
const mapped = bookings.map(mapBooking)
ongoingBookings.value = mapped.filter(b => b.status === 'ongoing')
completedBookings.value = mapped.filter(b => b.status === 'completed')
} else {
ongoingBookings.value = []
completedBookings.value = []
}
} catch (e) {
console.error('[myCourses] 获取预约记录失败:', e)
ongoingBookings.value = []
completedBookings.value = []
} finally {
loading.value = false
}
}
function mapBooking(b) {
const courseStartTime = b.courseStartTime || b.startTime || ''
const bookStatus = String(b.status || '0')
// status: 0-1-2-3-
let status = 'ongoing'
let statusLabel = '已预约'
let statusClass = 'booked'
if (bookStatus === '1') {
status = 'completed'
statusLabel = '已取消'
statusClass = 'cancelled'
} else if (bookStatus === '2') {
// 便
status = 'ongoing'
statusLabel = '已签到'
statusClass = 'attended'
} else if (bookStatus === '3') {
status = 'ongoing'
statusLabel = '缺席'
statusClass = 'absent'
} else if (bookStatus === '0') {
status = 'ongoing'
statusLabel = '已预约'
statusClass = 'booked'
}
return {
id: b.id,
courseId: b.courseId,
title: b.courseName || '预约课程',
status: status,
originalStatus: bookStatus,
statusLabel: statusLabel,
statusClass: statusClass,
schedule: formatBookingTime(courseStartTime),
location: b.location || '',
coverImage: b.coverImage || ''
}
}
function formatBookingTime(timeStr) {
if (!timeStr) return ''
try {
const d = new Date(timeStr)
const month = d.getMonth() + 1
const day = d.getDate()
const hour = d.getHours().toString().padStart(2, '0')
const minute = d.getMinutes().toString().padStart(2, '0')
return `${month}${day}${hour}:${minute}`
} catch {
return timeStr
}
}
async function handleCancel(item) {
uni.showModal({
title: '取消预约',
content: `确定要取消「${item.title}」吗?`,
success: async (res) => {
if (!res.confirm) return
//
cancelTarget = item
payPwdTitle.value = '取消验证'
payPwdSubtitle.value = item.title
//
await fetchFreeLimit()
// 退
if (freeCancelsLeft.value > 0) {
payPwdNotice.value = `您还有 ${freeCancelsLeft.value} 次免手续费退款机会(超出后将扣除10%手续费)`
} else {
payPwdNotice.value = '本次取消将扣除10%手续费'
}
console.log('[myCourses] 取消弹窗: payPwdNotice=', payPwdNotice.value, 'freeCancelsLeft=', freeCancelsLeft.value, 'cancelCount=', cancelCount.value, 'freeLimit=', freeCancelLimit.value)
const payPassword = await requestPayPassword()
if (!payPassword) return
uni.showLoading({ title: '取消中...' })
try {
const result = await cancelBookingApi(Number(item.id), {
memberId: getMemberId(),
payPassword: payPassword
})
uni.hideLoading()
if (result.success) {
uni.showToast({ title: '已取消', icon: 'success' })
fetchBookings()
} else {
uni.showToast({ title: result.message || '取消失败', icon: 'none', duration: 3000 })
}
} catch (e) {
uni.hideLoading()
console.error('[myCourses] 取消预约失败:', e)
const errMsg = e?.message || '取消失败'
uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
}
}
})
}
function requestPayPassword() {
return new Promise((resolve) => {
payPwdResolve = resolve
showPayPwd.value = true
})
}
function onPayPasswordConfirm(password) {
payPwdResolve?.(password)
showPayPwd.value = false
}
function onPayPasswordCancel() {
payPwdResolve?.(null)
showPayPwd.value = false
}
// ===== =====
function handleScan() {
uni.scanCode({
success: async (res) => {
const qrContent = res.result
console.log('[myCourses] 扫码结果:', qrContent)
// courseId
let courseId = null
try {
const parsed = JSON.parse(qrContent)
courseId = Number(parsed.courseId)
} catch {
const num = Number(qrContent)
if (!isNaN(num)) {
courseId = num
}
}
if (!courseId) {
uni.showToast({ title: '无效的签到二维码', icon: 'none' })
return
}
const memberId = getMemberId()
if (!memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
uni.showLoading({ title: '签到中...' })
try {
const result = await qrSignInGroupCourse(courseId, memberId)
uni.hideLoading()
if (result.success) {
uni.showToast({ title: '签到成功', icon: 'success' })
//
await fetchBookings()
} else {
uni.showToast({ title: result.message || '签到失败', icon: 'none', duration: 3000 })
}
} catch (e) {
uni.hideLoading()
console.error('[myCourses] 扫码签到失败:', e)
const errMsg = e?.message || e?.data?.message || '签到失败'
uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
}
},
fail: (err) => {
console.log('[myCourses] 扫码取消或失败:', err)
}
})
}
onMounted(() => {
fetchBookings()
fetchFreeLimit()
})
</script>
<style>
@@ -191,7 +336,6 @@ export default {
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@import '@/common/style/memberInfo/pages/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
@@ -213,13 +357,80 @@ export default {
font-weight: 700;
}
.mi-mod-course-card__action {
margin-top: 6px;
.mi-mod-course-card__cancel {
margin-top: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8rpx 24rpx;
border-radius: 16rpx;
background: rgba(255, 107, 53, 0.1);
border: 2rpx solid var(--accent-orange, #FF6B35);
}
.mi-mod-course-card__action text {
font-size: 12px;
font-weight: 600;
.mi-mod-course-card__cancel text {
font-size: 24rpx;
font-weight: 700;
color: var(--accent-orange, #FF6B35);
}
/* 状态标签 */
.mi-mod-course-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.mi-mod-course-card__status {
font-size: 22rpx;
font-weight: 600;
padding: 4rpx 16rpx;
border-radius: 12rpx;
flex-shrink: 0;
white-space: nowrap;
}
.mi-mod-course-card__status--booked {
background: rgba(24, 144, 255, 0.1);
color: #1890ff;
border: 2rpx solid rgba(24, 144, 255, 0.2);
}
.mi-mod-course-card__status--attended {
background: rgba(82, 196, 26, 0.1);
color: #389e0d;
border: 2rpx solid rgba(82, 196, 26, 0.2);
}
.mi-mod-course-card__status--cancelled {
background: rgba(153, 153, 153, 0.1);
color: #999;
border: 2rpx solid rgba(153, 153, 153, 0.2);
}
.mi-mod-course-card__status--absent {
background: rgba(250, 173, 20, 0.1);
color: #d48806;
border: 2rpx solid rgba(250, 173, 20, 0.2);
}
/* 头部扫码按钮 */
.header-scan-btn {
width: 64rpx;
height: 64rpx;
background: rgba(24, 144, 255, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.header-scan-btn:active {
background: rgba(24, 144, 255, 0.2);
}
.header-scan-icon {
font-size: 32rpx;
}
</style>
@@ -306,7 +306,7 @@ let wsReject = null
let qrPaymentResolve = null
let qrPaymentReject = null
const WS_BASE_URL = 'ws://192.168.43.89:8084/ws/payment'
const WS_BASE_URL = 'ws://192.168.5.15:8084/ws/payment'
const TRADE_TYPE = 'ALIPAY'
@@ -1,7 +1,7 @@
<template>
<view class="scroll-container theme-light">
<view class="Pixso-frame-2_791">
<MemberInfoSubNav title="个人信息" @back="goBack" />
<PageHeader title="个人信息" subtitle="" :show-back="true" />
<view class="Pixso-frame-2_802">
<view class="frame-content-2_802">
<view class="avatar-block">
@@ -201,7 +201,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import PageHeader from '@/components/index/PageHeader.vue'
import { getUserInfo, updateUserInfo } from '@/api/main.js'
import { previewImage, persistChosenImage } from '@/common/memberInfo/media.js'
import { maskPhone, normalizePhoneForStore } from '@/common/memberInfo/format.js'
@@ -43,6 +43,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import PageHeader from '@/components/index/PageHeader.vue'
import RecommendCourseCard from '@/components/recommendCourses/RecommendCourseCard.vue'
import { getGroupCourseRecommendList } from '@/api/groupCourse.js'
@@ -150,6 +151,11 @@ const handleCardClick = (recommend) => {
onMounted(() => {
fetchRecommendCourses()
})
onShow(() => {
console.log('[Recommend Courses Page] onShow 刷新推荐课程')
loadFromNetwork()
})
</script>
<style lang="scss" scoped>
+2 -1
View File
@@ -1,5 +1,6 @@
// const BASE_URL = 'http://localhost:8080/api'
const BASE_URL = '/api'
// const BASE_URL = 'http://192.168.5.15:8084/api'
const BASE_URL = 'https://www.gymmanage.xyz/api'
export const request = (options) => {
return new Promise((resolve, reject) => {
+2 -1
View File
@@ -1,5 +1,6 @@
// const BASE_URL = '/api'
const BASE_URL = 'http://192.168.43.89:8084/api'
// const BASE_URL = 'http://192.168.5.15:8084/api'
const BASE_URL = 'https://www.gymmanage.xyz/api'
// OSS 静态资源基础地址
export const OSS_BASE_URL = 'https://gymfuture.oss-cn-chengdu.aliyuncs.com'
+1 -1
View File
@@ -17,7 +17,7 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8084',
target: 'http://192.168.5.15:8084',
changeOrigin: true,
secure: true,
bypass: function(req, res, proxyOptions) {