修正多处问题
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,11 +99,11 @@ export function navigateToPage(url) {
|
||||
uni.showToast({ title: '页面跳转失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
success: () => {
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
},3000)
|
||||
}
|
||||
complete: () => {
|
||||
// 页面已发起跳转,隐藏 loading
|
||||
// 目标页面的 onLoad/onReady 也会调用 hideLoading 做兜底
|
||||
uni.hideLoading()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -183,9 +183,7 @@ function onTabTap(index) {
|
||||
emit('tab-change', index)
|
||||
// 设置导航标志,阻止轮询覆盖状态
|
||||
isNavigating = true
|
||||
let timer = setTimeout(() => {
|
||||
uni.showLoading({ title: '加载中...', mask: true })
|
||||
}, 50)
|
||||
uni.showLoading({ title: '加载中...', mask: true })
|
||||
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>
|
||||
@@ -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,69 +10,60 @@
|
||||
: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
|
||||
v-for="item in previewItems"
|
||||
:key="item.id"
|
||||
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__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 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, 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__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.title }}</text>
|
||||
<text class="booking-section__text">{{ item.time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="booking-section__tag-badge"
|
||||
:class="'booking-section__tag-badge--' + item.status"
|
||||
>
|
||||
<text
|
||||
class="booking-section__tag-text"
|
||||
:class="'booking-section__tag-text--' + item.status"
|
||||
>
|
||||
{{ item.statusLabel }}
|
||||
</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="!previewItems.length" class="booking-section__empty">
|
||||
<text class="booking-section__empty-text">暂无进行中的预约</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -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__empty {
|
||||
.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;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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,89 +1,50 @@
|
||||
<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">
|
||||
<image
|
||||
class="profile-header__avatar"
|
||||
: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>
|
||||
</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>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 登录状态 -->
|
||||
<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-avatar__img"
|
||||
:key="userInfo.avatar"
|
||||
:src="displayAvatar"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 未登录状态 -->
|
||||
<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-info">
|
||||
<text class="profile-info__name">小伙伴,点我登录</text>
|
||||
<text class="profile-info__phone">陪你遇见更好的自己 ~</text>
|
||||
</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>
|
||||
|
||||
</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>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -94,10 +55,9 @@ import { computed } from 'vue'
|
||||
const props = defineProps({
|
||||
userInfo: { type: Object, required: true },
|
||||
stats: { type: Object, required: true },
|
||||
isLogin: { type: Boolean }
|
||||
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="member-card-preview"
|
||||
hover-class="mi-tap-card--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>
|
||||
</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 class="card-three-row">
|
||||
<!-- 储值卡 -->
|
||||
<view
|
||||
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="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"
|
||||
hover-class="mi-tap-btn--hover"
|
||||
:hover-stay-time="150"
|
||||
@tap.stop="$emit('purchase')"
|
||||
>
|
||||
<text
|
||||
class="member-card-preview__purchase-text"
|
||||
>
|
||||
购买新卡
|
||||
</text>
|
||||
</view>
|
||||
</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')"
|
||||
>
|
||||
<text class="card-actions__btn-text">查看卡片</text>
|
||||
</view>
|
||||
<view
|
||||
class="card-actions__btn card-actions__btn--ghost"
|
||||
hover-class="mi-tap-btn--hover"
|
||||
:hover-stay-time="150"
|
||||
@tap.stop="$emit('purchase')"
|
||||
>
|
||||
<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
|
||||
}
|
||||
|
||||
// 计算有效会员卡数量(过滤储值卡)
|
||||
let activeCount = 0
|
||||
let allActiveCards = []
|
||||
if (activeCardsRes?.value) {
|
||||
allActiveCards = activeCardsRes.value
|
||||
} else if (Array.isArray(activeCardsRes)) {
|
||||
allActiveCards = activeCardsRes
|
||||
} else if (activeCardsRes?.data) {
|
||||
allActiveCards = Array.isArray(activeCardsRes.data) ? activeCardsRes.data : (activeCardsRes.data.value || [])
|
||||
}
|
||||
// 过滤储值卡
|
||||
allActiveCards = allActiveCards.filter(card => card.memberCardType !== 'STORED_VALUE_CARD')
|
||||
activeCount = allActiveCards.length
|
||||
|
||||
// 计算卡片状态
|
||||
let diffDays = 0
|
||||
let isExpiring = false
|
||||
let isExpired = false
|
||||
let isUsedUp = false
|
||||
let displayDays = 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 now = new Date()
|
||||
diffDays = Math.ceil((expireTime - now) / (1000 * 60 * 60 * 24))
|
||||
isExpiring = diffDays > 0 && diffDays <= 3
|
||||
isExpired = diffDays <= 0
|
||||
displayDays = Math.abs(diffDays)
|
||||
// 解析储值卡
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
// 解析有效卡列表(排除储值卡)
|
||||
let allActive = []
|
||||
if (activeCardsRes?.value) {
|
||||
allActive = activeCardsRes.value
|
||||
} else if (Array.isArray(activeCardsRes)) {
|
||||
allActive = activeCardsRes
|
||||
} else if (activeCardsRes?.data) {
|
||||
allActive = Array.isArray(activeCardsRes.data) ? activeCardsRes.data : (activeCardsRes.data.value || [])
|
||||
}
|
||||
allActive = allActive.filter(card => card.memberCardType !== 'STORED_VALUE_CARD')
|
||||
activeCount.value = allActive.length
|
||||
|
||||
console.log('[MemberInfoMemberCard] 更新后的 cardInfo:', JSON.stringify(cardInfo, null, 2))
|
||||
// 查找次数卡
|
||||
const countCardData = allActive.find(c => c.memberCardType === 'COUNT_CARD')
|
||||
if (countCardData) {
|
||||
countCard.hasCard = true
|
||||
countCard.remainingTimes = countCardData.remainingTimes || countCardData.remainingAmount || 0
|
||||
}
|
||||
|
||||
// 查找时长卡(优先找有效期最长的)
|
||||
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()
|
||||
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
|
||||
}
|
||||
} 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": "错误描述信息"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*文档结束*
|
||||
@@ -39,6 +39,7 @@
|
||||
{
|
||||
"path": "pages/memberInfo/memberInfo",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "我的",
|
||||
"app-plus": {
|
||||
"animationType": "fade-in",
|
||||
|
||||
@@ -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("")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,48 +393,89 @@ 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) {
|
||||
uni.showLoading({ title: '预约中...' })
|
||||
bookGroupCourse({
|
||||
courseId: course.value.id,
|
||||
memberId: '1', // 模拟会员ID
|
||||
memberCardRecordId: '1' // 模拟会员卡记录ID
|
||||
}).then((result) => {
|
||||
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: '预约中...' })
|
||||
try {
|
||||
const cardRes = await getPrimaryMemberCard(memberId)
|
||||
const memberCardRecordId = cardRes?.id || cardRes?.data?.id
|
||||
if (!memberCardRecordId) {
|
||||
uni.hideLoading()
|
||||
if (result.success) {
|
||||
uni.showToast({
|
||||
title: '预约成功',
|
||||
icon: 'success'
|
||||
})
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 1500)
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: result.message || '预约失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}).catch((error) => {
|
||||
uni.hideLoading()
|
||||
console.error('[detail.vue] 预约失败:', error)
|
||||
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({
|
||||
title: '预约失败',
|
||||
icon: 'none'
|
||||
title: '预约成功',
|
||||
icon: 'success'
|
||||
})
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: '/pages/memberInfo/myCourses' })
|
||||
}, 1500)
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: result.message || '预约失败',
|
||||
icon: 'none',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
uni.hideLoading()
|
||||
console.error('[detail.vue] 预约失败:', error)
|
||||
const errMsg = error?.message || error?.data?.message || '预约失败'
|
||||
uni.showToast({
|
||||
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)
|
||||
fetchCourseDetail(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));
|
||||
|
||||
@@ -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>
|
||||
@@ -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 // 转换为rpx(1px = 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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
预约课程
|
||||
</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 v-if="!displayedBookings.length && !loading" class="booking-page__empty">
|
||||
<text class="booking-page__empty-text">
|
||||
{{ activeTab === 'ongoing' ? '暂无进行中的预约' : '暂无历史预约' }}
|
||||
</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
@@ -1,8 +1,14 @@
|
||||
<template>
|
||||
<view class="scroll-container theme-light">
|
||||
<view class="member-card-page">
|
||||
<MemberInfoSubNav title="我的会员卡" @back="goBack" />
|
||||
<view class="member-card-page__body">
|
||||
<view class="member-card-page">
|
||||
<!-- 固定页面头部 -->
|
||||
<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">
|
||||
<view
|
||||
@@ -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;
|
||||
color: #FFFFFF;
|
||||
font-weight: 600;
|
||||
}
|
||||
.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"
|
||||
/>
|
||||
</view>
|
||||
<text class="profile-header__title">个人中心</text>
|
||||
<view class="profile-header__nav-right" :style="navRightStyle"></view>
|
||||
</view>
|
||||
<!-- 使用公共页面头部,背景色 #F5F7FA -->
|
||||
<view class="member-center-page-header">
|
||||
<PageHeader title="个人中心" subtitle="" />
|
||||
</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">
|
||||
<image
|
||||
class="profile-header__avatar"
|
||||
: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>
|
||||
</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>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<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-avatar__img"
|
||||
:key="userInfo.avatar"
|
||||
:src="displayAvatar"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</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-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>
|
||||
<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 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%);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 将获取到的数据保存到store,供其他页面(如memberCenter)使用
|
||||
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 {
|
||||
|
||||
@@ -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>
|
||||
</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'"
|
||||
>进行中</text>
|
||||
<text
|
||||
class="mi-mod-subtab"
|
||||
:class="{ 'mi-mod-subtab--on': groupSub === 'completed' }"
|
||||
@tap="groupSub = 'completed'"
|
||||
>已完成</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in groupList"
|
||||
: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" />
|
||||
<view class="mi-mod-course-card__content">
|
||||
<!-- 子标签:进行中 / 已完成 -->
|
||||
<view class="mi-mod-subtabs">
|
||||
<text
|
||||
class="mi-mod-subtab"
|
||||
:class="{ 'mi-mod-subtab--on': activeTab === 'ongoing' }"
|
||||
@tap="activeTab = 'ongoing'"
|
||||
>进行中</text>
|
||||
<text
|
||||
class="mi-mod-subtab"
|
||||
:class="{ 'mi-mod-subtab--on': activeTab === 'completed' }"
|
||||
@tap="activeTab = 'completed'"
|
||||
>已完成</text>
|
||||
</view>
|
||||
|
||||
<!-- 预约列表 -->
|
||||
<view
|
||||
v-for="item in displayedBookings"
|
||||
:key="item.id"
|
||||
class="mi-mod-course-card"
|
||||
hover-class="mi-tap-card--hover"
|
||||
>
|
||||
<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>
|
||||
</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">
|
||||
<text>取消预约</text>
|
||||
</view>
|
||||
<view v-if="groupSub === 'completed' && item.canEvaluate" class="mi-mod-course-card__action" @tap.stop="evaluate(item)">
|
||||
<text>去评价</text>
|
||||
</view>
|
||||
<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="activeTab === 'ongoing' && item.statusClass === 'booked'" class="mi-mod-course-card__cancel" @tap.stop="handleCancel(item)">
|
||||
<text>取消预约</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<!-- 私教 -->
|
||||
<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>
|
||||
</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 v-if="!displayedBookings.length" class="bt-empty">
|
||||
<text class="bt-empty__text">
|
||||
{{ activeTab === 'ongoing' ? '暂无进行中的预约' : '暂无历史预约' }}
|
||||
</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;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
.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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user