修正多处问题

This commit is contained in:
2026-06-29 13:35:29 +08:00
parent 09e587cb65
commit e85f39ab83
37 changed files with 3545 additions and 2538 deletions
+1 -1
View File
@@ -110,7 +110,7 @@ import { getMemberId } from '@/utils/request.js'
const STQRC = ref(false)//是否扫码
const isCheckIn = ref(false)
const isHaveCard = ref(true)
const webSoketURL = "ws://localhost:8084/webSocket/checkIn"
const webSoketURL = "ws://192.168.5.15:8084/webSocket/checkIn"
const qrcode = ref("")
+2
View File
@@ -130,6 +130,7 @@ function handleTabActive(index) {
}
onLoad(() => {
uni.hideLoading()
// 优先显示缓存
const hasCache = loadFromCache()
if (!hasCache) {
@@ -143,6 +144,7 @@ onLoad(() => {
})
onShow(() => {
uni.hideLoading()
// 每次显示时尝试刷新数据(后台静默更新)
if (!loading.value) {
loadFromNetwork()
+102 -40
View File
@@ -228,16 +228,36 @@
</view>
</view>
</view>
<!-- 支付密码弹窗 -->
<PayPasswordModal
:visible="showPayPwd"
title="支付验证"
:subtitle="course.courseName"
:amount="course.storedValueAmount || 0"
amount-label="课程金额"
@confirm="onPayPasswordConfirm"
@cancel="onPayPasswordCancel"
/>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getGroupCourseDetail, bookGroupCourse } from '@/api/groupCourse.js'
import { getPrimaryMemberCard } from '@/api/main.js'
import { getMemberId } from '@/utils/request.js'
import PageHeader from '@/components/index/PageHeader.vue'
import PayPasswordModal from '@/components/global/PayPasswordModal.vue'
// 加载状态
const loading = ref(true)
// 支付密码弹窗状态
const showPayPwd = ref(false)
const payPwdLoading = ref(false)
let payPwdResolve = null
// 课程详情数据
const course = ref({
id: '',
@@ -373,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));
+92 -3
View File
@@ -32,6 +32,18 @@
@change="onTimePeriodChange"
ref="timePeriodRef"
/>
<!-- 常态化团课筛选按钮 -->
<view class="recurring-filter">
<view
class="recurring-btn"
:class="{ 'recurring-btn--active': isRecurring }"
@tap="toggleRecurring"
>
<text class="recurring-icon">{{ isRecurring ? '✓' : '' }}</text>
<text class="recurring-text">常态化团课</text>
</view>
</view>
</view>
<!-- 团课列表 -->
@@ -46,6 +58,7 @@
:key="course.id"
:course="course"
:courseTypeLabels="getCourseLabels(course)"
:booked="isCourseBooked(course.id)"
@booking="handleBooking"
@detail="goDetail"
></GroupCourseCard>
@@ -74,6 +87,7 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import TabBar from '@/components/TabBar.vue'
import GroupCourseCard from '@/components/groupCourse/CourseCard.vue'
import SearchBar from '@/components/groupCourse/SearchBar.vue'
@@ -82,7 +96,8 @@ import TimePeriodSelector from '@/components/groupCourse/TimePeriodSelector.vue'
import TimeRangePicker from '@/components/groupCourse/TimeRangePicker.vue'
import PageHeader from '@/components/index/PageHeader.vue'
import { useGroupCourseList } from '@/composables/useGroupCourseList.js'
import { getTypeLabels, getGroupCourseTypes } from '@/api/groupCourse.js'
import { getTypeLabels, getGroupCourseTypes, getMemberBookings } from '@/api/groupCourse.js'
import { getMemberId } from '@/utils/request.js'
// 组件引用
const searchBarRef = ref(null)
@@ -93,6 +108,9 @@ const timeRangePickerRef = ref(null)
// 课程类型标签缓存
const courseTypeLabelsCache = ref({})
// 已预约课程ID集合
const bookedCourseIds = ref(new Set())
// 使用组合式函数
const {
// 状态
@@ -106,6 +124,7 @@ const {
sortIndex,
timePeriodOptions,
timePeriodIndex,
isRecurring,
showTimePicker,
startDate,
endDate,
@@ -126,13 +145,20 @@ const {
onScrollToLower
} = useGroupCourseList()
// 切换常态化团课筛选
const toggleRecurring = () => {
isRecurring.value = !isRecurring.value
fetchCourseList()
}
// 组件挂载时调用接口获取团课列表
onMounted(async () => {
console.log('[list.vue] 页面组件已挂载,开始获取团课列表')
// 并行获取课程类型列表课程列表
// 并行获取课程类型列表课程列表和已预约课程
await Promise.all([
fetchCourseTypes(),
fetchCourseList()
fetchCourseList(),
fetchBookedCourses()
])
// 获取所有团课的类型标签
await fetchAllCourseTypeLabels()
@@ -144,6 +170,12 @@ onMounted(async () => {
console.log(' - getAllSearchParams() 获取所有参数')
})
// 页面显示时刷新已预约状态(从其他页面返回时)
onShow(async () => {
console.log('[list.vue] onShow 刷新已预约状态')
await fetchBookedCourses()
})
const fetchCourseTypes = async () => {
try {
const result = await getGroupCourseTypes()
@@ -159,6 +191,33 @@ const fetchCourseTypes = async () => {
}
}
const fetchBookedCourses = async () => {
const memberId = getMemberId()
if (!memberId) {
console.log('[list.vue] 未登录,跳过获取已预约课程')
return
}
try {
const res = await getMemberBookings(memberId)
const bookings = Array.isArray(res) ? res : (res?.data || res?.content || [])
if (Array.isArray(bookings)) {
const ids = new Set()
bookings.forEach(b => {
// 只统计状态为正常的预约(status='0'),排除已取消(status='1'
if (b.courseId && String(b.status) === '0') ids.add(Number(b.courseId))
})
bookedCourseIds.value = ids
console.log('[list.vue] 已预约课程ID:', [...ids])
}
} catch (error) {
console.error('[list.vue] 获取已预约课程失败:', error)
}
}
const isCourseBooked = (courseId) => {
return bookedCourseIds.value.has(Number(courseId))
}
const fetchAllCourseTypeLabels = async () => {
const types = [...new Set(filteredCourseList.value.map(c => c.courseType))]
for (const type of types) {
@@ -227,4 +286,34 @@ defineExpose({
.load-more-text {
color: #1890ff;
}
/* 常态化团课筛选按钮 */
.recurring-filter {
padding-top: 8rpx;
}
.recurring-btn {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 28rpx;
border-radius: 32rpx;
border: 2rpx solid #e0e0e0;
background: #fff;
font-size: 26rpx;
color: #666;
transition: all 0.2s;
}
.recurring-btn--active {
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
border-color: #66bb6a;
color: #2e7d32;
font-weight: 600;
}
.recurring-icon {
font-size: 22rpx;
font-weight: 700;
}
</style>
+12 -104
View File
@@ -1,14 +1,10 @@
<template>
<!-- 固定白色块滚动时显示 -->
<view class="hand" :style="{height : handHeight + 50 + 'rpx',lineHeight: handHeight + 70 + 'rpx'}" v-show="isShow">活氧舱</view>
<!-- 页面头部 -->
<PageHeader title="活氧舱" subtitle="科学训练 · 遇见更好的自己" />
<!-- 滚动内容区域 -->
<scroll-view
scroll-y
refresher-enabled
:refresher-triggered="isRefreshing"
refresher-default-style="none"
@refresherrefresh="onRefresh"
@scroll="handleScroll"
class="scroll-container"
>
@@ -18,8 +14,8 @@
<template v-else>
<BannerSwiper />
<QuickEntry />
<RecommendCourses />
<TodayRecommend />
<RecommendCourses ref="recommendCoursesRef" />
<TodayRecommend ref="todayRecommendRef" />
<view class="bottom-placeholder"></view>
</template>
</view>
@@ -40,60 +36,19 @@ import RecommendCourses from '@/components/index/RecommendCourses.vue'
import TodayRecommend from '@/components/index/TodayRecommend.vue'
import TabBar from '@/components/TabBar.vue'
import HomeSkeleton from '@/components/Skeleton/HomeSkeleton.vue'
import PageHeader from '@/components/index/PageHeader.vue'
const loading = ref(true)
const isShow = ref(false)
const handHeight = ref(0)
const scrollDistance = ref(0)
const isRefreshing = ref(false)
const recommendCoursesRef = ref(null)
const todayRecommendRef = ref(null)
// 获取可视窗口高度
const windowHeight = ref(0)
// 获取整个滚动内容的高度
const scrollContentHeight = ref(0)
// 滚动距离
const scrollDistance = ref(0)
// 滚动监听
const handleScroll = (e) => {
const distance = e.detail.scrollTop
scrollDistance.value = distance
console.log(`滚动距离: ${distance}`)
// 当滚动超过一定距离时显示白色块(200px = 400rpx
const SHOW_THRESHOLD = 50 // 滚动超过200px时显示白条
isShow.value = distance > SHOW_THRESHOLD
}
// 下拉刷新处理
const onRefresh = async () => {
console.log('开始下拉刷新')
isRefreshing.value = true
try {
await refreshData()
isRefreshing.value = false
uni.showToast({
title: '刷新成功',
icon: 'success'
})
} catch (error) {
console.error('刷新失败', error)
isRefreshing.value = false
uni.showToast({
title: '刷新失败',
icon: 'error'
})
}
}
// 刷新数据
const refreshData = () => {
return new Promise((resolve) => {
setTimeout(() => {
console.log('数据已刷新')
resolve()
}, 1500)
})
scrollDistance.value = e.detail.scrollTop
console.log(`滚动距离: ${scrollDistance.value}`)
}
onShow(() => {
@@ -114,41 +69,6 @@ onMounted(() => {
setTimeout(() => {
loading.value = false
}, 1500)
// 获取系统信息
uni.getSystemInfo({
success: (res) => {
windowHeight.value = res.windowHeight
console.log('可视窗口高度:', windowHeight.value)
console.log('平台:', res.platform)
// #ifdef MP-WEIXIN
// 微信小程序使用胶囊按钮高度(保持原有逻辑)
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
const navTotalHeight = menuButtonInfo.top + menuButtonInfo.height
handHeight.value = navTotalHeight * 2.5
console.log('微信小程序胶囊按钮高度:', handHeight.value)
// #endif
// #ifndef MP-WEIXIN
// H5和安卓App只显示状态栏高度
const statusBarHeight = res.statusBarHeight || 0
handHeight.value = statusBarHeight * 2 // 转换为rpx1px = 2rpx
console.log('非微信小程序状态栏高度:', handHeight.value, 'rpx')
// #endif
}
})
// 延迟获取滚动内容高度(确保DOM已渲染)
setTimeout(() => {
const query = uni.createSelectorQuery().in(this)
query.select('.home-page').boundingClientRect(data => {
if (data) {
scrollContentHeight.value = data.height
console.log('内容总高度:', scrollContentHeight.value)
}
}).exec()
}, 500)
})
</script>
@@ -167,18 +87,6 @@ onMounted(() => {
padding-bottom: 160rpx;
}
/* 固定白色块 */
.hand {
position: fixed;
top: 0;
left: 0;
z-index: 100;
background-color: white;
width: 100%;
text-align: center;
font-size: 34rpx;
}
/* 固定 TabBar */
.tabbar-fixed {
position: fixed;
+109 -76
View File
@@ -1,7 +1,7 @@
<template>
<view class="scroll-container theme-light">
<view class="booking-page">
<MemberInfoSubNav title="我的预约" @back="goBack" />
<PageHeader title="我的预约" subtitle="" :show-back="true" />
<view class="booking-page__tabs">
<view
@@ -11,7 +11,7 @@
:class="{ 'booking-page__tab--active': activeTab === tab.key }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="setActiveTab(tab.key)"
@tap="activeTab = tab.key"
>
<text
class="booking-page__tab-text"
@@ -26,31 +26,15 @@
</view>
</view>
<view class="bt-page__action-bar bt-page__action-bar--end">
<text
class="bt-page__action-link bt-page__action-link--primary"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="goCourseList"
>
预约课程
</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])
})
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 }))
async function fetchBookings() {
if (loading.value) return
loading.value = true
console.log('[booking] 开始获取预约记录')
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
+282 -179
View File
@@ -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%);
+75 -108
View File
@@ -1,5 +1,10 @@
<template>
<view class="member-info-page">
<!-- 固定页面头部 -->
<view class="member-info-page-header">
<PageHeader title="个人中心" subtitle="" />
</view>
<!-- 滚动内容区域 -->
<view class="member-scroll-wrap">
<scroll-view scroll-y class="member-scroll">
@@ -15,32 +20,13 @@
<view class="member-page__sections">
<MemberInfoMemberCard
v-if="isLogin"
ref="memberCardRef"
:card-info="cardInfo"
@view-all="goMemberCard"
@renew="onRenewCard"
@purchase="goPurchaseCard"
@stored-card-tap="goRechargeStoredCard"
/>
<MemberInfoQuickActions @action="onQuickAction" />
<MemberInfoBookingList
v-if="isLogin"
:items="bookingPreview"
@view-all="goBooking"
@item-tap="goBooking"
/>
<MemberInfoCheckInList
v-if="isLogin"
:items="checkIns"
@view-all="onCheckInViewAll"
@item-tap="onCheckInTap"
/>
<MemberInfoCouponPoints
v-if="isLogin"
:data="couponPoints"
@view-all="onCouponViewAll"
@use-coupon="onUseCoupon"
@redeem-points="onRedeemPoints"
/>
<MemberInfoSettings v-if="isLogin" @setting="onSetting" />
<MemberInfoLogout v-if="isLogin" @logout="handleLogout" />
</view>
</view>
@@ -63,31 +49,27 @@ import {
getCenterPageData,
renewMemberCard,
saveMemberStore,
getDefaultStore
getDefaultStore,
loadMemberStore
} from '@/common/memberInfo/store.js'
import { getMemberId } from '@/utils/request.js'
import { login as miniappLogin, getUserInfo, getMemberDetail, getCheckInStats, updateUserInfo } from '@/api/main.js'
import { login as miniappLogin, getUserInfo, getMemberDetail, getCheckInStats, getMemberBookings, updateUserInfo } from '@/api/main.js'
import { setToken, getToken, clearToken } from '@/utils/request.js'
import MemberInfoHeader from '@/components/memberInfo/MemberInfoHeader.vue'
import MemberInfoMemberCard from '@/components/memberInfo/MemberInfoMemberCard.vue'
import MemberInfoQuickActions from '@/components/memberInfo/MemberInfoQuickActions.vue'
import MemberInfoBookingList from '@/components/memberInfo/MemberInfoBookingList.vue'
import MemberInfoCheckInList from '@/components/memberInfo/MemberInfoCheckInList.vue'
import MemberInfoCouponPoints from '@/components/memberInfo/MemberInfoCouponPoints.vue'
import MemberInfoSettings from '@/components/memberInfo/MemberInfoSettings.vue'
import MemberInfoLogout from '@/components/memberInfo/MemberInfoLogout.vue'
import TabBar from '@/components/TabBar.vue'
import PageHeader from '@/components/index/PageHeader.vue'
const userInfo = ref({})
const stats = ref({})
const cardInfo = ref({})
const bookingPreview = ref([])
const checkIns = ref([])
const couponPoints = ref({})
const loading = ref(false)
const hasActiveCard = ref(false)
const isLogin = ref(false)
const memberCardRef = ref(null)
onMounted(() => {
const token = uni.getStorageSync('token')
const loginMember = uni.getStorageSync('loginMemberInfo')
@@ -147,6 +129,50 @@ async function fetchMemberInfo() {
stats.value.checkInCount = 0
}
// 获取报课次数
try {
const memberIdForBooking = getMemberId()
console.log('[memberInfo] getMemberId 结果:', memberIdForBooking)
if (memberIdForBooking) {
const bookingsRes = await getMemberBookings(memberIdForBooking)
console.log('[memberInfo] getMemberBookings 原始响应:', JSON.stringify(bookingsRes))
// 兼容多种后端响应格式(数组 / data数组 / data.content / content / data.data.content
let bookings = []
const raw = bookingsRes || {}
if (Array.isArray(raw)) {
bookings = raw
} else if (Array.isArray(raw.data)) {
bookings = raw.data
} else if (raw.data && Array.isArray(raw.data.content)) {
bookings = raw.data.content
} else if (Array.isArray(raw.content)) {
bookings = raw.content
} else if (raw.data && typeof raw.data === 'object' && raw.data.data && Array.isArray(raw.data.data)) {
bookings = raw.data.data
} else if (raw.data && typeof raw.data === 'object' && raw.data.data && Array.isArray(raw.data.data.content)) {
bookings = raw.data.data.content
}
stats.value.courseCount = bookings.length
console.log('[memberInfo] 解析后 courseCount:', stats.value.courseCount)
} else {
stats.value.courseCount = 0
console.warn('[memberInfo] getMemberId 返回 null/undefined')
}
} catch (e) {
console.error('[memberInfo] 获取报课次数失败:', e)
stats.value.courseCount = 0
}
// 将获取到的数据保存到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 {
+387 -176
View File
@@ -1,189 +1,334 @@
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="我的课程" @back="goBack" />
<view class="mi-mod-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="bt-tab"
:class="{ 'bt-tab--active': activeTab === tab.key }"
hover-class="mi-tap-tab--hover"
@tap="activeTab = tab.key"
>
<text class="bt-tab__text">{{ tab.label }}</text>
</view>
</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>