146 changed files with 16628 additions and 6047 deletions
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="gym.manage.uniapp">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<application>
<activity
android:name="com.mobile.auth.gatewayauth.LoginAuthActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="false"
android:launchMode="singleTop"
android:screenOrientation="behind"
android:theme="@style/authsdk_activity_dialog" />
<activity
android:name="com.mobile.auth.gatewayauth.activity.AuthWebVeiwActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="false"
android:launchMode="singleTop"
android:screenOrientation="behind" />
<activity
android:name="com.mobile.auth.gatewayauth.PrivacyDialogActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="false"
android:launchMode="singleTop"
android:screenOrientation="behind"
android:theme="@style/authsdk_activity_dialog" />
</application>
</manifest>
+76 -68
View File
@@ -1,77 +1,85 @@
<template>
<template>
<view>
<GlobalLoading />
</view>
</template>
<script>
import GlobalLoading from '@/components/global/GlobalLoading.vue'
export default {
onLaunch: function() {
console.log('App Launch')
this.preloadTabData()
},
onShow: function() {
console.log('App Show')
},
onHide: function() {
console.log('App Hide')
},
methods: {
// 预加载所有 Tab 页面的核心数据
preloadTabData() {
// 延迟执行,不阻塞首屏
setTimeout(() => {
// 预加载课程数据
// #ifdef MP-WEIXIN
// 小程序端预请求数据
// #endif
// 预加载训练数据
}, 1000)
}
<script setup>
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
import GlobalLoading from '@/components/global/GlobalLoading.vue'
// 隐藏原生 TabBar - 这里是核心修复
const hideNativeTabBar = () => {
// 尝试多次执行,确保执行成功
const tryHide = (times = 0) => {
if (times > 10) return // 最多尝试10次
uni.hideTabBar({
animation: false,
success: () => {
console.log('✅ 原生TabBar隐藏成功')
// 强制 CSS 覆盖(双重保险)
forceCSSHide()
},
fail: (err) => {
console.log(`❌ 第${times+1}次隐藏失败,1秒后重试`, err)
setTimeout(() => tryHide(times + 1), 1000)
}
})
}
// 延迟 300ms 执行,确保页面挂载完成
setTimeout(() => tryHide(), 300)
}
// 强制 CSS 覆盖(最终保险)
const forceCSSHide = () => {
// #ifdef APP-PLUS
const style = document.createElement('style')
style.innerHTML = `
uni-tabbar,
uni-tabbar .uni-tabbar,
.uni-tabbar,
uni-tabbar > .uni-tabbar,
[class*="uni-tabbar"] {
display: none !important;
height: 0 !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
`
document.head.appendChild(style)
console.log('✅ CSS 强制覆盖已注入')
// #endif
}
// 预加载所有 Tab 页面的核心数据
const preloadTabData = () => {
// 延迟执行,不阻塞首屏
setTimeout(() => {
// 预加载课程数据等...
}, 1000)
}
onLaunch(() => {
console.log('App Launch')
preloadTabData()
})
onShow(() => {
console.log('App Show')
// #ifdef APP-PLUS
hideNativeTabBar()
// #endif
})
onHide(() => {
console.log('App Hide')
})
</script>
<style lang="scss">
@import 'common/style/base.css';
/* 全局骨架屏样式 */
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 页面切换动画 */
.page-enter-active,
.page-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.page-enter-from {
opacity: 0;
transform: translateX(30rpx);
}
.page-leave-to {
opacity: 0;
transform: translateX(-30rpx);
}
.app-container {
width: 100%;
min-height: 100vh;
max-width: 430px;
margin: 0 auto;
background-color: var(--bg-light);
position: relative;
overflow-x: hidden;
}
</style>
/* 注意要写在第一行,同时给style标签加入lang="scss"属性 */
@import "@/uni.scss";
</style>
+132
View File
@@ -0,0 +1,132 @@
import request from "@/utils/request.js"
export function getGroupCourseList(params = {}, options = {}) {
return request.get('/groupCourse/list', params, options)
}
export function getGroupCoursePage(params = {}, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
const { page = 0, size = 10, sort = 'id', order = 'asc', keyword } = params
return request.post('/groupCourse/page', { page, size, sort, order, keyword }, options)
}
export function getGroupCourseById(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) {
return request.get(`/groupCourse/${id}`, {}, options)
}
export function getGroupCourseDetail(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) {
return request.get(`/groupCourse/${id}/detail`, {}, options)
}
export function createGroupCourse(params) {
return request.post('/groupCourse', params)
}
export function updateGroupCourse(id, params) {
return request.put(`/groupCourse/${id}`, params)
}
export function cancelGroupCourse(id) {
return request.post(`/groupCourse/${id}/cancel`)
}
export function deleteGroupCourse(id) {
return request.delete(`/groupCourse/${id}`)
}
export function getGroupCourseTypes(params = {}, options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
return request.get('/groupCourse/types', params, options)
}
export function getGroupCourseTypeById(id, options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
return request.get(`/groupCourse/types/${id}`, {}, options)
}
export function getTypeLabels(typeId, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
return request.get(`/groupCourse/types/${typeId}/labels`, {}, options)
}
export function searchGroupCourse(params = {}, options = {}) {
const {
courseName,
courseType,
startDate,
endDate,
timePeriod,
priceSort,
remainingMost,
isRecurring,
page = 0,
size = 10
} = params
const requestBody = { page, size }
if (courseName) requestBody.courseName = courseName
if (courseType) requestBody.courseType = courseType
if (startDate) requestBody.startDate = formatDateTime(startDate)
if (endDate) requestBody.endDate = formatDateTime(endDate, true)
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)
}
function formatDateTime(dateStr, isEnd = false) {
if (!dateStr) return dateStr
if (dateStr.includes('T')) return dateStr
return isEnd
? `${dateStr}T23:59:59`
: `${dateStr}T00:00:00`
}
export function bookGroupCourse(params) {
return request.post('/groupCourse/book', params)
}
export function cancelBooking(bookingId, params) {
return request.post(`/groupCourse/booking/${bookingId}/cancel`, params)
}
export function getMemberBookings(memberId, options = {}) {
return request.get(`/groupCourse/bookings/member/${memberId}`, {}, options)
}
export function getActiveRecommendCourses(options = { cache: false }) {
return request.get('/groupCourse/recommend/active', {}, options)
}
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/signin/${memberId}`, { courseId })
}
export default {
getGroupCourseList,
getGroupCoursePage,
searchGroupCourse,
getGroupCourseById,
getGroupCourseDetail,
createGroupCourse,
updateGroupCourse,
cancelGroupCourse,
deleteGroupCourse,
getGroupCourseTypes,
getGroupCourseTypeById,
getTypeLabels,
bookGroupCourse,
cancelBooking,
getMemberBookings,
getActiveRecommendCourses,
getGroupCourseRecommendList
}
+246 -2
View File
@@ -1,50 +1,294 @@
import request from "@/utils/request.js"
/**
* 微信小程序登录
* @param {object} params - 登录参数 { code: string }
*/
export function login(params) {
return request.post('/member/auth/miniapp/login', params)
}
/**
* 发送短信验证码
* @param {object} params - { phone: string }
*/
export function sendCode(params) {
return request.post('/auth/phone/send-code', params)
}
/**
* 手机号验证码登录
* @param {object} params - { phone: string, code: string }
*/
export function loginWithPhone(params) {
return request.post('/auth/phone/code-login', params)
}
/**
* 手机号一键登录
* @param {object} params - { accessToken: string, openid: string, nickname?: string, avatar?: string }
*/
export function oneClickLogin(params) {
return request.post('/auth/phone/one-click-login', params)
}
/**
* 退出登录
*/
export function logout() {
return request.post('/member/auth/logout')
}
/**
* 获取签到二维码
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getQRCode(options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
return request.get('/checkIn/qrcode', {}, options)
}
/**
* 扫码签到
* @param {object} params - { qrcode: string, memberId: number }
*/
export function checkIn(params) {
return request.post('/checkIn/scan', params)
return request.post('/checkIn', params)
}
/**
* 获取签到记录(分页)
* @param {object} params - { page: number, size: number }
* @param {object} options - 请求选项
*/
export function getCheckInRecords(params = {}, options = {}) {
const { page = 0, size = 5 } = params
return request.post('/checkIn/page', { page, size }, options)
}
/**
* 获取用户未读消息数量
* @param {number} userId - 用户ID
* @param {object} options - 请求选项
*/
export function getUnreadMessageCount(userId, options = {}) {
return request.get(`/messages/user/${userId}/unread`, {}, options)
}
// ========== 消息相关API ==========
/**
* 获取用户消息列表(支持分页)
* @param {number} userId - 用户ID
* @param {object} params - 查询参数
* @param {number} params.page - 页码(从0开始)
* @param {number} params.size - 每页条数
* @param {object} options - 请求选项
* @returns {Promise} 消息列表
*/
export function getUserMessages(userId, params = {}, options = {}) {
const { page = 0, size = 10 } = params
return request.get(`/messages/user/${userId}/page`, { page, size }, options)
}
/**
* 获取未读消息列表(支持分页)
* @param {number} userId - 用户ID
* @param {object} params - 查询参数
* @param {number} params.page - 页码(从0开始)
* @param {number} params.size - 每页条数
* @param {object} options - 请求选项
* @returns {Promise} 未读消息列表
*/
export function getUnreadMessages(userId, params = {}, options = {}) {
const { page = 0, size = 10 } = params
return request.get(`/messages/user/${userId}/unread/page`, { page, size }, options)
}
/**
* 标记消息为已读
* @param {number|string} id - 消息ID
* @param {object} options - 请求选项
* @returns {Promise} 操作结果
*/
export function markMessageAsRead(id, options = {}) {
return request.put(`/messages/${id}/read`, {}, options)
}
/**
* 标记所有消息为已读
* @param {number} userId - 用户ID
* @param {object} options - 请求选项
* @returns {Promise} 操作结果
*/
export function markAllMessagesAsRead(userId, options = {}) {
return request.put(`/messages/user/${userId}/read`, {}, options)
}
/**
* 删除消息
* @param {number|string} id - 消息ID
* @param {object} options - 请求选项
* @returns {Promise} 操作结果
*/
export function deleteMessage(id, options = {}) {
return request.delete(`/messages/${id}`, {}, options)
}
/**
* 获取用户信息(基础信息缓存)
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getUserInfo(options = { cache: true, cacheTime: 30 * 60 * 1000 }) {
return request.get('/member/info', {}, options)
}
/**
* 获取会员详细信息(包含会员卡、积分等)
* @param {object} options - 请求选项 { cache: boolean }
*/
export function getMemberDetail(options = { cache: false }) {
return request.get('/member/info', {}, options)
}
/**
* 获取签到统计
* @param {object} params - 查询参数 { startDate: string, endDate: string }
* @param {object} options - 请求选项 { cache: boolean }
*/
export function getCheckInStats(params = {}, options = { cache: false }) {
return request.get('/checkIn/statistics', params, options)
}
/**
* 更新用户信息
* @param {object} params - 用户信息参数
*/
export function updateUserInfo(params) {
return request.put('/member/info', params)
}
// ========== 系统配置相关API ==========
/**
* 根据配置键获取配置值
* @param {string} configKey - 配置键
* @param {object} options - 请求选项
*/
export function getConfigByKey(configKey, options = {}) {
return request.get(`/config/key/${configKey}`, {}, options)
}
// ========== 课程相关API ==========
/**
* 获取推荐课程列表
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getRecommendCourses(options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
return request.get('/course/recommend', {}, options)
}
/**
* 获取课程详情
* @param {number} id - 课程ID
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getCourseDetail(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) {
return request.get(`/course/${id}`, {}, options)
}
/**
* 获取团课列表(分页)
* @param {object} params - 查询参数 { page: number, size: number, sort: string, order: string, keyword: string }
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
*/
export function getGroupCoursePage(params = {}, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
const { page = 0, size = 10, sort = 'id', order = 'asc', keyword } = params
return request.post('/groupCourse/page', { page, size, sort, order, keyword }, options)
}
// ========== 团课预约相关API ==========
/**
* 预约团课
* @param {object} params - 预约参数
* @param {number} params.courseId - 团课ID
* @param {number} params.memberId - 会员ID
*/
export function bookGroupCourse(params) {
return request.post('/groupCourse/book', params)
}
/**
* 取消预约
* @param {number} bookingId - 预约ID
*/
export function cancelBooking(bookingId) {
return request.delete(`/groupCourse/book/${bookingId}`, {})
}
/**
* 团课签到
* @param {number} memberId - 会员ID
* @param {number} courseId - 团课ID
*/
export function signinGroupCourse(memberId, courseId) {
return request.post(`/groupCourse/signin/${memberId}`, { courseId })
}
/**
* 查询会员预约记录
* @param {number} memberId - 会员ID
*/
export function getMemberBookings(memberId, options = { cache: false }) {
return request.get(`/groupCourse/bookings/member/${memberId}`, {}, options)
}
/**
* 查询课程预约记录
* @param {number} courseId - 团课ID
*/
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,
loginWithPhone,
oneClickLogin,
logout,
getQRCode,
checkIn,
getCheckInRecords,
getCheckInStats,
getUnreadMessageCount,
getUserMessages,
getUnreadMessages,
markMessageAsRead,
markAllMessagesAsRead,
deleteMessage,
getUserInfo,
getMemberDetail,
updateUserInfo,
getConfigByKey,
getRecommendCourses,
getCourseDetail,
getGroupCoursePage
getGroupCoursePage,
bookGroupCourse,
cancelBooking,
signinGroupCourse,
getMemberBookings,
getCourseBookings,
getActiveBanners
}
+12 -13
View File
@@ -4,11 +4,8 @@
export const PAGE = {
INDEX: '/pages/index/index',
COURSE: '/pages/course/index',
TRAIN: '/pages/train/index',
DISCOVER: '/pages/discover/index',
MEMBER: '/pages/memberInfo/memberInfo',
BOOKING: '/pages/memberInfo/booking',
MEMBER_CARD: '/pages/memberInfo/memberCard',
USER_INFO: '/pages/memberInfo/userInfo',
BODY_TEST_HOME: '/pages/memberInfo/bodyTestHome',
BODY_TEST_CONNECT: '/pages/memberInfo/bodyTestConnect',
@@ -39,8 +36,6 @@ export const PAGE = {
export const TAB_ROUTES = [
PAGE.INDEX,
PAGE.COURSE,
PAGE.TRAIN,
PAGE.DISCOVER,
PAGE.MEMBER
]
@@ -81,6 +76,7 @@ export function navigateToPage(url) {
// 这种情况应该使用 switchToTabPage(会清空页面栈)
if (TAB_PAGES.has(path)) {
console.warn('[navigateToPage] 不应该用 navigateTo 跳转 TabBar 页面,请使用 switchToTabPage')
uni.hideLoading()
switchToTabPage(path)
return
}
@@ -91,6 +87,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 })
@@ -98,11 +95,11 @@ export function navigateToPage(url) {
uni.showToast({ title: '页面跳转失败', icon: 'none' })
}
},
success: () => {
setTimeout(() => {
uni.hideLoading()
},3000)
}
complete: () => {
// 页面已发起跳转,隐藏 loading
// 目标页面的 onLoad/onReady 也会调用 hideLoading 做兜底
uni.hideLoading()
}
})
}
@@ -123,19 +120,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)
@@ -186,4 +185,4 @@ export function backToMemberCenter() {
*/
export function backToTab(tabUrl) {
goBackOrTab(tabUrl)
}
}
@@ -0,0 +1,247 @@
/**
* 登录拦截Hook
* 用于在需要登录的操作前检查用户登录状态,未登录则弹出登录框
*
* 使用方式:
* import { useLoginGuard } from '@/common/hooks/useLoginGuard.js'
*
* const { checkLogin, requireLogin } = useLoginGuard()
*
* // 方式1:检查登录状态(返回布尔值)
* if (!checkLogin()) return
*
* // 方式2:需要登录才执行(推荐)
* await requireLogin()
* // 之后的代码只有在登录后才执行
*
* // 方式3:在点击事件中使用
* async function handleClick() {
* await requireLogin()
* // 执行需要登录的操作
* }
*/
import { getToken } from '@/utils/request.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
// 保存当前页面的登录弹窗引用
let loginModalRef = null
/**
* 注册登录弹窗组件
* 在App.vue或页面onMounted中调用
* @param {Object} modalRef - loginModal组件的ref
*/
export function registerLoginModal(modalRef) {
loginModalRef = modalRef
}
/**
* 获取当前登录状态
* @returns {Boolean}
*/
export function isLoggedIn() {
const token = getToken()
const isLoginStorage = uni.getStorageSync('isLogin')
return !!(token || isLoginStorage)
}
/**
* 获取登录用户信息
* @returns {Object|null}
*/
export function getLoginUser() {
const store = loadMemberStore()
return store.profile || null
}
/**
* 使用登录拦截Hook
* @returns {Object}
*/
export function useLoginGuard() {
/**
* 检查登录状态(同步)
* @returns {Boolean} 是否已登录
*/
function checkLogin() {
return isLoggedIn()
}
/**
* 需要登录才继续执行(异步)
* 如果未登录,弹出登录框,等待用户登录或取消
* @param {Object} options - 配置选项
* @param {String} options.title - 弹窗标题
* @param {String} options.subtitle - 弹窗副标题
* @param {Function} options.onSuccess - 登录成功回调
* @param {Function} options.onCancel - 取消登录回调
* @returns {Promise<Boolean>} 是否已登录(用户登录成功返回true,取消返回false)
*/
async function requireLogin(options = {}) {
// 如果已经登录,直接返回true
if (isLoggedIn()) {
return true
}
// 如果传入了onSuccess回调,先检查
if (options.onSuccess) {
const store = loadMemberStore()
if (store.profile?.id) {
options.onSuccess(store.profile)
return true
}
}
// 显示登录弹窗
return new Promise((resolve) => {
if (!loginModalRef) {
console.warn('[useLoginGuard] 登录弹窗未注册,请先调用 registerLoginModal')
// 尝试跳转到登录页
uni.navigateTo({
url: '/pages/memberInfo/login',
fail: () => {
uni.showToast({
title: '请先登录',
icon: 'none'
})
}
})
resolve(false)
return
}
// 保存resolve函数
let resolvePromise = resolve
// 显示登录弹窗
loginModalRef.show()
// 监听登录成功
const loginSuccessHandler = (result) => {
if (options.onSuccess) {
options.onSuccess(result)
}
resolvePromise(true)
}
// 监听登录失败/取消
const closeHandler = () => {
if (options.onCancel) {
options.onCancel()
}
resolvePromise(false)
}
// 注册事件监听
uni.$once('loginModal:success', loginSuccessHandler)
uni.$once('loginModal:close', closeHandler)
// 设置超时
setTimeout(() => {
uni.$off('loginModal:success', loginSuccessHandler)
uni.$off('loginModal:close', closeHandler)
}, 60000) // 60秒超时
})
}
/**
* 安全执行函数(仅登录后执行)
* @param {Function} fn - 要执行的函数
* @param {Object} options - 配置选项
* @returns {Promise<any>} 函数返回值或null
*/
async function safeExecute(fn, options = {}) {
const loggedIn = await requireLogin(options)
if (loggedIn && typeof fn === 'function') {
return await fn()
}
return null
}
/**
* 包装需要登录的API调用
* @param {Function} apiFn - API函数
* @param {Array} args - API参数
* @param {Object} options - 配置选项
* @returns {Promise<any>}
*/
async function callLoggedInApi(apiFn, args = [], options = {}) {
const loggedIn = await requireLogin(options)
if (loggedIn && typeof apiFn === 'function') {
return await apiFn(...args)
}
throw new Error('用户未登录')
}
return {
checkLogin,
requireLogin,
safeExecute,
callLoggedInApi,
isLoggedIn,
getLoginUser
}
}
/**
* 创建登录拦截Mixin(用于选项式API)
* @returns {Object}
*/
export function loginGuardMixin() {
return {
data() {
return {
isLogin: false
}
},
onLoad() {
this.checkLoginStatus()
},
onShow() {
this.checkLoginStatus()
},
methods: {
checkLoginStatus() {
this.isLogin = isLoggedIn()
},
async ensureLogin() {
if (!this.isLogin) {
if (!loginModalRef) {
uni.navigateTo({
url: '/pages/memberInfo/login'
})
return false
}
loginModalRef.show()
return false
}
return true
}
}
}
}
/**
* 路由守卫配置
* 配置需要登录的页面路径
*/
export const protectedRoutes = [
'/pages/memberInfo/memberInfo',
'/pages/memberInfo/checkIn',
'/pages/memberInfo/booking',
'/pages/memberInfo/coupons',
'/pages/memberInfo/points',
'/pages/memberInfo/userInfo'
]
/**
* 检查当前页面是否需要登录
* @param {String} currentPage - 当前页面路径
* @returns {Boolean}
*/
export function isProtectedRoute(currentPage) {
return protectedRoutes.some(route => currentPage.includes(route))
}
@@ -1,4 +1,4 @@
const COLORS = {
const COLORS = {
primary: '#0B2B4B',
accent: '#FF6B35',
accentLight: 'rgba(255, 107, 53, 0.25)',
@@ -1,10 +1,4 @@
import { bodyTestMock } from './mockData.js'
function clone(value) {
return JSON.parse(JSON.stringify(value))
}
function formatRecordTime(date) {
function formatRecordTime(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
@@ -28,9 +22,9 @@ function formatTime(date) {
export function getDefaultBodyTestState() {
return {
settings: { ...bodyTestMock.settings },
device: { ...bodyTestMock.device },
records: clone(bodyTestMock.records)
settings: {},
device: { connected: false, battery: 80 },
records: []
}
}
@@ -167,14 +161,26 @@ export function getBodyTestTrendData(store, metricKey, limit = 6) {
}))
}
function getMetricDefs() {
return [
{ key: 'weight', label: '体重' },
{ key: 'bmi', label: 'BMI' },
{ key: 'bodyFat', label: '体脂率' },
{ key: 'muscleMass', label: '肌肉量' },
{ key: 'visceralFat', label: '内脏脂肪' },
{ key: 'bmr', label: '基础代谢' }
]
}
export function getCompareData(store, idA, idB) {
const a = getBodyTestRecordById(store, idA)
const b = getBodyTestRecordById(store, idB)
if (!a || !b) return null
const metricDefs = getMetricDefs()
const keys = ['weight', 'bmi', 'bodyFat', 'muscleMass', 'visceralFat', 'bmr']
const metrics = keys.map((key) => ({
key,
label: bodyTestMock.metricDefs.find((m) => m.key === key)?.label || key,
label: metricDefs.find((m) => m.key === key)?.label || key,
valueA: a.metrics[key],
valueB: b.metrics[key],
diff: Math.round((a.metrics[key] - b.metrics[key]) * 10) / 10
@@ -183,8 +189,7 @@ export function getCompareData(store, idA, idB) {
}
export function getRecommendedCourses(record) {
const ids = record?.recommendedCourseIds || []
return bodyTestMock.recommendedCourses.filter((c) => ids.includes(c.id))
return []
}
export function updateBodyTestSettings(store, patch) {
@@ -243,9 +248,9 @@ export function saveSimulatedBodyTestRecord(store, finalMetrics) {
status,
metrics,
radar,
bodySegments: clone(bodyTestMock.records[0].bodySegments),
advice: clone(bodyTestMock.records[0].advice),
recommendedCourseIds: [1, 2]
bodySegments: [],
advice: [],
recommendedCourseIds: []
}
if (previous) {
@@ -279,5 +284,3 @@ export function interpolateMeasuringMetrics(progress, profile) {
bodyWater: Math.round((51 + (target.bodyWater - 51) * ratio) * 10) / 10
}
}
export { bodyTestMock }
@@ -1,4 +1,4 @@
import { courseCatalogMock } from './mockData.js'
import { courseCatalogMock } from './mockData.js'
function clone(value) {
return JSON.parse(JSON.stringify(value))
@@ -57,6 +57,14 @@ export function canCancelBooking(item) {
return diff >= 2 * 3600000
}
export function canSigninCourse(item) {
if (!item?.courseDate || !item?.startTime) return false
if (item.status !== 'booked' && item.status !== 0) return false
const start = new Date(`${item.courseDate} ${item.startTime}`.replace(/-/g, '/'))
const diff = start - Date.now()
return diff <= 2 * 3600000 && diff >= -2 * 3600000
}
export function bookCourse(store, courseId) {
const course = store.courseCatalog.find((c) => c.id === Number(courseId))
if (!course) return { ok: false, message: '课程不存在' }
@@ -1,4 +1,4 @@
/** 手机号展示脱敏(中间四位 ****) */
/** 手机号展示脱敏(中间四位 ****) */
export function maskPhone(phone) {
if (phone == null || phone === '') return ''
+8 -9
View File
@@ -1,5 +1,4 @@
export { memberCenterMock, userInfoMock, fitnessGoalOptions, bookingMock, memberCardMock, bodyTestMock, moduleMock, courseCatalogMock } from './mockData.js'
export { statusBarTimeMixin, subPageMixin } from './mixins.js'
export { statusBarTimeMixin, subPageMixin } from './mixins.js'
export {
loadMemberStore,
saveMemberStore,
@@ -13,7 +12,10 @@ export {
cancelOngoingBooking,
renewMemberCard,
parseLocalDate,
saveUserProfile
saveUserProfile,
getCurrentMemberId,
getLoginMemberInfo,
getToken
} from './store.js'
export {
getLatestBodyTestRecord,
@@ -31,8 +33,7 @@ export {
connectBodyTestDevice,
disconnectBodyTestDevice,
saveSimulatedBodyTestRecord,
interpolateMeasuringMetrics,
bodyTestMock
interpolateMeasuringMetrics
} from './bodyTestStore.js'
export {
getTrainingReportData,
@@ -52,8 +53,7 @@ export {
getMyCoursesByTab,
getOnlineCourseById,
updateOnlineProgress,
getCheckInHistory,
moduleMock
getCheckInHistory
} from './moduleStore.js'
export {
filterCourses,
@@ -61,8 +61,7 @@ export {
bookCourse,
canCancelBooking,
enrichCourseForDisplay,
getWeekDates,
courseCatalogMock
getWeekDates
} from './bookingStore.js'
export { previewImage, persistChosenImage, isLocalFilePath } from './media.js'
export { maskPhone, formatMemberCenterPhone, normalizePhoneForStore } from './format.js'
+1 -1
View File
@@ -1,4 +1,4 @@
/** 头像等媒体:真机选图后须 saveFile/static/ 须 getImageInfo */
/** 头像等媒体:真机选图后须 saveFile/static/ 须 getImageInfo */
function buildStaticPathCandidates(url) {
const list = [url]
@@ -1,4 +1,4 @@
import { backToMemberCenter } from '../constants/routes.js'
import { backToMemberCenter } from '../constants/routes.js'
/** 状态栏时间(Pixso 顶栏占位) */
export const statusBarTimeMixin = {
@@ -1,6 +1,9 @@
/** 个人中心模块 mock 数据(后续可替换为 API) */
/** 个人中心模块 mock 数据(后续可替换为 API) */
export const memberCenterMock = {
// Mock 数据开关 - 设为 false 可关闭所有 mock 数据
export const MOCK_ENABLED = false
export const memberCenterMock = MOCK_ENABLED ? {
userInfo: {
name: '张小芳',
phone: '13812345678 已绑定微信',
@@ -65,7 +68,7 @@ export const memberCenterMock = {
registered: 3,
purchased: 2
}
}
} : null
export const userInfoMock = {
name: '张小芳',
@@ -1,4 +1,4 @@
import { moduleMock } from './mockData.js'
import { moduleMock } from './mockData.js'
+94 -50
View File
@@ -1,11 +1,4 @@
import {
memberCenterMock,
userInfoMock,
memberCardMock,
bookingMock
} from './mockData.js'
import { formatMemberCenterPhone, normalizePhoneForStore } from './format.js'
import {
import {
getDefaultBodyTestState,
mergeBodyTestState,
getLatestBodyTestRecord,
@@ -16,16 +9,34 @@ import {
mergeModuleState,
finalizeModules
} from './moduleStore.js'
import {
getDefaultCourseCatalog,
import { getDefaultCourseCatalog,
mergeCourseCatalog,
canCancelBooking
} from './bookingStore.js'
import { getMemberId as requestGetMemberId } from '@/utils/request.js'
const STORAGE_KEY = 'gym_member_info_v1'
function clone(value) {
return JSON.parse(JSON.stringify(value))
/**
* 获取当前登录会员ID
* @deprecated 请使用 @/utils/request.js 中的 getMemberId
*/
export function getCurrentMemberId() {
return requestGetMemberId()
}
/**
* 获取登录信息
*/
export function getLoginMemberInfo() {
return uni.getStorageSync('loginMemberInfo') || null
}
/**
* 获取token
*/
export function getToken() {
return uni.getStorageSync('token') || null
}
export function buildCardTip(remainingDays) {
@@ -43,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
}
@@ -51,12 +64,6 @@ export function syncStats(store) {
function finalizeStore(store) {
syncStats(store)
applyCardInfo(store)
if (store.profile?.avatar) {
store.memberProfile.avatar = store.profile.avatar
}
if (store.profile?.phone) {
store.memberProfile.phone = formatMemberCenterPhone(store.profile.phone)
}
const latestBodyTest = getLatestBodyTestRecord(store)
if (latestBodyTest) {
const previous = store.bodyTest.records[1]
@@ -70,32 +77,36 @@ function finalizeStore(store) {
function getDefaultStore() {
return finalizeStore({
profile: { ...userInfoMock, avatar: memberCenterMock.userInfo.avatar },
memberProfile: { ...memberCenterMock.userInfo },
stats: { ...memberCenterMock.stats },
cardInfo: { ...memberCenterMock.cardInfo },
card: { ...memberCardMock.card },
records: clone(memberCardMock.records),
ongoingBookings: clone(bookingMock.ongoing),
historyBookings: clone(bookingMock.history),
checkIns: clone(memberCenterMock.checkIns),
bodyReport: { ...memberCenterMock.bodyReport },
stats: {},
cardInfo: {},
card: {},
cards: [],
records: [],
ongoingBookings: [],
historyBookings: [],
checkIns: [],
bodyReport: {},
bodyTest: getDefaultBodyTestState(),
modules: getDefaultModuleState(),
courseCatalog: getDefaultCourseCatalog(),
couponPoints: { ...memberCenterMock.couponPoints },
referral: { ...memberCenterMock.referral }
couponPoints: {},
referral: {},
storedCard: {
balance: 0,
zeroVerified: false
}
})
}
export { getDefaultStore }
function mergeDefaults(saved) {
const defaults = getDefaultStore()
return finalizeStore({
profile: { ...defaults.profile, ...(saved.profile || {}) },
memberProfile: { ...defaults.memberProfile, ...(saved.memberProfile || {}) },
stats: { ...defaults.stats, ...(saved.stats || {}) },
cardInfo: { ...defaults.cardInfo, ...(saved.cardInfo || {}) },
card: { ...defaults.card, ...(saved.card || {}) },
cards: saved.cards?.length ? saved.cards : defaults.cards,
records: saved.records?.length ? saved.records : defaults.records,
ongoingBookings: saved.ongoingBookings ?? defaults.ongoingBookings,
historyBookings: saved.historyBookings ?? defaults.historyBookings,
@@ -105,7 +116,8 @@ function mergeDefaults(saved) {
modules: mergeModuleState(saved.modules),
courseCatalog: mergeCourseCatalog(saved.courseCatalog),
couponPoints: { ...defaults.couponPoints, ...(saved.couponPoints || {}) },
referral: { ...defaults.referral, ...(saved.referral || {}) }
referral: { ...defaults.referral, ...(saved.referral || {}) },
storedCard: { ...defaults.storedCard, ...(saved.storedCard || {}) }
})
}
@@ -201,17 +213,33 @@ export function getBookingPreview(store, limit = 2) {
return store.ongoingBookings.slice(0, limit).map(toBookingPreviewItem)
}
export function getExpiringCards(store, daysThreshold = 30) {
if (!store.cards || store.cards.length === 0) {
return []
}
return store.cards.filter(card => {
const remainingDays = computeRemainingDays(card.validityEnd || card.expireTime)
return remainingDays > 0 && remainingDays <= daysThreshold
}).sort((a, b) => {
const daysA = computeRemainingDays(a.validityEnd || a.expireTime)
const daysB = computeRemainingDays(b.validityEnd || b.expireTime)
return daysA - daysB
})
}
export function getCenterPageData(store) {
const expiringCards = getExpiringCards(store, 30)
return {
userInfo: { ...store.memberProfile },
stats: { ...store.stats },
stats: {
checkInCount: store.stats?.checkInCount ?? 0,
courseCount: store.stats?.courseCount ?? 0,
pointsBalance: store.stats?.pointsBalance ?? 0
},
cardInfo: { ...store.cardInfo },
expiringCards: expiringCards,
bookingPreview: getBookingPreview(store),
checkIns: store.checkIns.map((item) => ({ ...item })),
bodyReport: {
...store.bodyReport,
weight: store.profile.weight || store.bodyReport.weight
},
bodyReport: { ...store.bodyReport },
couponPoints: {
...store.couponPoints,
points: store.stats.pointsBalance
@@ -234,6 +262,15 @@ export function cancelOngoingBooking(store, id) {
const course = store.courseCatalog.find((c) => c.id === removed.courseId)
if (course && course.enrolled > 0) course.enrolled -= 1
}
// 同步从 myCourses.group.ongoing 中移除
if (store.modules?.myCourses?.group?.ongoing) {
const myCoursesIndex = store.modules.myCourses.group.ongoing.findIndex((c) => c.id === id)
if (myCoursesIndex >= 0) {
store.modules.myCourses.group.ongoing.splice(myCoursesIndex, 1)
}
}
store.historyBookings.unshift({
...removed,
status: 'cancelled',
@@ -285,16 +322,9 @@ export function renewMemberCard(store, addDays = 90) {
}
export function saveUserProfile(store, profile) {
const phone = normalizePhoneForStore(profile.phone ?? store.profile.phone)
store.profile = { ...store.profile, ...profile, phone }
store.memberProfile = {
...store.memberProfile,
name: store.profile.name,
phone: formatMemberCenterPhone(store.profile.phone),
avatar: store.profile.avatar || store.memberProfile.avatar
}
if (store.profile.weight) {
store.bodyReport.weight = store.profile.weight
// 不再保存profile到store,用户信息从loginMemberInfo获取
if (profile.weight) {
store.bodyReport.weight = profile.weight
}
finalizeStore(store)
saveMemberStore(store)
@@ -306,3 +336,17 @@ export function persistMemberStore(store) {
saveMemberStore(store)
return store
}
export function getStoredCardBalance() {
console.log('[storedCard] 余额不使用缓存,每次从后端读取')
return null
}
export function saveStoredCardBalance(balance) {
console.log('[storedCard] 余额不使用缓存,跳过保存')
return Number(balance) || 0
}
export function clearStoredCardCache() {
console.log('[storedCard] 余额不使用缓存,无需清除')
}
@@ -1,4 +1,4 @@
/** 个人信息页前端校验(与后端手机号规则对齐:^1[3-9]\\d{9}$ */
/** 个人信息页前端校验(与后端手机号规则对齐:^1[3-9]\\d{9}$ */
const PHONE_REG = /^1[3-9]\d{9}$/
const MIN_NAME_LEN = 2
+302
View File
@@ -0,0 +1,302 @@
/**
* 路由权限拦截器
* 自动拦截配置好的路径,未登录时自动弹出登录弹窗
*
* 使用方式:在 App.vue 中 import 并调用 init() 即可
* 无需在每个页面单独配置
*/
import { getToken } from '@/utils/request.js'
/**
* 白名单 - 不需要登录即可访问的页面
*/
const WHITE_LIST = [
'/pages/index/index',
'/pages/course/course',
'/pages/memberInfo/login',
'/pages/memberInfo/register',
'/pages/memberInfo/forgetPassword',
'/pages/about/about',
'/pages/help/help',
'/pages/notice/notice',
'/pages/product/product',
'/pages/store/store',
'/pages/article/article',
'/pages/search/search'
]
/**
* 需要登录的页面路径前缀
* 匹配以下前缀的页面都需要登录
*/
const LOGIN_REQUIRED_PREFIXES = [
'/pages/memberInfo/checkIn',
'/pages/memberInfo/booking',
'/pages/memberInfo/coupons',
'/pages/memberInfo/points',
'/pages/memberInfo/userInfo',
'/pages/memberInfo/order',
'/pages/memberInfo/settings',
'/pages/memberInfo/feedback',
'/pages/memberInfo/invite',
'/pages/memberInfo/recharge'
]
/**
* 需要登录的具体页面路径
*/
const LOGIN_REQUIRED_PATHS = [
'/pages/memberInfo/memberInfo'
]
/**
* 检查当前路径是否需要登录
* @param {String} url - 页面路径(可能包含query参数)
* @returns {Boolean}
*/
function isNeedLogin(url) {
// 去掉query参数
const path = url.split('?')[0]
// 检查白名单
if (WHITE_LIST.includes(path)) {
return false
}
// 检查具体路径
if (LOGIN_REQUIRED_PATHS.includes(path)) {
return true
}
// 检查路径前缀
if (LOGIN_REQUIRED_PREFIXES.some(prefix => path.startsWith(prefix))) {
return true
}
return false
}
/**
* 检查用户是否已登录
* @returns {Boolean}
*/
function isLoggedIn() {
const token = getToken()
const isLoginStorage = uni.getStorageSync('isLogin')
return !!(token || isLoginStorage)
}
/**
* 登录弹窗组件引用(需要在App.vue中注册)
*/
let loginModalRef = null
/**
* 注册登录弹窗组件
* @param {Object} modalRef - LoginModal组件的ref
*/
export function registerLoginModal(modalRef) {
loginModalRef = modalRef
}
/**
* 显示登录弹窗
*/
function showLoginModal() {
if (loginModalRef) {
loginModalRef.show()
} else {
// 如果没有注册弹窗,跳转到登录页
uni.navigateTo({
url: '/pages/memberInfo/login'
})
}
}
/**
* 记录用户原本要访问的页面
* @param {String} url - 页面路径
*/
function saveRedirectUrl(url) {
uni.setStorageSync('redirectUrl', url)
}
/**
* 获取用户原本要访问的页面
* @returns {String|null}
*/
export function getRedirectUrl() {
const url = uni.getStorageSync('redirectUrl')
uni.removeStorageSync('redirectUrl')
return url
}
/**
* 跳转到用户原本要访问的页面
*/
export function redirectToSavedUrl() {
const url = getRedirectUrl()
if (url) {
uni.navigateTo({
url: url,
fail: () => {
uni.switchTab({
url: '/pages/index/index'
})
}
})
}
}
/**
* 初始化路由拦截器
*/
export function initPermissionInterceptor() {
console.log('[PermissionInterceptor] 初始化路由拦截器')
// 拦截 navigateTo
uni.addInterceptor('navigateTo', {
invoke(args) {
const url = args.url
if (isNeedLogin(url) && !isLoggedIn()) {
console.log('[PermissionInterceptor] 拦截 navigateTo:', url)
saveRedirectUrl(url)
showLoginModal()
return false
}
return true
},
success() {
// 跳转成功
},
fail(err) {
console.error('[PermissionInterceptor] navigateTo 失败:', err)
}
})
// 拦截 redirectTo
uni.addInterceptor('redirectTo', {
invoke(args) {
const url = args.url
if (isNeedLogin(url) && !isLoggedIn()) {
console.log('[PermissionInterceptor] 拦截 redirectTo:', url)
saveRedirectUrl(url)
showLoginModal()
return false
}
return true
},
success() {},
fail(err) {
console.error('[PermissionInterceptor] redirectTo 失败:', err)
}
})
// 拦截 reLaunch
uni.addInterceptor('reLaunch', {
invoke(args) {
const url = args.url
if (isNeedLogin(url) && !isLoggedIn()) {
console.log('[PermissionInterceptor] 拦截 reLaunch:', url)
saveRedirectUrl(url)
showLoginModal()
return false
}
return true
},
success() {},
fail(err) {
console.error('[PermissionInterceptor] reLaunch 失败:', err)
}
})
// 拦截 switchTab
uni.addInterceptor('switchTab', {
invoke(args) {
const url = args.url
if (isNeedLogin(url) && !isLoggedIn()) {
console.log('[PermissionInterceptor] 拦截 switchTab:', url)
saveRedirectUrl(url)
showLoginModal()
return false
}
return true
},
success() {},
fail(err) {
console.error('[PermissionInterceptor] switchTab 失败:', err)
}
})
// 拦截 navigateBack
uni.addInterceptor('navigateBack', {
invoke() {
return true
},
success() {},
fail() {}
})
console.log('[PermissionInterceptor] 路由拦截器初始化完成')
}
/**
* 添加需要登录的页面路径
* @param {String|Array} paths - 页面路径或路径数组
*/
export function addLoginRequiredPaths(paths) {
if (Array.isArray(paths)) {
LOGIN_REQUIRED_PATHS.push(...paths)
} else {
LOGIN_REQUIRED_PATHS.push(paths)
}
}
/**
* 添加需要登录的页面路径前缀
* @param {String|Array} prefixes - 路径前缀或前缀数组
*/
export function addLoginRequiredPrefixes(prefixes) {
if (Array.isArray(prefixes)) {
LOGIN_REQUIRED_PREFIXES.push(...prefixes)
} else {
LOGIN_REQUIRED_PREFIXES.push(prefixes)
}
}
/**
* 添加到白名单
* @param {String|Array} paths - 页面路径或路径数组
*/
export function addWhiteList(paths) {
if (Array.isArray(paths)) {
WHITE_LIST.push(...paths)
} else {
WHITE_LIST.push(paths)
}
}
/**
* 获取当前配置的白名单
* @returns {Array}
*/
export function getWhiteList() {
return [...WHITE_LIST]
}
/**
* 获取当前配置的需要登录的路径
* @returns {Array}
*/
export function getLoginRequiredPaths() {
return [...LOGIN_REQUIRED_PATHS]
}
/**
* 获取当前配置的需要登录的路径前缀
* @returns {Array}
*/
export function getLoginRequiredPrefixes() {
return [...LOGIN_REQUIRED_PREFIXES]
}
+18 -13
View File
@@ -8,11 +8,12 @@
*/
:root {
/* ========== 主品牌色(清新浅蓝色系)========== */
--primary-dark: #0B2B4B; /* 深蓝主色 - 用于重要文字、品牌标识,体现专业信赖感 */
--primary-deep: #1A4A6F; /* 中深蓝色 - 用于hover状态、次级按钮、图标点缀,增加层次感 */
/* ========== 主品牌色(首页主题蓝绿色系)========== */
--primary-dark: #2D4A5A; /* 深蓝绿主色 - 用于重要文字、品牌标识,体现专业信赖感 */
--primary-deep: #7AB5CC; /* 蓝绿主色 - 用于按钮、图标、标签背景,首页核心主题色 */
--primary-light: #9CCFDF; /* 浅蓝绿 - 用于hover状态、渐变辅助,增加层次感 */
/* 主页主题蓝渐变色系 */
/* 主页主题蓝绿渐变色系 */
--primary-sky-100: #D6EEF8; /* 最浅蓝 - 渐变起始色,清新自然 */
--primary-sky-200: #E4F2FA; /* 浅蓝 - 渐变第二层 */
--primary-sky-300: #EEF6FB; /* 淡蓝 - 渐变第三层 */
@@ -24,10 +25,13 @@
--glow-blue-2: rgba(180, 220, 240, 0.3); /* 浅蓝色光晕 */
--glow-blue-3: rgba(170, 215, 238, 0.25); /* 浅蓝绿色光晕 */
/* ========== 强调/行动色(活力========== */
--accent-orange: #FF6B35; /* 活力 - 主要CTA按钮、会员标识、高亮徽章、关键数据,刺激行动 */
--accent-orange-light: #FF8C5A; /* 浅橙色 - hover轻量背景、渐变辅助,带来温暖运动感 */
--accent-orange-dark: #E55A2B; /* 深橙色 - 按压状态或重要警告,保持色彩体系完整 */
/* ========== 强调/行动色(活力绿========== */
--accent-green: rgba(130, 220, 130, 0.9); /* 活力绿 - 主要CTA按钮、图标背景,首页核心行动 */
--accent-green-light: rgba(150, 230, 150, 0.8); /* 浅绿 - hover轻量背景 */
--accent-green-dark: rgba(100, 200, 100, 1); /* 深绿 - 按压状态 */
--accent-orange: #FF6B35; /* 活力橙 - 辅助CTA按钮、会员标识、高亮徽章、关键数据 */
--accent-orange-light: #FF8C5A; /* 浅橙色 - hover轻量背景、渐变辅助 */
--accent-orange-dark: #E55A2B; /* 深橙色 - 按压状态或重要警告 */
/* ========== 背景色系(主页主题)========== */
--bg-gradient-primary: linear-gradient(180deg, #D6EEF8 0%, #E4F2FA 15%, #EEF6FB 30%, #F5FAFD 50%, #FAFCFE 70%, #FFFFFF 100%); /* 主页主渐变背景 */
@@ -52,19 +56,20 @@
--info-blue: #3498DB; /* 信息蓝 - 提示气泡、帮助文字 */
/* ========== 渐变色 (提升活力感) ========== */
--gradient-orange: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%); /* 色渐变 - 会员按钮、重要徽章 */
--gradient-blue: linear-gradient(135deg, #0B2B4B 0%, #1A4A6F 100%); /* 深蓝渐变 - 头部banner或特别卡片 */
--gradient-green: linear-gradient(135deg, rgba(130, 220, 130, 0.9) 0%, rgba(150, 230, 150, 0.8) 100%); /* 绿色渐变 - 主要CTA按钮、图标背景 */
--gradient-orange: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%); /* 橙色渐变 - 辅助按钮、重要徽章 */
--gradient-blue: linear-gradient(135deg, #7AB5CC 0%, #9CCFDF 100%); /* 蓝绿渐变 - 头部banner或特别卡片,首页核心渐变 */
--gradient-sky: linear-gradient(180deg, #D6EEF8 0%, #E4F2FA 15%, #EEF6FB 30%, #F5FAFD 50%, #FAFCFE 70%, #FFFFFF 100%); /* 主页天空渐变 - 全局背景 */
--gradient-subtle: linear-gradient(120deg, #F9FAFE 0%, #FFFFFF 100%); /* 微弱渐变 - 增加细节精致度 */
/* ========== TabBar 配色(清新蓝风格)========== */
/* ========== TabBar 配色(清新蓝绿风格)========== */
/* 引用位置:components/TabBar.vue */
--tabbar-bg: rgba(200, 225, 238, 0.8); /* TabBar背景色 - 半透明浅蓝色毛玻璃效果 */
--tabbar-shadow: rgba(120, 185, 215, 0.2); /* TabBar阴影色 - 蓝色系柔和阴影 */
--tabbar-icon-inactive: gray; /* 未选中图标颜色 - 灰色 */
--tabbar-icon-active: #5A98B0; /* 选中图标颜色 - 蓝绿色 */
--tabbar-icon-active: #7AB5CC; /* 选中图标颜色 - 蓝绿色(首页主题色) */
--tabbar-text-inactive: #8AABBB; /* 未选中文字颜色 - 浅灰蓝 */
--tabbar-text-active: #5A98B0; /* 选中文字颜色 - 蓝绿色(与图标一致) */
--tabbar-text-active: #7AB5CC; /* 选中文字颜色 - 蓝绿色(与图标一致,首页主题色 */
/* ========== 通用蓝色系阴影(用于卡片、按钮等)========== */
/* 引用位置:components/index/RecommendCourses.vue, QuickEntry.vue, TodayRecommend.vue */
@@ -269,3 +269,63 @@
flex-shrink: 0;
background-color: rgba(255, 255, 255, 0.31);
}
.profile-header__user-inner--guest {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
width: 100%;
}
.profile-header__avatar-wrap--guest {
width: 72px;
height: 72px;
flex-shrink: 0;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.5);
overflow: hidden;
background-color: rgba(255, 255, 255, 0.9);
}
.profile-header__avatar--guest {
width: 100%;
height: 100%;
display: block;
}
.profile-header__user-meta--guest {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.profile-header__name--guest {
font-size: 18px;
font-weight: 600;
color: black;
white-space: nowrap;
}
.profile-header__phone--guest {
font-size: 14px;
font-weight: 400;
color: #999999;
white-space: nowrap;
}
.profile-header__user-inner--guest {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
width: 100%;
margin: 0;
padding: 0;
background: none;
border: none;
outline: none;
box-shadow: none;
line-height: inherit;
}
@@ -360,3 +360,84 @@
flex-grow: 1;
flex-basis: 0;
}
.member-card-section__count {
display: inline-flex;
flex-direction: row;
align-items: center;
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: 26rpx;
font-weight: 700;
color: #333;
line-height: 1;
}
.member-card-section__count-text {
font-size: 22rpx;
font-weight: 400;
color: #333;
line-height: 1;
}
.member-card-preview__tag--expiring {
background-color: #FFE4E4;
}
.member-card-preview__tag--expiring .member-card-preview__tag-text {
color: #E74C3C;
}
.member-card-preview__tag--expired {
background-color: rgba(0, 0, 0, 0.2);
}
.member-card-preview__tag--expired .member-card-preview__tag-text {
color: rgba(255, 255, 255, 0.7);
}
.member-card-preview__tag--active {
background-color: rgba(255, 255, 255, 0.19);
}
.member-card-preview__tag--active .member-card-preview__tag-text {
color: var(--text-inverse);
}
.member-card-preview__days-num--expiring {
color: #FFE4E4;
}
.member-card-preview__days-num--expired {
color: #FF6B6B;
}
.member-card-preview__purchase {
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 20px;
border-radius: 20px;
background-color: var(--bg-white);
}
.member-card-preview__purchase-text {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 600;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
@@ -239,6 +239,29 @@
text-overflow: ellipsis;
}
.bk-card__actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.bk-card__signin {
flex-shrink: 0;
padding: 6px 14px;
border-radius: 8px;
background-color: var(--accent-orange, #FF6B35);
box-sizing: border-box;
}
.bk-card__signin-text {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-inverse, #ffffff);
white-space: nowrap;
}
.bk-card__cancel {
flex-shrink: 0;
padding: 6px 14px;
@@ -1,4 +1,4 @@
<template>
<template>
<view class="qr-status">
<!-- 加载中状态 -->
<view v-if="status === 'loading'" class="status-loading">
@@ -1,4 +1,4 @@
<template>
<template>
<SkeletonBase>
<view class="skeleton-banner"></view>
@@ -0,0 +1,169 @@
<!-- components/Skeleton/ListSkeleton.vue -->
<template>
<SkeletonBase>
<!-- Tab区域 -->
<view v-if="showTabs" class="skeleton-tabs">
<view v-for="i in tabCount" :key="i" class="skeleton-tab skeleton-shimmer"></view>
</view>
<!-- 列表项 -->
<view class="skeleton-list" :style="{ padding: listPadding }">
<view v-for="i in count" :key="i" class="skeleton-item">
<!-- 卡片式布局默认 -->
<template v-if="layout === 'card'">
<view class="skeleton-card-item">
<view class="skeleton-card-icon skeleton-shimmer"></view>
<view class="skeleton-card-body">
<view class="skeleton-card-title skeleton-shimmer"></view>
<view class="skeleton-card-desc skeleton-shimmer"></view>
<view class="skeleton-card-footer">
<view class="skeleton-card-tag skeleton-shimmer"></view>
<view class="skeleton-card-time skeleton-shimmer"></view>
</view>
</view>
</view>
</template>
<!-- 简洁列表布局 -->
<template v-else-if="layout === 'simple'">
<view class="skeleton-simple-item">
<view class="skeleton-simple-title skeleton-shimmer"></view>
<view class="skeleton-simple-meta">
<view class="skeleton-simple-line skeleton-shimmer"></view>
<view class="skeleton-simple-line skeleton-shimmer short"></view>
</view>
</view>
</template>
</view>
</view>
</SkeletonBase>
</template>
<script setup>
import SkeletonBase from './SkeletonBase.vue'
defineProps({
count: { type: Number, default: 6 },
layout: { type: String, default: 'card' },
showTabs: { type: Boolean, default: false },
tabCount: { type: Number, default: 3 },
listPadding: { type: String, default: '0 24rpx' }
})
</script>
<style lang="scss" scoped>
.skeleton-tabs {
display: flex;
gap: 16rpx;
padding: 20rpx 24rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.skeleton-tab {
height: 60rpx;
width: 140rpx;
border-radius: 12rpx;
}
.skeleton-list {
display: flex;
flex-direction: column;
gap: 16rpx;
padding-top: 16rpx;
}
/* 卡片式 */
.skeleton-card-item {
display: flex;
align-items: center;
padding: 24rpx 20rpx;
background: #fff;
border-radius: 16rpx;
gap: 20rpx;
}
.skeleton-card-icon {
width: 80rpx;
height: 80rpx;
border-radius: 16rpx;
flex-shrink: 0;
}
.skeleton-card-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
min-width: 0;
}
.skeleton-card-title {
height: 32rpx;
width: 60%;
border-radius: 8rpx;
}
.skeleton-card-desc {
height: 28rpx;
width: 80%;
border-radius: 8rpx;
}
.skeleton-card-footer {
display: flex;
justify-content: space-between;
margin-top: 4rpx;
}
.skeleton-card-tag {
height: 24rpx;
width: 80rpx;
border-radius: 6rpx;
}
.skeleton-card-time {
height: 24rpx;
width: 120rpx;
border-radius: 6rpx;
}
/* 简洁式 */
.skeleton-simple-item {
padding: 28rpx 24rpx;
background: #fff;
border-radius: 16rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-simple-title {
height: 34rpx;
width: 55%;
border-radius: 8rpx;
}
.skeleton-simple-meta {
display: flex;
gap: 24rpx;
}
.skeleton-simple-line {
height: 26rpx;
width: 180rpx;
border-radius: 6rpx;
&.short {
width: 100rpx;
}
}
:deep(.skeleton-shimmer) {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
@@ -0,0 +1,179 @@
<!-- components/Skeleton/MemberCenterSkeleton.vue -->
<template>
<SkeletonBase>
<!-- 头像和个人信息 -->
<view class="skeleton-header">
<view class="skeleton-avatar skeleton-shimmer"></view>
<view class="skeleton-header-info">
<view class="skeleton-name skeleton-shimmer"></view>
<view class="skeleton-level skeleton-shimmer"></view>
</view>
</view>
<!-- 统计数据 -->
<view class="skeleton-stats">
<view v-for="i in 3" :key="i" class="skeleton-stat-item">
<view class="skeleton-stat-num skeleton-shimmer"></view>
<view class="skeleton-stat-label skeleton-shimmer"></view>
</view>
</view>
<!-- 会员卡 -->
<view class="skeleton-card">
<view class="skeleton-card-title skeleton-shimmer"></view>
<view class="skeleton-card-body">
<view class="skeleton-card-line skeleton-shimmer"></view>
<view class="skeleton-card-line skeleton-shimmer short"></view>
<view class="skeleton-card-btn skeleton-shimmer"></view>
</view>
</view>
<!-- 快捷入口 -->
<view class="skeleton-entry">
<view v-for="i in 4" :key="i" class="skeleton-entry-item">
<view class="skeleton-entry-icon skeleton-shimmer"></view>
<view class="skeleton-entry-label skeleton-shimmer"></view>
</view>
</view>
<!-- 底部占位 -->
<view class="skeleton-spacer"></view>
</SkeletonBase>
</template>
<script setup>
import SkeletonBase from './SkeletonBase.vue'
</script>
<style lang="scss" scoped>
.skeleton-header {
display: flex;
align-items: center;
padding: 40rpx 32rpx 24rpx;
}
.skeleton-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
flex-shrink: 0;
}
.skeleton-header-info {
margin-left: 24rpx;
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-name {
height: 36rpx;
width: 160rpx;
border-radius: 8rpx;
}
.skeleton-level {
height: 28rpx;
width: 120rpx;
border-radius: 8rpx;
}
.skeleton-stats {
display: flex;
justify-content: space-around;
padding: 24rpx 32rpx;
}
.skeleton-stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.skeleton-stat-num {
height: 40rpx;
width: 80rpx;
border-radius: 8rpx;
}
.skeleton-stat-label {
height: 24rpx;
width: 60rpx;
border-radius: 8rpx;
}
.skeleton-card {
margin: 0 24rpx 24rpx;
padding: 32rpx;
background: #fff;
border-radius: 20rpx;
}
.skeleton-card-title {
height: 32rpx;
width: 140rpx;
border-radius: 8rpx;
margin-bottom: 24rpx;
}
.skeleton-card-body {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-card-line {
height: 28rpx;
width: 70%;
border-radius: 8rpx;
&.short {
width: 45%;
}
}
.skeleton-card-btn {
height: 56rpx;
width: 160rpx;
border-radius: 12rpx;
margin-top: 8rpx;
}
.skeleton-entry {
display: flex;
justify-content: space-around;
padding: 24rpx 32rpx;
}
.skeleton-entry-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.skeleton-entry-icon {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
}
.skeleton-entry-label {
height: 24rpx;
width: 60rpx;
border-radius: 8rpx;
}
.skeleton-spacer {
height: 200rpx;
}
:deep(.skeleton-shimmer) {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
@@ -0,0 +1,89 @@
<template>
<view class="message-skeleton">
<view v-for="i in 6" :key="i" class="skeleton-item">
<view class="skeleton-icon skeleton-shimmer"></view>
<view class="skeleton-content">
<view class="skeleton-title skeleton-shimmer"></view>
<view class="skeleton-desc skeleton-shimmer"></view>
<view class="skeleton-footer">
<view class="skeleton-tag skeleton-shimmer"></view>
<view class="skeleton-time skeleton-shimmer"></view>
</view>
</view>
</view>
</view>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.message-skeleton {
padding: 8rpx 24rpx;
}
.skeleton-item {
display: flex;
align-items: center;
padding: 24rpx 0;
margin-bottom: 12rpx;
background: var(--bg-white);
border-radius: 16rpx;
}
.skeleton-icon {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
margin-right: 20rpx;
flex-shrink: 0;
}
.skeleton-content {
flex: 1;
min-width: 0;
}
.skeleton-title {
height: 32rpx;
width: 60%;
border-radius: 8rpx;
margin-bottom: 12rpx;
}
.skeleton-desc {
height: 28rpx;
width: 85%;
border-radius: 8rpx;
margin-bottom: 16rpx;
}
.skeleton-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.skeleton-tag {
height: 24rpx;
width: 80rpx;
border-radius: 8rpx;
}
.skeleton-time {
height: 24rpx;
width: 100rpx;
border-radius: 8rpx;
}
:deep(.skeleton-shimmer) {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
@@ -1,4 +1,4 @@
<template>
<template>
<view class="skeleton" :style="{ padding: padding }">
<slot />
</view>
+62 -50
View File
@@ -1,21 +1,23 @@
<!-- components/TabBar.vue -->
<template>
<view v-if="shouldShowTabBar" class="tab-bar">
<view
v-for="(tab, index) in tabs"
:key="tab.path"
:class="['tab-item', { active: currentActiveIndex === index }]"
hover-class="tab-item--hover"
@tap.stop="onTabTap(index)"
>
<!-- 判断是否使用字体图标我的页面用字体其他用图片 -->
<text
v-if="tab.useFontIcon"
:class="['iconfont', tab.icon]"
class="tab-icon-font"
:style="{ fontSize: tab.fontSize}"
></text>
<text class="tab-label">{{ tab.label }}</text>
<view v-if="shouldShowTabBar" class="tab-bar-wrapper">
<view class="tab-bar">
<view
v-for="(tab, index) in tabs"
:key="tab.path"
:class="['tab-item', { active: currentActiveIndex === index }]"
hover-class="tab-item--hover"
@tap.stop="onTabTap(index)"
>
<!-- 判断是否使用字体图标我的页面用字体其他用图片 -->
<text
v-if="tab.useFontIcon"
:class="['iconfont', tab.icon]"
class="tab-icon-font"
:style="{ fontSize: tab.fontSize}"
></text>
<text class="tab-label">{{ tab.label }}</text>
</view>
</view>
</view>
</template>
@@ -46,7 +48,6 @@ const HIDE_TABBAR_PAGES = [
'pages/memberInfo/bodyTestReport',
'pages/groupCourse/list',
'pages/groupCourse/detail',
'pages/searchCourse/searchCourse',
'pages/checkIn/checkIn',
'pages/memberInfo/myCourses',
'pages/memberInfo/coupons',
@@ -54,13 +55,11 @@ const HIDE_TABBAR_PAGES = [
'pages/memberInfo/pointsMall',
'pages/memberInfo/referral',
'pages/memberInfo/userInfo',
'pages/memberInfo/memberCard',
]
function getActiveIndexFromRoute() {
const routePath = getCurrentRoutePath()
const index = getTabIndexByRoute(routePath)
console.log('从路由获取索引:', routePath, '->', index)
return index >= 0 ? index : 0
}
@@ -89,24 +88,26 @@ function checkShouldShow() {
}
const shouldHide = HIDE_TABBAR_PAGES.includes(routePath)
shouldShowTabBar.value = !shouldHide
console.log('=== TabBar 显示控制 ===')
console.log('原始路径:', getCurrentRoutePath())
console.log('标准化路径:', routePath)
console.log('是否隐藏:', shouldHide)
console.log('是否显示 TabBar:', shouldShowTabBar.value)
}
let routeWatcher = null
let appRouteCallback = null
let isNavigating = false
onMounted(() => {
syncActiveState()
checkShouldShow()
// #ifdef APP-PLUS
// #ifndef MP-WEIXIN
// H5和其他平台使用轮询监听路由变化
routeWatcher = setInterval(() => {
syncActiveState()
// 导航期间不更新状态,避免覆盖用户点击的索引
if (isNavigating) return
const newIndex = getActiveIndexFromRoute()
if (newIndex !== currentActiveIndex.value) {
currentActiveIndex.value = newIndex
}
checkShouldShow()
}, 300)
}, 200)
// #endif
// #ifdef MP-WEIXIN
if (typeof uni.onAppRoute === 'function') {
@@ -122,12 +123,17 @@ onMounted(() => {
})
onBeforeUnmount(() => {
// #ifdef APP-PLUS
if (routeWatcher) { clearInterval(routeWatcher) }
// #ifndef MP-WEIXIN
// H5和其他平台清理定时器
if (routeWatcher) {
clearInterval(routeWatcher)
routeWatcher = null
}
// #endif
// #ifdef MP-WEIXIN
if (appRouteCallback && typeof uni.offAppRoute === 'function') {
uni.offAppRoute(appRouteCallback)
appRouteCallback = null
}
// #endif
})
@@ -153,20 +159,6 @@ const tabs = [
useFontIcon: true,
fontSize:"36rpx"
},
{
path: PAGE.TRAIN,
icon: 'icon-train',
label: '训练',
useFontIcon: true,
fontSize:"48rpx"
},
{
path: PAGE.DISCOVER,
icon: 'icon-discover',
label: '发现',
useFontIcon: true,
fontSize:"48rpx"
},
{
path: PAGE.MEMBER,
icon: 'icon-profile',
@@ -184,12 +176,13 @@ function onTabTap(index) {
const currentPath = TAB_ROUTES[currentActiveIndex.value]
if (targetPath === currentPath) return
console.log('Tab 点击:', index, targetPath)
// 立即更新状态
currentActiveIndex.value = index
emit('update:active', index)
emit('tab-change', index)
let timer = setTimeout(() => {
uni.showLoading({ title: '加载中...', mask: true })
}, 50)
// 设置导航标志,阻止轮询覆盖状态
isNavigating = true
uni.showLoading({ title: '加载中...', mask: true })
isSwitching = true
uni.switchTab({
url: targetPath,
@@ -199,11 +192,13 @@ function onTabTap(index) {
uni.reLaunch({ url: targetPath })
},
complete: () => {
clearTimeout(timer)
uni.hideLoading()
setTimeout(() => {
isSwitching = false
isNavigating = false
// #ifdef MP-WEIXIN
syncActiveState()
// #endif
checkShouldShow()
}, 100)
}
@@ -215,11 +210,21 @@ function onTabTap(index) {
// 引入字体图标 CSS(定义 @font-face
@import '/common/style/tabbar_icon/tabbar.css';
.tab-bar {
// 固定容器 - 确保TabBar始终在屏幕底部
.tab-bar-wrapper {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9999;
pointer-events: none;
}
.tab-bar-wrapper .tab-bar {
pointer-events: auto;
}
.tab-bar {
height: 120rpx;
background: white;
backdrop-filter: blur(24px);
@@ -231,7 +236,14 @@ function onTabTap(index) {
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4rpx 24rpx var(--tabbar-shadow);
border-radius: 32rpx 32rpx 0 0;
z-index: 999;
/* 防闪烁优化 */
transform: translateZ(0);
-webkit-transform: translateZ(0);
will-change: transform;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
perspective: 1000;
-webkit-perspective: 1000;
}
.tab-item {
@@ -277,4 +289,4 @@ function onTabTap(index) {
color: rgba(130, 220, 130, 0.9);
font-weight: 600;
}
</style>
</style>
@@ -1,4 +1,4 @@
<!-- components/GlobalLoading.vue -->
<!-- components/GlobalLoading.vue -->
<template>
<view v-if="visible" class="global-loading">
<view class="loading-mask"></view>
@@ -0,0 +1,233 @@
/**
* 登录弹窗组件使用示例
*
* 该组件提供了统一的登录拦截功能,可以在任何页面使用
*/
## 方式一:在页面中直接使用LoginModal组件
```vue
<template>
<view>
<!-- 其他内容 -->
<!-- 引入登录弹窗 -->
<LoginModal
ref="loginModalRef"
v-model="showLoginModal"
title="登录后享受更多服务"
subtitle="登录即表示同意相关协议"
:show-close="true"
:mask-closable="true"
@loginSuccess="onLoginSuccess"
@loginError="onLoginError"
/>
</view>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { registerLoginModal } from '@/common/hooks/useLoginGuard.js'
import LoginModal from '@/components/global/LoginModal.vue'
const showLoginModal = ref(false)
const loginModalRef = ref(null)
// 注册到全局
onMounted(() => {
nextTick(() => {
if (loginModalRef.value) {
registerLoginModal(loginModalRef.value)
}
})
})
// 显示登录弹窗
function showLogin() {
showLoginModal.value = true
}
// 登录成功回调
function onLoginSuccess(result) {
console.log('登录成功', result)
// 刷新页面数据等
}
// 登录失败回调
function onLoginError(error) {
console.log('登录失败', error)
}
</script>
```
## 方式二:使用 useLoginGuard Hook
```vue
<script setup>
import { useLoginGuard } from '@/common/hooks/useLoginGuard.js'
const { requireLogin, checkLogin } = useLoginGuard()
// 示例1:需要登录才能执行的操作
async function handleBookCourse() {
// 检查是否已登录
if (!checkLogin()) {
return
}
// 执行预约操作...
}
// 示例2:弹窗式登录(推荐)
async function handlePurchase() {
const loggedIn = await requireLogin({
title: '请先登录',
subtitle: '登录后即可购买会员卡',
onSuccess: (user) => {
console.log('登录成功,用户信息:', user)
}
})
if (loggedIn) {
// 执行购买操作
await purchaseCard()
}
}
// 示例3:安全执行函数
async function handleCheckIn() {
const result = await requireLogin().safeExecute(async () => {
// 只有登录后才能执行这里的代码
return await checkInApi()
})
if (result) {
uni.showToast({ title: '签到成功' })
}
}
// 示例4:包装API调用
async function handleGetCoupons() {
const { callLoggedInApi } = useLoginGuard()
try {
const coupons = await callLoggedInApi(getCouponsApi, [], {
title: '领取优惠券'
})
console.log('优惠券列表:', coupons)
} catch (e) {
if (e.message === '用户未登录') {
// 用户未登录,弹窗已显示
}
}
}
</script>
```
## 方式三:在组件中使用登录守卫Mixin
```vue
<script>
import { loginGuardMixin } from '@/common/hooks/useLoginGuard.js'
export default {
mixins: [loginGuardMixin()],
methods: {
async onQuickAction(type) {
// 自动检查登录状态
if (!await this.ensureLogin()) {
return
}
// 已登录,执行操作
if (type === 'book') {
this.goToBooking()
}
}
}
}
</script>
```
## 登录弹窗组件Props说明
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| modelValue | Boolean | false | 控制显示/隐藏 |
| title | String | '登录后享受更多服务' | 弹窗标题 |
| subtitle | String | '登录即表示同意相关协议' | 弹窗副标题 |
| showClose | Boolean | true | 是否显示关闭按钮 |
| modalStyle | String | 'center' | 弹窗样式:'center'居中,'bottom'底部 |
| maskClosable | Boolean | true | 点击遮罩是否关闭 |
| onLoginSuccess | Function | null | 登录成功回调 |
| onLoginError | Function | null | 登录失败回调 |
## 登录弹窗组件Events
| 事件名 | 说明 | 回调参数 |
|--------|------|----------|
| update:modelValue | 显示状态变化 | Boolean |
| loginSuccess | 登录成功 | Object: 登录结果 |
| loginError | 登录失败 | Error: 错误对象 |
| close | 弹窗关闭 | - |
## 登录弹窗组件暴露的方法
| 方法名 | 说明 | 参数 |
|--------|------|------|
| show() | 显示弹窗 | - |
| hide() | 隐藏弹窗 | - |
| switchCard(card) | 切换登录方式 | 'phone'一键登录 / 'sms'验证码登录 |
## 全局登录拦截示例
`App.vue` 中注册全局登录弹窗:
```vue
<script>
import LoginModal from '@/components/global/LoginModal.vue'
import { registerLoginModal } from '@/common/hooks/useLoginGuard.js'
export default {
components: { LoginModal },
onLaunch() {
// 注册全局登录弹窗
this.$nextTick(() => {
if (this.$refs.globalLoginModal) {
registerLoginModal(this.$refs.globalLoginModal)
}
})
}
}
</script>
<template>
<view id="app">
<router-view />
<LoginModal ref="globalLoginModal" />
</view>
</template>
```
## API路径配置
登录弹窗组件使用的API路径定义在 `@/api/main.js`
```javascript
// 一键登录
export function phoneLogin(data) {
return request.post('/auth/phone/login', data)
}
// 短信验证码登录
export function smsLogin(data) {
return request.post('/auth/phone/sms/login', data)
}
// 发送验证码
export function sendSmsCode(phone) {
return request.post('/auth/phone/sms/send', { phone })
}
```
如需修改API路径,请编辑 `main.js` 文件中的相应函数。
@@ -0,0 +1,691 @@
<template>
<!-- 登录弹窗组件 -->
<view class="login-modal-mask" v-if="visible" @tap="handleMaskTap">
<view class="login-modal" :class="{ 'login-modal--center': modalStyle === 'center' }" @tap.stop>
<!-- 关闭按钮 -->
<view class="login-modal__close" v-if="showClose" @tap="handleClose">
<text class="icon-close">×</text>
</view>
<!-- 弹窗内容 -->
<view class="login-modal__content">
<!-- 头部 -->
<view class="login-modal__header">
<view class="logo-wrapper">
<image class="logo-image" src="/static/logo.png" mode="aspectFit" />
<text class="app-name">活氧舱</text>
</view>
<text class="modal-title">{{ title }}</text>
<text class="modal-subtitle">{{ subtitle }}</text>
</view>
<!-- 一键登录卡片 -->
<view class="login-card" :class="{ active: activeCard === 'phone' }">
<view class="card-body">
<text class="card-title">本机号码登录</text>
<text class="auth-tip">认证服务由中国移动提供</text>
<button class="login-btn primary" :class="{ disabled: isLoading, loading: isLoading }"
@tap="handlePhoneLogin" :disabled="isLoading">
<text>{{ isLoading ? '登录中...' : '一键登录' }}</text>
</button>
<button class="login-btn switch-btn" @tap="switchToSms">
<text>短信验证码登录</text>
</button>
</view>
</view>
<!-- 短信验证码登录卡片 -->
<view class="login-card sms-card" :class="{ active: activeCard === 'sms' }">
<view class="card-body">
<text class="card-title">验证码登录</text>
<text class="auth-tip">认证服务由中国移动提供</text>
<view class="form-group">
<view class="input-wrapper" :class="{ focused: isPhoneFocused }">
<input v-model="smsPhone" type="number" placeholder="请输入手机号" maxlength="11"
class="phone-input" confirm-type="next" @focus="isPhoneFocused = true"
@blur="isPhoneFocused = false" />
</view>
</view>
<view class="form-group">
<view class="input-wrapper code-input" :class="{ focused: isCodeFocused }">
<input v-model="smsCode" type="number" placeholder="请输入验证码" maxlength="6"
class="phone-input" confirm-type="done" @focus="isCodeFocused = true"
@blur="isCodeFocused = false" />
<button class="send-code-btn" :class="{ disabled: countdown > 0 || isLoading }"
@tap="handleSendCode" :disabled="countdown > 0 || isLoading">
<text>{{ countdown > 0 ? `${countdown}秒后重发` : '发送验证码' }}</text>
</button>
</view>
</view>
<button class="login-btn primary" :class="{ disabled: !canSmsSubmit, loading: isLoading }"
@tap="handleSmsLogin" :disabled="!canSmsSubmit || isLoading">
<text>{{ isLoading ? '登录中...' : '登录' }}</text>
</button>
<button class="login-btn switch-btn" @tap="switchToPhone">
<text>一键登录</text>
</button>
</view>
</view>
<!-- 协议 -->
<view class="agreement-text">
<checkbox-group @change="onAgreementChange">
<checkbox :checked="agreed" value="agreed" color="#FF8C42" />
</checkbox-group>
<text>登录即表示同意</text>
<text class="link" @tap="showAgreement">用户协议</text>
<text></text>
<text class="link" @tap="showPrivacy">隐私政策</text>
</view>
</view>
</view>
<!-- Toast提示 -->
<view class="custom-toast" :class="{ show: toastShow }">
<text class="toast-text">{{ toastMessage }}</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { oneClickLogin, loginWithPhone, sendCode } from '@/api/main.js'
// Props定义
const props = defineProps({
// 是否显示弹窗
modelValue: {
type: Boolean,
default: false
},
// 弹窗标题
title: {
type: String,
default: '登录后享受更多服务'
},
// 弹窗副标题
subtitle: {
type: String,
default: '登录即表示同意相关协议'
},
// 是否显示关闭按钮
showClose: {
type: Boolean,
default: true
},
// 弹窗样式: 'center'居中, 'bottom'底部
modalStyle: {
type: String,
default: 'center'
},
// 是否点击遮罩层关闭
maskClosable: {
type: Boolean,
default: true
},
// 登录成功回调
onLoginSuccess: {
type: Function,
default: null
},
// 登录失败回调
onLoginError: {
type: Function,
default: null
}
})
// Emits定义
const emit = defineEmits(['update:modelValue', 'loginSuccess', 'loginError', 'close'])
// 响应式状态
const visible = ref(false)
const activeCard = ref('phone') // 'phone'一键登录, 'sms'验证码登录
const isLoading = ref(false)
const isPhoneFocused = ref(false)
const isCodeFocused = ref(false)
const smsPhone = ref('')
const smsCode = ref('')
const countdown = ref(0)
const agreed = ref(false)
const toastShow = ref(false)
const toastMessage = ref('')
// 计算属性
const canSmsSubmit = computed(() => {
return smsPhone.value.length === 11 && smsCode.value.length === 6 && agreed.value
})
// 监听modelValue变化
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
if (newVal) {
// 打开弹窗时重置状态
resetState()
}
}, { immediate: true })
// 重置状态
function resetState() {
activeCard.value = 'phone'
isLoading.value = false
smsPhone.value = ''
smsCode.value = ''
countdown.value = 0
agreed.value = false
}
// 显示Toast
function showToast(message, duration = 2000) {
toastMessage.value = message
toastShow.value = true
setTimeout(() => {
toastShow.value = false
}, duration)
}
// 显示用户协议
function showAgreement() {
uni.showModal({
title: '用户协议',
content: '这里是用户协议内容...',
showCancel: false
})
}
// 显示隐私政策
function showPrivacy() {
uni.showModal({
title: '隐私政策',
content: '这里隐私政策内容...',
showCancel: false
})
}
// 切换到短信登录
function switchToSms() {
activeCard.value = 'sms'
}
// 切换到一键登录
function switchToPhone() {
activeCard.value = 'phone'
}
// 协议勾选变化
function onAgreementChange(e) {
agreed.value = e.detail.value.includes('agreed')
}
// 发送验证码
async function handleSendCode() {
if (countdown.value > 0 || isLoading.value) return
if (!smsPhone.value || smsPhone.value.length !== 11) {
showToast('请输入正确的手机号')
return
}
if (!agreed.value) {
showToast('请先同意用户协议')
return
}
isLoading.value = true
try {
await sendCode({ phone: smsPhone.value })
showToast('验证码已发送')
countdown.value = 60
const timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer)
}
}, 1000)
} catch (e) {
console.error('发送验证码失败:', e)
showToast(e.message || '发送验证码失败')
} finally {
isLoading.value = false
}
}
// 一键登录
async function handlePhoneLogin() {
if (isLoading.value) return
if (!agreed.value) {
showToast('请先同意用户协议')
return
}
isLoading.value = true
try {
// #ifdef APP-PLUS
// App环境下使用一键登录
await new Promise((resolve, reject) => {
uni.login({
provider: 'univerify',
univerifyStyle: {
fullScreen: false,
backgroundColor: '#ffffff',
icon: {
path: '/static/logo.png'
},
closeIcon: {
path: '/static/close.png'
},
phoneNum: {
color: '#333333',
fontSize: '18px'
},
slogan: {
color: '#999999',
fontSize: '12px'
},
authButton: {
normalColor: '#FF8C42',
highlightColor: '#FF7A2C',
disabledColor: '#CCCCCC',
textColor: '#FFFFFF',
title: '本机号码一键登录'
},
otherLoginButton: {
visible: false
},
protocols: []
},
success: async (res) => {
try {
// 调用后端API进行一键登录
const result = await oneClickLogin({
accessToken: res.authResult.access_token,
openid: res.authResult.openid
})
if (result.code === 200 || result.data) {
handleLoginSuccess(result)
resolve(result)
} else {
reject(new Error(result.message || '登录失败'))
}
} catch (e) {
reject(e)
}
},
fail: (err) => {
console.error('一键登录失败:', err)
reject(new Error(err.errMsg || '一键登录失败'))
}
})
})
// #endif
// #ifndef APP-PLUS
// 非App环境(小程序、H5等)提示使用其他方式
uni.showToast({
title: '请使用短信验证码登录',
icon: 'none'
})
activeCard.value = 'sms'
// #endif
} catch (e) {
console.error('一键登录失败:', e)
showToast(e.message || '一键登录失败')
if (props.onLoginError) {
props.onLoginError(e)
}
emit('loginError', e)
} finally {
isLoading.value = false
}
}
// 短信验证码登录
async function handleSmsLogin() {
if (!canSmsSubmit.value || isLoading.value) return
isLoading.value = true
try {
const result = await loginWithPhone({
phone: smsPhone.value,
code: smsCode.value
})
handleLoginSuccess(result)
} catch (e) {
console.error('短信登录失败:', e)
showToast(e.message || '登录失败')
if (props.onLoginError) {
props.onLoginError(e)
}
emit('loginError', e)
} finally {
isLoading.value = false
}
}
// 处理登录成功
function handleLoginSuccess(result) {
console.log('登录成功:', result)
// 保存token和用户信息到本地缓存
if (result.data?.token) {
uni.setStorageSync('token', result.data.token)
}
// 直接缓存登录响应数据
if (result.data) {
uni.setStorageSync('loginMemberInfo', result.data)
}
showToast('登录成功')
if (props.onLoginSuccess) {
props.onLoginSuccess(result)
}
emit('loginSuccess', result)
uni.$emit('loginModal:success', result)
setTimeout(() => {
handleClose()
}, 1500)
}
// 关闭弹窗
function handleClose() {
visible.value = false
emit('update:modelValue', false)
emit('close')
uni.$emit('loginModal:close')
}
// 点击遮罩层
function handleMaskTap() {
if (props.maskClosable) {
handleClose()
}
}
// 对外暴露的方法
defineExpose({
// 显示弹窗
show() {
visible.value = true
emit('update:modelValue', true)
},
// 隐藏弹窗
hide() {
handleClose()
},
// 切换登录方式
switchCard(card) {
activeCard.value = card
}
})
</script>
<style lang="scss" scoped>
.login-modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.login-modal {
position: relative;
width: 90%;
max-width: 650rpx;
background: #fff;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.15);
&--center {
display: flex;
flex-direction: column;
align-items: center;
}
}
.login-modal__close {
position: absolute;
top: 20rpx;
right: 20rpx;
width: 50rpx;
height: 50rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
.icon-close {
font-size: 48rpx;
color: #999;
line-height: 1;
}
}
.login-modal__content {
width: 100%;
padding: 40rpx 50rpx 50rpx;
}
.login-modal__header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 40rpx;
}
.logo-wrapper {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 20rpx;
.logo-image {
width: 80rpx;
height: 80rpx;
border-radius: 16rpx;
}
.app-name {
font-size: 36rpx;
font-weight: 600;
color: #333;
}
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 8rpx;
}
.modal-subtitle {
font-size: 24rpx;
color: #999;
}
.login-card {
display: none;
&.active {
display: block;
}
}
.sms-card {
display: none;
&.active {
display: block;
}
}
.card-body {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.card-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
text-align: center;
}
.auth-tip {
font-size: 22rpx;
color: #999;
text-align: center;
}
.form-group {
width: 100%;
}
.input-wrapper {
width: 100%;
height: 88rpx;
background: #F5F7FA;
border-radius: 16rpx;
border: 2rpx solid transparent;
display: flex;
align-items: center;
padding: 0 24rpx;
box-sizing: border-box;
transition: all 0.3s;
&.focused {
border-color: #FF8C42;
background: #fff;
}
}
.phone-input {
flex: 1;
height: 100%;
font-size: 28rpx;
color: #333;
}
.code-input {
padding-right: 0;
.phone-input {
width: 55%;
}
}
.send-code-btn {
width: 40%;
height: 64rpx;
line-height: 64rpx;
background: #FF8C42;
color: #fff;
font-size: 24rpx;
border-radius: 8rpx;
padding: 0;
margin: 0;
&.disabled {
background: #ccc;
}
&::after {
border: none;
}
}
.login-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: 600;
padding: 0;
margin: 0;
&.primary {
background: linear-gradient(135deg, #FF8C42 0%, #FF6B2C 100%);
color: #fff;
border: none;
}
&.disabled {
background: #ccc;
color: #fff;
}
&.loading {
opacity: 0.8;
}
&.switch-btn {
background: transparent;
color: #FF8C42;
border: 2rpx solid #FF8C42;
}
&::after {
border: none;
}
}
.agreement-text {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8rpx;
margin-top: 24rpx;
font-size: 22rpx;
color: #999;
.link {
color: #FF8C42;
}
}
.custom-toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 24rpx 48rpx;
border-radius: 12rpx;
font-size: 28rpx;
z-index: 99999;
opacity: 0;
transition: all 0.3s;
&.show {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
}
</style>
@@ -0,0 +1,278 @@
<template>
<view
class="verify-code-input"
@tap="focusInput"
:style="{
'--vci-active-color': activeColor,
'--vci-filled-color': filledColor,
'--vci-border-color': borderColor
}"
>
<view class="vci-title" v-if="title">
<text class="vci-title__text">{{ title }}</text>
</view>
<view class="vci-desc" v-if="desc">
<text class="vci-desc__text">{{ desc }}</text>
</view>
<view class="vci-code-box">
<view
v-for="(item, index) in codeLength"
:key="index"
class="vci-code-item"
:class="{
'vci-code-item--active': isFocused && currentIndex === index,
'vci-code-item--filled': code.length > index,
'vci-code-item--underline': type === 'underline',
'vci-code-item--box': type === 'box'
}"
>
<text v-if="code[index] && !mask" class="vci-code-text">{{ code[index] }}</text>
<view v-else-if="code[index] && mask" class="vci-code-dot"></view>
<view v-if="isFocused && currentIndex === index && code.length === index" class="vci-code-cursor"></view>
</view>
</view>
<view class="vci-tips" v-if="errorMsg">
<text class="vci-tips__text">{{ errorMsg }}</text>
</view>
<input
ref="inputRef"
class="vci-hidden-input"
type="number"
:maxlength="codeLength"
:value="code"
:focus="isFocused"
:adjust-position="false"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
/>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
desc: {
type: String,
default: ''
},
length: {
type: Number,
default: 6
},
mask: {
type: Boolean,
default: false
},
type: {
type: String,
default: 'box'
},
errorMsg: {
type: String,
default: ''
},
autoFocus: {
type: Boolean,
default: false
},
activeColor: {
type: String,
default: '#1677FF'
},
filledColor: {
type: String,
default: '#1A202C'
},
borderColor: {
type: String,
default: '#E2E8F0'
}
})
const emit = defineEmits(['update:modelValue', 'complete', 'focus', 'blur'])
const code = ref(props.modelValue)
const isFocused = ref(false)
const currentIndex = ref(0)
const inputRef = ref(null)
const codeLength = computed(() => props.length)
watch(() => props.modelValue, (val) => {
code.value = val
currentIndex.value = Math.min(val.length, codeLength.value - 1)
})
function focusInput() {
isFocused.value = true
}
function onInput(e) {
let val = e.detail.value.replace(/\D/g, '').slice(0, codeLength.value)
code.value = val
currentIndex.value = Math.min(val.length, codeLength.value - 1)
emit('update:modelValue', val)
if (val.length === codeLength.value) {
emit('complete', val)
}
}
function onFocus() {
isFocused.value = true
currentIndex.value = code.value.length
emit('focus')
}
function onBlur() {
isFocused.value = false
emit('blur')
}
function clear() {
code.value = ''
currentIndex.value = 0
emit('update:modelValue', '')
}
function setFocus(val) {
isFocused.value = val
}
defineExpose({
clear,
focusInput,
setFocus
})
</script>
<style lang="scss" scoped>
.verify-code-input {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.vci-title {
margin-bottom: 8px;
&__text {
font-size: 18px;
font-weight: 600;
color: #1A202C;
}
}
.vci-desc {
margin-bottom: 32px;
&__text {
font-size: 13px;
color: #718096;
}
}
.vci-code-box {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 20px;
}
.vci-code-item {
position: relative;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&--box {
width: 44px;
height: 44px;
background: #FFFFFF;
border: 1px solid var(--vci-border-color, #E2E8F0);
border-radius: 8px;
&.vci-code-item--active {
border-color: var(--vci-active-color, #1677FF);
}
&.vci-code-item--filled {
border-color: var(--vci-filled-color, #1A202C);
}
}
&--underline {
width: 40px;
height: 50px;
border-bottom: 2px solid var(--vci-border-color, #E2E8F0);
&.vci-code-item--active {
border-bottom-color: var(--vci-active-color, #FF8C42);
}
&.vci-code-item--filled {
border-bottom-color: var(--vci-filled-color, #1A202C);
}
}
}
.vci-code-text {
font-size: 24px;
font-weight: 600;
color: var(--vci-filled-color, #1A202C);
line-height: 1;
}
.vci-code-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--vci-filled-color, #1A202C);
}
.vci-code-cursor {
position: absolute;
width: 2px;
height: 24px;
background: var(--vci-active-color, #1677FF);
animation: vci-blink 1s infinite;
}
@keyframes vci-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.vci-tips {
text-align: center;
margin-bottom: 16px;
&__text {
font-size: 13px;
color: #E53E3E;
}
}
.vci-hidden-input {
position: absolute;
left: -9999px;
top: -9999px;
width: 1px;
height: 1px;
opacity: 0;
}
</style>
@@ -1,4 +1,4 @@
<template>
<template>
<!-- 团课卡片容器 -->
<view class="course-card" @click="goDetail">
<!-- 卡片顶部图片区域 -->
@@ -7,7 +7,7 @@
<view v-if="!imageLoaded" class="skeleton skeleton-image"></view>
<!-- 课程封面图片 -->
<image
:src="course.coverImage"
:src="getCourseCoverUrl(course.coverImage)"
mode="aspectFill"
class="cover-image"
:class="{ hidden: !imageLoaded }"
@@ -47,9 +47,19 @@
</view>
<!-- 课程时长 -->
<view class="course-duration">
<uni-icons type="time" size="14" color="#8A99B4" />
<text>{{ formatDuration(course.startTime, course.endTime) }}</text>
<view class="course-tags">
<view class="tag-item duration-tag">
<uni-icons type="time" size="14" color="#8A99B4" />
<text>{{ formatDuration(course.startTime, course.endTime) }}</text>
</view>
<view
v-for="label in courseTypeLabels"
:key="label.id"
class="tag-item type-tag"
:style="{ background: `${label.color}15`, color: label.color }"
>
<text>{{ label.labelName }}</text>
</view>
</view>
</view>
@@ -93,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>
@@ -101,6 +111,7 @@
<script setup>
import { ref, computed } from 'vue'
import { getCourseCoverUrl } from '@/utils/request.js'
// 图片加载状态
const imageLoaded = ref(false)
@@ -109,6 +120,14 @@ const props = defineProps({
course: {
type: Object,
required: true
},
courseTypeLabels: {
type: Array,
default: () => []
},
booked: {
type: Boolean,
default: false
}
})
@@ -133,7 +152,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) => {
@@ -150,14 +176,34 @@ const formatTime = (dateStr) => {
const formatDuration = (startStr, endStr) => {
if (!startStr || !endStr) return ''
const start = new Date(startStr)
const end = new Date(endStr)
const minutes = Math.floor((end.getTime() - start.getTime()) / 60000)
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
console.warn(`[CourseCard] 无效的时间格式: startTime=${startStr}, endTime=${endStr}`)
return ''
}
const diffMs = end.getTime() - start.getTime()
if (diffMs <= 0) {
console.warn(`[CourseCard] 结束时间小于或等于开始时间: startTime=${startStr}, endTime=${endStr}`)
return ''
}
const maxDurationMinutes = 24 * 60
const minutes = Math.floor(diffMs / 60000)
if (minutes > maxDurationMinutes) {
console.warn(`[CourseCard] 课程时长超过24小时,已修正: ${minutes}分钟 -> ${maxDurationMinutes}分钟`)
return `${maxDurationMinutes}分钟以上`
}
return `${minutes}分钟`
}
const goDetail = () => {
emit('detail', props.course.id)
emit('detail', Number(props.course.id))
}
const handleBooking = () => {
@@ -340,19 +386,35 @@ const handleBooking = () => {
flex: 1;
}
/* 课程时长 */
.course-duration {
/* 课程标签区域 */
.course-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
/* 标签项 */
.tag-item {
display: inline-flex;
align-items: center;
gap: 8rpx;
gap: 6rpx;
padding: 10rpx 20rpx;
background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);
border-radius: 20rpx;
font-size: 24rpx;
color: #3B82F6;
font-weight: 500;
}
/* 时长标签 */
.duration-tag {
background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);
color: #3B82F6;
}
/* 类型标签 */
.type-tag {
font-weight: 600;
}
/* 卡片底部区域 */
.card-footer {
padding: 24rpx 32rpx 28rpx;
@@ -1,5 +1,20 @@
<template>
<template>
<view class="filter-section">
<!-- 课程类型筛选 -->
<picker
mode="selector"
:range="courseTypeOptions"
range-key="label"
:value="courseTypeIndex"
@change="onCourseTypeChange"
>
<view class="filter-item">
<uni-icons type="apps" size="18" color="#5E6F8D" class="filter-icon" />
<text class="filter-text">{{ courseTypeOptions[courseTypeIndex]?.label || '全部类型' }}</text>
<uni-icons type="right" size="20" color="#A0AEC0" class="filter-arrow" />
</view>
</picker>
<!-- 时间区间筛选 -->
<view class="filter-item" @click="handleTimePick">
<uni-icons type="calendar" size="18" color="#5E6F8D" class="filter-icon" />
@@ -25,7 +40,7 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, watch, computed } from 'vue'
const props = defineProps({
timeRangeText: {
@@ -35,46 +50,90 @@ const props = defineProps({
sortOptions: {
type: Array,
default: () => [
{ label: '默认排序', value: 'default' },
{ label: '价格从低到高', value: 'priceAsc' },
{ label: '价格从高到低', value: 'priceDesc' },
{ label: '剩余名额最多', value: 'spotsDesc' },
{ label: '仅次数卡', value: 'pointCardOnly' },
{ label: '仅储值卡', value: 'storedValueOnly' },
{ label: '两种支付', value: 'bothPayment' }
{ label: '默认排序', value: 'default', priceSort: null, remainingMost: false },
{ label: '价格从低到高', value: 'priceAsc', priceSort: 'asc', remainingMost: false },
{ label: '价格从高到低', value: 'priceDesc', priceSort: 'desc', remainingMost: false },
{ label: '剩余名额最多', value: 'remainingMost', priceSort: null, remainingMost: true }
]
},
sortIndex: {
type: Number,
default: 0
},
courseTypes: {
type: Array,
default: () => []
},
currentCourseTypeId: {
type: [Number, String, null],
default: null,
validator: (val) => val === null || val === '' || !isNaN(Number(val))
}
})
const emit = defineEmits(['update:sortIndex', 'timePick'])
const emit = defineEmits(['update:sortIndex', 'timePick', 'courseTypeChange'])
const localSortIndex = ref(props.sortIndex)
const courseTypeOptions = computed(() => {
return [{ id: null, label: '全部类型' }, ...props.courseTypes]
})
const courseTypeIndex = computed(() => {
if (props.currentCourseTypeId === null || props.currentCourseTypeId === '') return 0
const currentId = Number(props.currentCourseTypeId)
const index = courseTypeOptions.value.findIndex(item => Number(item.id) === currentId)
return index >= 0 ? index : 0
})
watch(() => props.sortIndex, (val) => {
localSortIndex.value = val
})
const onSortChange = (e) => {
localSortIndex.value = e.detail.value
const sortOption = props.sortOptions[localSortIndex.value]
console.log('[FilterSection] 排序方式变更:', {
index: localSortIndex.value,
value: props.sortOptions[localSortIndex.value]
value: sortOption
})
if (sortOption.priceSort && sortOption.remainingMost) {
console.warn('[FilterSection] 排序参数冲突警告: priceSort和remainingMost不能同时设置')
}
emit('update:sortIndex', localSortIndex.value)
}
const onCourseTypeChange = (e) => {
const index = e.detail.value
const selectedType = courseTypeOptions.value[index]
// 确保返回数字类型而非字符串
const typeId = selectedType.id !== null ? Number(selectedType.id) : null
console.log('[FilterSection] 课程类型变更:', {
index,
id: typeId,
label: selectedType.label
})
emit('courseTypeChange', typeId)
}
const handleTimePick = () => {
console.log('[FilterSection] 触发时间选择器')
emit('timePick')
}
const getFilterParams = () => {
const sortOption = props.sortOptions[localSortIndex.value]
const selectedType = courseTypeOptions.value[courseTypeIndex.value]
const params = {
sortType: props.sortOptions[localSortIndex.value].value,
sortType: sortOption.value,
priceSort: sortOption.priceSort,
remainingMost: sortOption.remainingMost,
courseTypeId: selectedType.id !== null ? Number(selectedType.id) : null,
courseTypeName: selectedType.label,
timeRangeText: props.timeRangeText
}
console.log('[FilterSection] 获取筛选参数:', params)
@@ -173,18 +232,20 @@ defineExpose({
<style lang="scss" scoped>
.filter-section {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
.filter-item {
flex: 1;
min-width: calc(33.33% - 12rpx);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 20rpx 24rpx;
gap: 8rpx;
padding: 18rpx 16rpx;
background: #F5F7FA;
border-radius: 16rpx;
font-size: 26rpx;
font-size: 24rpx;
color: #5E6F8D;
.filter-icon {
@@ -197,6 +258,13 @@ defineExpose({
display: flex;
align-items: center;
}
.filter-text {
max-width: 100rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>
@@ -1,4 +1,4 @@
<template>
<template>
<view class="search-bar-wrapper">
<!-- 搜索框 -->
<view class="search-bar">
@@ -1,4 +1,4 @@
<template>
<template>
<view class="time-period-selector">
<view class="sort-header">
<uni-icons type="calendar" size="16" color="#FF6B35" class="sort-icon" />
@@ -1,4 +1,4 @@
<template>
<template>
<view>
<!-- 时间范围选择弹窗 -->
<Transition name="modal">
@@ -121,6 +121,8 @@ const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const quickOptions = [
{ label: '近7天', value: '7d' },
{ label: '近30天', value: '30d' },
{ label: '未来7天', value: 'future7d' },
{ label: '未来30天', value: 'future30d' },
{ label: '本月', value: 'month' },
{ label: '上月', value: 'lastMonth' }
]
@@ -218,12 +220,14 @@ function isToday(year, month, day) {
day === today.getDate()
}
// 判断是否是未来日期
// 判断是否是未来日期(限制最多选择未来90天)
function isFuture(year, month, day) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const maxFuture = new Date(today)
maxFuture.setDate(maxFuture.getDate() + 90) // 最多允许选择未来90天
const date = new Date(year, month - 1, day)
return date > today
return date > maxFuture
}
// 切换日期选择器
@@ -273,7 +277,7 @@ const selectDate = (day) => {
console.log(`[TimeRangePicker] ${pickerType.value === 'start' ? '开始' : '结束'}日期变更:`, day.date)
}
// 应用快捷选项
// 应用快捷选项(所有日期计算基于当前真实时间)
const applyQuickOption = (value) => {
quickSelected.value = value
const today = new Date()
@@ -281,18 +285,32 @@ const applyQuickOption = (value) => {
switch (value) {
case '7d':
// 近7天:以今天为终点,往前推7天
startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
endDate = today
break
case '30d':
// 近30天:以今天为终点,往前推30天
startDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
endDate = today
break
case 'future7d':
// 未来7天:以今天为起点,往后推7天
startDate = today
endDate = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000)
break
case 'future30d':
// 未来30天:以今天为起点,往后推30天
startDate = today
endDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000)
break
case 'month':
// 本月:当月1号到当月最后一天
startDate = new Date(today.getFullYear(), today.getMonth(), 1)
endDate = today
endDate = new Date(today.getFullYear(), today.getMonth() + 1, 0)
break
case 'lastMonth':
// 上月:上月1号到上月最后一天
startDate = new Date(today.getFullYear(), today.getMonth() - 1, 1)
endDate = new Date(today.getFullYear(), today.getMonth(), 0)
break
@@ -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'
console.error(`图片 ${index} 加载失败, URL:`, banners.value[index]?.image?.substring(0, 120))
}
</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 {
@@ -0,0 +1,273 @@
<template>
<!-- 课程卡片 -->
<view class="course-card" :style="cardStyle">
<!-- 课程图片区域 -->
<view class="course-image" :style="imageStyle">
<!-- 课程封面图片 -->
<image :src="course.image" mode="aspectFill" class="img" />
<!-- 图片渐变遮罩 -->
<view class="course-overlay"></view>
<!-- 课程标签 -->
<text :class="['course-tag', course.tagType]">{{ course.tag }}</text>
<!-- 课程信息区域 -->
<view class="course-info">
<!-- 课程名称 -->
<text class="course-name">{{ course.name }}</text>
<!-- 课程元信息时长难度 -->
<view class="course-meta">
<!-- 时长信息 -->
<view class="meta-item">
<text class="meta-icon">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/time.png"/>
</text>
<text>{{ course.duration }}</text>
</view>
<!-- 难度信息 -->
<view class="meta-item">
<text class="meta-icon">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/intensity.png"/>
</text>
<text>{{ course.level }}</text>
</view>
</view>
</view>
</view>
<!-- 课程底部区域 -->
<view class="course-footer">
<!-- 参与人数信息 -->
<view class="participants">
<text class="fire-icon">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/hot.png"/>
</text>
<text>{{ course.participants }}人参与</text>
</view>
<!-- 去参与按钮 -->
<view class="join-btn" @click="handleJoinCourse">
<text>去参与</text>
</view>
</view>
</view>
</template>
<script setup>
import { defineProps, computed } from 'vue'
// 定义 props
const props = defineProps({
course: {
type: Object,
required: true,
default: () => ({
id: '',
image: '',
tag: '',
tagType: 'default',
name: '',
duration: '',
level: '',
participants: 0,
rawData: null
})
},
width: {
type: [Number, String],
default: 320,
description: '卡片宽度,单位 rpx'
},
height: {
type: [Number, String],
default: null,
description: '卡片高度,单位 rpx,不传则自适应'
},
imageHeight: {
type: [Number, String],
default: 280,
description: '图片区域高度,单位 rpx'
},
borderRadius: {
type: [Number, String],
default: 24,
description: '卡片圆角,单位 rpx'
}
})
// 计算卡片样式
const cardStyle = computed(() => {
const style = {}
if (props.width) {
style.width = typeof props.width === 'number' ? `${props.width}rpx` : props.width
}
if (props.height) {
style.height = typeof props.height === 'number' ? `${props.height}rpx` : props.height
}
if (props.borderRadius) {
style.borderRadius = typeof props.borderRadius === 'number' ? `${props.borderRadius}rpx` : props.borderRadius
}
return style
})
// 计算图片区域样式
const imageStyle = computed(() => {
const style = {}
if (props.imageHeight) {
style.height = typeof props.imageHeight === 'number' ? `${props.imageHeight}rpx` : props.imageHeight
}
return style
})
// 处理参与课程点击
const handleJoinCourse = () => {
uni.navigateTo({ url: `/pages/groupCourse/detail?id=${props.course.id}` })
}
</script>
<style lang="scss">
.course-card {
min-width: 320rpx;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
min-height: 400rpx;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 8rpx 28rpx var(--shadow-blue-light);
border: 1rpx solid rgba(255, 255, 255, 0.6);
display: block;
}
.course-image {
min-height: 280rpx;
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 20rpx;
}
.img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.course-overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: linear-gradient(to top, rgba(45, 74, 90, 0.7) 0%, transparent 60%);
}
.course-tag {
position: absolute;
top: 16rpx;
right: 16rpx;
padding: 8rpx 16rpx;
border-radius: 12rpx;
font-size: 20rpx;
font-weight: 600;
color: #ffffff;
background: linear-gradient(135deg, #7AB5CC, #9CCFDF);
z-index: 2;
&.hot {
background: linear-gradient(135deg, #6BA8C0, #8CC5D5);
}
&.new {
background: linear-gradient(135deg, #6DB5C8, #90CEDD);
}
&.free {
background: linear-gradient(135deg, #7AB5CC, #9CCFDF);
}
&.full {
background: linear-gradient(135deg, #A0B8C8, #B8CCD8);
}
&.ended {
background: linear-gradient(135deg, #B0C0CC, #C4D2DC);
}
&.default {
background: linear-gradient(135deg, #7AB5CC, #9CCFDF);
}
}
.course-info {
position: relative;
z-index: 2;
}
.course-name {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 8rpx;
}
.course-meta {
display: flex;
gap: 16rpx;
align-items: center;
}
.meta-item {
display: flex;
align-items: end;
gap: 6rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
}
.meta-icon {
font-size: 20rpx;
image{
display: flex;
align-items: center;
width: 25rpx;
height: 25rpx;
}
}
.course-footer {
padding: 16rpx 10rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.participants {
display: flex;
align-items: center;
gap: 6rpx;
font-size: 22rpx;
color: var(--tabbar-text-inactive);
}
.fire-icon {
font-size: 24rpx;
image{
display: flex;
align-items: center;
width: 30rpx;
height: 30rpx;
}
}
.join-btn {
padding: 12rpx 28rpx;
background: rgba(130, 220, 130, 0.9);
border: none;
border-radius: 9999rpx;
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
box-shadow: 0 6rpx 16rpx rgba(130, 220, 130, 0.35);
}
</style>
@@ -0,0 +1,142 @@
<template>
<!-- 状态栏占位区为刘海屏和安全区域留出空间 -->
<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 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, computed } from 'vue'
const props = defineProps({
title: {
type: String,
default: '课程'
},
subtitle: {
type: String,
default: '精品团课 · 私教 · 线上课'
},
showBack: {
type: Boolean,
default: false
}
})
const isAnimating = ref(false)
const statusBarHeight = ref(0)
onMounted(() => {
// 所有平台获取状态栏高度,为刘海屏/状态栏留出安全空间
const sysInfo = uni.getSystemInfoSync()
statusBarHeight.value = sysInfo.statusBarHeight || 0
if (props.showBack) {
isAnimating.value = true
}
})
function goBack() {
uni.navigateBack({
fail: () => {
uni.switchTab({
url: '/pages/index/index'
})
}
})
}
</script>
<style lang="scss" scoped>
/* 状态栏占位:适配所有平台,包含刘海屏安全区域 */
.tab-page__status-bar {
width: 100%;
/* iOS 刘海屏额外安全区域 */
padding-top: constant(safe-area-inset-top); /* iOS 11.0-11.2 */
padding-top: env(safe-area-inset-top); /* iOS 11.2+ */
box-sizing: content-box;
}
.tab-page__header {
padding: 0 32rpx 24rpx 32rpx;
display: flex;
align-items: center;
height: 88rpx;
position: relative;
}
.tab-page__back-btn {
width: 48rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16rpx;
background: rgba(0, 0, 0, 0.06);
border-radius: 16rpx;
opacity: 0;
transform: translateX(120rpx);
transition: none;
z-index: 1;
}
.tab-page__back-btn--animate {
animation: slideInFromRight 0.4s ease-out forwards;
}
@keyframes slideInFromRight {
0% {
opacity: 0;
transform: translateX(120rpx);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
.tab-page__back-icon {
font-size: 36rpx;
color: $text-dark;
font-weight: bold;
line-height: 28rpx;
}
.tab-page__title-wrap {
flex: 1;
position: relative;
z-index: 2;
}
.tab-page__title {
display: block;
font-size: 40rpx;
font-weight: $font-weight-bold;
color: $text-dark;
}
.tab-page__subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: $text-muted;
}
.tab-page__right {
margin-left: 16rpx;
flex-shrink: 0;
display: flex;
align-items: center;
}
</style>
+115 -31
View File
@@ -4,7 +4,7 @@
v-for="(item, index) in entries"
:key="index"
class="entry-item"
@tap="QEClick(item.path)"
@tap="handleEntryClick(item)"
>
<view :class="['entry-icon', { accent: item.accent }]">
<image :src="item.icon" mode="aspectFit" class="icon-img" />
@@ -16,38 +16,113 @@
</template>
<script setup>
import { qrSignInGroupCourse } from '@/api/groupCourse.js'
import { getMemberId } from '@/utils/request.js'
const QEClick = path => {
uni.navigateTo({
url:path
})
const handleEntryClick = (item) => {
if (item.title === '签到') {
const token = uni.getStorageSync('token')
if (!token) {
uni.showModal({
title: '请登录',
content: '是否跳转到登录页面?',
confirmText: '确定',
cancelText: '取消',
confirmColor: '#FF6B35',
success: (res) => {
if (res.confirm) {
uni.navigateTo({
url: '/pages/login/login'
})
}
}
})
return
}
// 直接调用扫码签到
qrScanSignIn()
} else if (item.path) {
if (item.isTabBar) {
uni.switchTab({
url: item.path
})
} else {
uni.navigateTo({
url: item.path
})
}
}
}
const qrScanSignIn = () => {
uni.scanCode({
onlyFromCamera: true,
success: async (res) => {
console.log('扫码结果:', res)
const qrContent = res.result
// 解析二维码内容获取 courseId 和课程名称
let courseId = null
let courseName = ''
try {
const parsed = JSON.parse(qrContent)
courseId = Number(parsed.id || parsed.courseId)
courseName = parsed.courseName || ''
} 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: courseName ? `${courseName}」签到成功` : '签到成功', 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)
let errMsg = e?.message || e?.data?.message || '签到失败'
// 附加课程名称(如果已知)
if (courseName && !errMsg.includes(courseName)) {
errMsg = `${courseName}${errMsg}`
}
uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
}
},
fail: (err) => {
console.error('扫码失败:', err)
}
})
}
const entries = [
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/course.png',
title: '找课程',
desc: '精品课程',
accent: false,
path: "/pages/searchCourse/searchCourse"
path: "/pages/groupCourse/list"
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/plan.png',
title: '训练计划',
desc: '个性定制',
accent: true
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/data.png',
title: '健身数据',
desc: '记录分析',
accent: false
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/message.png',
title: '消息',
desc: '通知消息',
accent: true
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/checkIn.png',
title: '签到',
@@ -63,15 +138,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 {
@@ -91,6 +161,20 @@ const entries = [
justify-content: center;
margin-bottom: 16rpx;
box-shadow: 0 6rpx 20rpx rgba(130, 220, 130, 0.35);
position: relative;
}
.badge-dot {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 16rpx;
height: 16rpx;
background: linear-gradient(135deg, #FF4757, #FF2D55);
border-radius: 50%;
box-shadow: 0 2rpx 8rpx rgba(255, 71, 87, 0.5);
z-index: 99;
animation: pulse 2s infinite;
}
.icon-img {
@@ -6,7 +6,7 @@
<!-- 区域标题 -->
<text class="section-title">推荐课程</text>
<!-- 查看更多按钮 -->
<view class="view-more">
<view class="view-more" @click="handleViewMore">
<text>查看更多</text>
<text class="arrow">
<uni-icons type="right" size="20" color="#8CA0B0"/>
@@ -23,6 +23,7 @@
v-for="(course, index) in courses"
:key="course.id || index"
class="course-card"
@click="handleJoinCourse(course)"
>
<!-- 课程图片区域 -->
<view class="course-image">
@@ -30,27 +31,23 @@
<image :src="course.image" mode="aspectFill" class="img" />
<!-- 图片渐变遮罩 -->
<view class="course-overlay"></view>
<!-- 课程标签 -->
<text :class="['course-tag', course.tagType]">{{ course.tag }}</text>
<!-- 课程信息区域 -->
<view class="course-info">
<!-- 课程名称 -->
<text class="course-name">{{ course.name }}</text>
<!-- 课程元信息时长难度 -->
<view class="course-meta">
<!-- 时长信息 -->
<view class="meta-item">
<text class="meta-icon">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/time.png"/>
</text>
<text>{{ course.duration }}</text>
</view>
<!-- 难度信息 -->
<view class="meta-item">
<text class="meta-icon">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/intensity.png"/>
</text>
<text>{{ course.level }}</text>
<view class="meta-left">
<!-- 时长信息 -->
<view class="meta-item time-tag">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/time.png" class="meta-icon" />
<text>{{ course.duration }}</text>
</view>
<!-- 难度信息 -->
<view class="meta-item level-tag">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/intensity.png" class="meta-icon" />
<text>{{ course.level }}</text>
</view>
</view>
</view>
</view>
@@ -59,13 +56,11 @@
<view class="course-footer">
<!-- 参与人数信息 -->
<view class="participants">
<text class="fire-icon">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/hot.png"/>
</text>
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/hot.png" class="fire-icon" />
<text>{{ course.participants }}人参与</text>
</view>
<!-- 去参与按钮 -->
<view class="join-btn" @click="handleJoinCourse(course)">
<view class="join-btn" @click.stop="handleJoinCourse(course)">
<text>去参与</text>
</view>
</view>
@@ -77,46 +72,24 @@
<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'
// 测试开关:设置为 true 时使用假数据,false 时使用真实API数据
const USE_MOCK_DATA = true
import { getCourseCoverUrl } from '@/utils/request.js'
console.log('[RecommendCourses] 组件脚本已加载')
// 推荐课程数据列表
const courses = ref([])
// 课程类型映射(用于显示标签)
const getCourseTypeName = (type) => {
const typeMap = {
'1': '瑜伽',
'2': '搏击',
'3': '塑形'
}
return typeMap[type] || '课程'
}
// 根据课程信息获取标签文本
const getTag = (course) => {
if (course.currentMembers >= course.maxMembers) return '已满员'
if (course.status === '2') return '已结束'
if (course.currentMembers / course.maxMembers >= 0.8) return '热门'
return getCourseTypeName(course.courseType)
}
// 根据课程信息获取标签样式类型
const getTagType = (course) => {
if (course.currentMembers >= course.maxMembers) return 'full'
if (course.status === '2') return 'ended'
if (course.currentMembers / course.maxMembers >= 0.8) return 'hot'
return 'default'
}
// 计算课程时长
const calculateDuration = (startTime, endTime) => {
if (!startTime || !endTime) return '60分钟'
const start = new Date(startTime)
const end = new Date(endTime)
const durationMinutes = Math.floor((end - start) / (1000 * 60))
const diffMs = end - start
if (diffMs <= 0) return ''
const durationMinutes = Math.floor(diffMs / (1000 * 60))
return `${durationMinutes}分钟`
}
@@ -128,59 +101,77 @@ const getCourseLevel = (course) => {
return '初级'
}
// 处理图片URL
const getImageUrl = (coverImage) => {
if (!coverImage) return 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80'
if (coverImage.startsWith('http')) return coverImage
return `https://your-domain.com${coverImage}`
}
// 处理图片URL - 已由 getOssUrl 工具函数处理
// 使用 @/utils/request.js 中的 getOssUrl 替代
// 获取推荐课程
const fetchRecommendCourses = async () => {
// 如果测试开关打开,直接使用假数据
if (USE_MOCK_DATA) {
useFallbackData()
return
}
console.log('[RecommendCourses] fetchRecommendCourses 开始请求')
try {
const res = await getGroupCoursePage({
page: 0, size: 5, sort: 'current_members', order: 'desc'
}, { cache: true, cacheTime: 5 * 60 * 1000 })
if (res && res.content && Array.isArray(res.content)) {
courses.value = res.content.map(course => ({
id: course.id, image: getImageUrl(course.coverImage), tag: getTag(course),
tagType: getTagType(course), name: course.courseName || '未知课程',
duration: calculateDuration(course.startTime, course.endTime),
level: getCourseLevel(course), participants: course.currentMembers || 0, rawData: course
}))
} else { useFallbackData() }
} catch (err) { useFallbackData() }
}
const useFallbackData = () => {
const fallbackContent = [
{ id: "3", courseName: "燃脂搏击", courseType: "2", startTime: "2026-06-10T18:30:00", endTime: "2026-06-10T19:30:00", maxMembers: 20, currentMembers: 20, status: "0", coverImage: "https://picsum.photos/id/100/800/600", description: "高强度间歇训练" },
{ id: "2", courseName: "清晨流瑜伽", courseType: "1", startTime: "2026-06-12T09:00:00", endTime: "2026-06-12T10:30:00", maxMembers: 15, currentMembers: 5, status: "0", coverImage: "https://picsum.photos/id/101/800/600", description: "流畅体式" },
{ id: "4", courseName: "哈他瑜伽", courseType: "1", startTime: "2026-06-01T15:20:00", endTime: "2026-06-01T16:50:00", maxMembers: 12, currentMembers: 3, status: "0", coverImage: "https://picsum.photos/id/102/800/600", description: "基础瑜伽" },
{ id: "6", courseName: "蜜桃臀塑造", courseType: "3", startTime: "2026-05-30T19:00:00", endTime: "2026-05-30T20:00:00", maxMembers: 10, currentMembers: 8, status: "2", coverImage: "https://picsum.photos/id/103/800/600", description: "臀部训练" },
{ id: "7", courseName: "午间冥想放松", courseType: "1", startTime: "2026-05-31T12:00:00", endTime: "2026-05-31T13:00:00", maxMembers: 15, currentMembers: 6, status: "2", coverImage: "https://picsum.photos/id/104/800/600", description: "冥想" }
]
courses.value = fallbackContent.map(course => ({
id: course.id, image: getImageUrl(course.coverImage), tag: getTag(course),
tagType: getTagType(course), name: course.courseName || '未知课程',
duration: calculateDuration(course.startTime, course.endTime),
level: getCourseLevel(course), participants: course.currentMembers || 0, rawData: course
}))
const res = await getActiveRecommendCourses()
console.log('[RecommendCourses] API 返回:', JSON.stringify(res).substring(0, 200))
if (res && Array.isArray(res) && res.length > 0) {
console.log('[RecommendCourses] 第一项 groupCourse.coverImage:', JSON.stringify(res[0]?.groupCourse?.coverImage))
console.log('[RecommendCourses] 第一项完整 groupCourse:', JSON.stringify(res[0]?.groupCourse))
}
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(recommend => {
const course = recommend.groupCourse
const rawCover = course.coverImage
const finalImage = getCourseCoverUrl(rawCover) || 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80'
console.log('[RecommendCourses] 封面URL映射:', {
'courseName': course.courseName,
'原始coverImage': rawCover,
'转换后URL': finalImage,
'是否为完整URL': finalImage.startsWith('http')
})
return {
id: course.id,
image: finalImage,
name: course.courseName || '未知课程',
duration: calculateDuration(course.startTime, course.endTime),
level: getCourseLevel(course),
participants: course.currentMembers || 0,
rawData: course
}
})
console.log('[RecommendCourses] 最终展示 courses.value 数量:', courses.value.length)
} else {
console.warn('[RecommendCourses] API 返回格式异常:', res)
}
} catch (err) {
console.error('获取推荐课程失败:', err)
}
}
const handleJoinCourse = (course) => {
if (course.rawData.status === '2') { uni.showToast({ title: '课程已结束', icon: 'none' }); return }
if (course.rawData.currentMembers >= course.rawData.maxMembers) { uni.showToast({ title: '课程已满员', icon: 'none' }); return }
uni.navigateTo({ url: `/pages/course/detail?id=${course.id}` })
uni.navigateTo({ url: `/pages/groupCourse/detail?id=${course.id}` })
}
onMounted(() => { fetchRecommendCourses() })
const handleViewMore = () => {
uni.navigateTo({ url: '/pages/recommendCourses/index' })
}
onMounted(() => { console.log('[RecommendCourses] onMounted 触发'); fetchRecommendCourses() })
onShow(() => {
console.log('[RecommendCourses] onShow 触发,刷新推荐课程')
fetchRecommendCourses()
})
defineExpose({ fetchRecommendCourses })
</script>
<style lang="scss">
@@ -222,14 +213,13 @@ onMounted(() => { fetchRecommendCourses() })
.courses-list {
display: inline-flex;
gap: 48rpx;
gap: 24rpx;
padding-right: 24rpx;
}
.course-card {
width: 320rpx;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
width: 338rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 8rpx 28rpx var(--shadow-blue-light);
@@ -239,18 +229,11 @@ onMounted(() => { fetchRecommendCourses() })
}
.course-image {
height: 280rpx;
height: 260rpx;
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 20rpx;
}
.img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
@@ -264,39 +247,12 @@ onMounted(() => { fetchRecommendCourses() })
background: linear-gradient(to top, rgba(45, 74, 90, 0.7) 0%, transparent 60%);
}
.course-tag {
position: absolute;
top: 16rpx;
right: 16rpx;
padding: 8rpx 16rpx;
border-radius: 12rpx;
font-size: 20rpx;
font-weight: 600;
color: #ffffff;
background: linear-gradient(135deg, #7AB5CC, #9CCFDF);
z-index: 2;
&.hot {
background: linear-gradient(135deg, #6BA8C0, #8CC5D5);
}
&.new {
background: linear-gradient(135deg, #6DB5C8, #90CEDD);
}
&.free {
background: linear-gradient(135deg, #7AB5CC, #9CCFDF);
}
&.full {
background: linear-gradient(135deg, #A0B8C8, #B8CCD8);
}
&.ended {
background: linear-gradient(135deg, #B0C0CC, #C4D2DC);
}
&.default {
background: linear-gradient(135deg, #7AB5CC, #9CCFDF);
}
}
.course-info {
position: relative;
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 16rpx 20rpx;
z-index: 2;
}
@@ -306,34 +262,56 @@ onMounted(() => { fetchRecommendCourses() })
font-weight: 600;
color: #ffffff;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.course-meta {
display: flex;
gap: 16rpx;
justify-content: space-between;
align-items: center;
gap: 8rpx;
}
.meta-left {
display: flex;
gap: 8rpx;
}
.meta-item {
display: flex;
align-items: end;
align-items: center;
gap: 6rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
}
.meta-icon {
font-size: 20rpx;
image{
display: flex;
align-items: center;
width: 25rpx;
height: 25rpx;
color: rgba(255, 255, 255, 0.9);
padding: 6rpx 10rpx;
border-radius: 6rpx;
background: rgba(255, 255, 255, 0.2);
line-height: 1;
.meta-icon {
vertical-align: middle;
}
}
.time-tag {
background: rgba(255, 255, 255, 0.25);
}
.level-tag {
background: rgba(255, 255, 255, 0.25);
}
.meta-icon {
width: 22rpx;
height: 22rpx;
display: inline-block;
vertical-align: middle;
}
.course-footer {
padding: 16rpx 10rpx;
padding: 16rpx 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
@@ -348,13 +326,8 @@ onMounted(() => { fetchRecommendCourses() })
}
.fire-icon {
font-size: 24rpx;
image{
display: flex;
align-items: center;
width: 30rpx;
height: 30rpx;
}
width: 32rpx;
height: 32rpx;
}
.join-btn {
@@ -5,13 +5,6 @@
<view class="section-header">
<!-- 区域标题 -->
<text class="section-title">今日推荐</text>
<!-- 查看更多按钮 -->
<view class="view-more">
<text>查看更多</text>
<text class="arrow">
<uni-icons type="right" size="20" color="#8CA0B0"></uni-icons>
</text>
</view>
</view>
<!-- 推荐列表 -->
@@ -19,8 +12,9 @@
<!-- 推荐项 -->
<view
v-for="(item, index) in recommends"
:key="index"
:key="item.id || index"
class="recommend-item"
@click="handleItemClick(item)"
>
<!-- 推荐项图片 -->
<image :src="item.image" mode="aspectFill" class="item-image" />
@@ -38,7 +32,7 @@
<!-- 推荐项操作区域 -->
<view class="item-action">
<!-- 开始训练按钮 -->
<view class="start-btn">
<view class="start-btn" @click.stop="handleItemClick(item)">
<text class="start-btn-text">开始训练</text>
</view>
<!-- 参与人数 -->
@@ -50,30 +44,99 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getActiveRecommendCourses } from '@/api/groupCourse.js'
import { getCourseCoverUrl } from '@/utils/request.js'
console.log('[TodayRecommend] 组件脚本已加载')
// 今日推荐数据列表
const recommends = [
{
image: 'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=300&q=80',
title: '晨间活力唤醒跑',
tags: ['20分钟', '初级'],
desc: '唤醒身体,开启活力一天',
participants: '2784'
},
{
image: 'https://images.unsplash.com/photo-1581009146145-b5ef050c149a?w=300&q=80',
title: '全身力量塑形',
tags: ['50分钟', '中级'],
desc: '全身综合训练,塑造完美线条',
participants: '4126'
},
{
image: 'https://images.unsplash.com/photo-1490645935967-10de6ba17061?w=300&q=80',
title: '蛋白增肌饮食指南',
tags: ['营养饮食', '12分钟'],
desc: '科学饮食搭配,助力肌肉增长',
participants: '1865'
const recommends = ref([])
// 计算课程时长
const calculateDuration = (startTime, endTime) => {
if (!startTime || !endTime) return '60分钟'
const start = new Date(startTime)
const end = new Date(endTime)
const diffMs = end - start
if (diffMs <= 0) return ''
const durationMinutes = Math.floor(diffMs / (1000 * 60))
return `${durationMinutes}分钟`
}
// 获取课程难度
const getCourseLevel = (course) => {
if (course.courseType === '2') return '中级'
if (course.courseType === '3') return '高级'
if (course.courseType === '1') return '初级'
return '初级'
}
// 处理图片URL - 已由 getOssUrl 工具函数处理
// 获取今日推荐
const fetchTodayRecommend = async () => {
console.log('[TodayRecommend] fetchTodayRecommend 开始请求')
try {
const res = await getActiveRecommendCourses()
console.log('[TodayRecommend] API 返回:', JSON.stringify(res).substring(0, 200))
if (res && Array.isArray(res) && res.length > 0) {
console.log('[TodayRecommend] 第一项 groupCourse.coverImage:', JSON.stringify(res[0]?.groupCourse?.coverImage))
console.log('[TodayRecommend] 第一项完整 groupCourse:', JSON.stringify(res[0]?.groupCourse))
}
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(recommend => {
const course = recommend.groupCourse
const rawCover = course.coverImage
const finalImage = getCourseCoverUrl(rawCover) || 'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=300&q=80'
console.log('[TodayRecommend] 封面URL映射:', {
'courseName': course.courseName,
'原始coverImage': rawCover,
'转换后URL': finalImage,
'是否为完整URL': finalImage.startsWith('http')
})
return {
id: course.id,
image: finalImage,
title: course.courseName || '未知课程',
tags: [calculateDuration(course.startTime, course.endTime), getCourseLevel(course)],
desc: course.description || '精彩课程,不容错过',
participants: String(course.currentMembers || 0)
}
})
console.log('[TodayRecommend] 最终展示 recommends.value 数量:', recommends.value.length)
} else {
console.warn('[TodayRecommend] API 返回格式异常:', res)
}
} catch (err) {
console.error('获取今日推荐失败:', err)
}
]
}
const handleItemClick = (item) => {
if (!item.id) { uni.showToast({ title: '课程信息不完整', icon: 'none' }); return }
uni.navigateTo({ url: `/pages/groupCourse/detail?id=${item.id}` })
}
onMounted(() => { console.log('[TodayRecommend] onMounted 触发'); fetchTodayRecommend() })
onShow(() => {
console.log('[TodayRecommend] onShow 触发,刷新今日推荐')
fetchTodayRecommend()
})
defineExpose({ fetchTodayRecommend })
</script>
<style lang="scss" scoped>
@@ -96,18 +159,6 @@ const recommends = [
color: #2D4A5A;
}
.view-more {
display: flex;
align-items: center;
gap: 4rpx;
font-size: 26rpx;
color: var(--tabbar-text-inactive);
}
.arrow {
font-size: 32rpx;
}
.recommend-list {
display: flex;
flex-direction: column;
@@ -1,4 +1,4 @@
<template>
<template>
<view class="bt-radar">
<canvas
:id="canvasId"
@@ -10,62 +10,50 @@
</view>
</template>
<script>
<script setup>
import { ref, watch, onMounted, nextTick } from 'vue'
import { drawRadarChart } from '@/common/memberInfo/bodyTestChart.js'
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
labels: { type: Array, default: () => [] },
values: { type: Array, default: () => [] },
width: { type: Number, default: 280 },
height: { type: Number, default: 240 }
},
data() {
return {
canvasId: `bt-radar-${Math.random().toString(36).slice(2, 9)}`
}
},
watch: {
values: {
deep: true,
handler() {
this.renderChart()
}
}
},
mounted() {
this.renderChart()
},
methods: {
renderChart() {
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this)
query
.select(`#${this.canvasId}`)
.fields({ node: true, size: true })
.exec((res) => {
const node = res?.[0]?.node
if (!node) return
const dpr = uni.getSystemInfoSync().pixelRatio || 1
drawRadarChart(node, {
width: this.width,
height: this.height,
labels: this.labels,
values: this.values,
dpr
})
})
const props = defineProps({
labels: { type: Array, default: () => [] },
values: { type: Array, default: () => [] },
width: { type: Number, default: 280 },
height: { type: Number, default: 240 }
})
const canvasId = ref(`bt-radar-${Math.random().toString(36).slice(2, 9)}`)
function renderChart() {
nextTick(() => {
const query = uni.createSelectorQuery().in(getCurrentInstance())
query
.select(`#${canvasId.value}`)
.fields({ node: true, size: true })
.exec((res) => {
const node = res?.[0]?.node
if (!node) return
const dpr = uni.getSystemInfoSync().pixelRatio || 1
drawRadarChart(node, {
width: props.width,
height: props.height,
labels: props.labels,
values: props.values,
dpr
})
})
}
}
})
}
watch(() => props.values, () => {
renderChart()
}, { deep: true })
onMounted(() => {
renderChart()
})
</script>
<style>
<style lang="scss">
.bt-radar {
display: flex;
align-items: center;
@@ -1,4 +1,4 @@
<template>
<template>
<view class="bt-trend">
<canvas
:id="canvasId"
@@ -10,62 +10,50 @@
</view>
</template>
<script>
<script setup>
import { ref, watch, onMounted, nextTick } from 'vue'
import { drawTrendChart } from '@/common/memberInfo/bodyTestChart.js'
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
points: { type: Array, default: () => [] },
unit: { type: String, default: '' },
width: { type: Number, default: 300 },
height: { type: Number, default: 160 }
},
data() {
return {
canvasId: `bt-trend-${Math.random().toString(36).slice(2, 9)}`
}
},
watch: {
points: {
deep: true,
handler() {
this.renderChart()
}
}
},
mounted() {
this.renderChart()
},
methods: {
renderChart() {
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this)
query
.select(`#${this.canvasId}`)
.fields({ node: true, size: true })
.exec((res) => {
const node = res?.[0]?.node
if (!node) return
const dpr = uni.getSystemInfoSync().pixelRatio || 1
drawTrendChart(node, {
width: this.width,
height: this.height,
points: this.points,
unit: this.unit,
dpr
})
})
const props = defineProps({
points: { type: Array, default: () => [] },
unit: { type: String, default: '' },
width: { type: Number, default: 300 },
height: { type: Number, default: 160 }
})
const canvasId = ref(`bt-trend-${Math.random().toString(36).slice(2, 9)}`)
function renderChart() {
nextTick(() => {
const query = uni.createSelectorQuery().in(getCurrentInstance())
query
.select(`#${canvasId.value}`)
.fields({ node: true, size: true })
.exec((res) => {
const node = res?.[0]?.node
if (!node) return
const dpr = uni.getSystemInfoSync().pixelRatio || 1
drawTrendChart(node, {
width: props.width,
height: props.height,
points: props.points,
unit: props.unit,
dpr
})
})
}
}
})
}
watch(() => props.points, () => {
renderChart()
}, { deep: true })
onMounted(() => {
renderChart()
})
</script>
<style>
<style lang="scss">
.bt-trend {
display: flex;
align-items: center;
@@ -1,4 +1,4 @@
<template>
<template>
<view class="body-report-section">
<view class="body-report-section__inner">
<view class="body-report-section__header">
@@ -102,31 +102,26 @@
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
report: {
type: Object,
default: () => ({
date: '2024-07-01',
weight: '63.5',
bmi: '22.1',
bodyFat: '24.8%',
bmr: '165',
status: '比较健康',
change: '-1.2kg'
})
}
},
emits: ['view-history', 'view-report']
}
<script setup>
defineProps({
report: {
type: Object,
default: () => ({
date: '2024-07-01',
weight: '63.5',
bmi: '22.1',
bodyFat: '24.8%',
bmr: '165',
status: '比较健康',
change: '-1.2kg'
})
}
})
defineEmits(['view-history', 'view-report'])
</script>
<style>
<style lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-body-report.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@@ -1,4 +1,4 @@
<template>
<template>
<view class="booking-section">
<view class="booking-section__inner">
<view class="booking-section__header">
@@ -10,108 +10,256 @@
: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>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
items: {
type: Array,
default: () => []
}
},
emits: ['view-all', 'item-tap'],
computed: {
previewItems() {
return this.items
}
<script setup>
import { computed } from 'vue'
const props = defineProps({
items: {
type: Array,
default: () => []
}
}
})
defineEmits(['view-all', 'item-tap'])
const displayItems = computed(() => {
return props.items || []
})
</script>
<style>
<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>
@@ -1,4 +1,4 @@
<template>
<template>
<view class="checkin-section">
<view class="checkin-section__inner">
<view class="checkin-section__header">
@@ -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"
@@ -64,24 +68,31 @@
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
items: {
type: Array,
default: () => []
}
},
emits: ['view-all', 'item-tap']
}
<script setup>
defineProps({
items: {
type: Array,
default: () => []
}
})
defineEmits(['view-all', 'item-tap'])
</script>
<style>
<style lang="scss">
@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,4 +1,4 @@
<template>
<template>
<view class="coupon-section">
<view class="coupon-section__inner">
<view class="coupon-section__header">
@@ -55,30 +55,25 @@
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
data: {
type: Object,
default: () => ({
amount: '¥50',
couponDesc: '满500可用 · 1张',
couponAction: '去使用',
points: 1250,
pointsLabel: '我的积分',
pointsAction: '去兑换'
})
}
},
emits: ['view-all', 'use-coupon', 'redeem-points']
}
<script setup>
defineProps({
data: {
type: Object,
default: () => ({
amount: '¥50',
couponDesc: '满500可用 · 1张',
couponAction: '去使用',
points: 1250,
pointsLabel: '我的积分',
pointsAction: '去兑换'
})
}
})
defineEmits(['view-all', 'use-coupon', 'redeem-points'])
</script>
<style>
<style lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-coupon-points.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@@ -1,151 +1,186 @@
<template>
<template>
<view class="profile-header">
<!-- 顶栏白底居中标题左侧放通知/设置右侧留胶囊安全区 -->
<view class="profile-header__toolbar" :style="toolbarStyle">
<view class="profile-header__nav">
<view class="profile-header__nav-left">
<!-- 登录状态 -->
<view class="profile-card" v-if="isLogin" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="handleUserTap">
<view class="profile-card__main">
<view class="profile-avatar">
<image
class="profile-header__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"
class="profile-avatar__img"
:key="userInfo.avatar"
:src="displayAvatar"
mode="aspectFill"
/>
</view>
<text class="profile-header__title">个人中心</text>
<view class="profile-header__nav-right" :style="navRightStyle"></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-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="$emit('user-info')">
<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 profile-card--guest" v-else @tap="handleGuestLogin">
<view class="profile-card__main">
<view class="profile-avatar">
<image class="profile-avatar__img" src="/static/OIP-C.png" mode="aspectFill" />
</view>
<view class="profile-header__stats">
<view class="profile-header__stats-inner">
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.checkInCount }}</text>
<text class="profile-header__stat-label">累计签到</text>
</view>
</view>
<view class="profile-header__stat-divider"></view>
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.trainingHours }}</text>
<text class="profile-header__stat-label">训练时长</text>
</view>
</view>
<view class="profile-header__stat-divider"></view>
<view class="profile-header__stat">
<view class="profile-header__stat-inner">
<text class="profile-header__stat-value">{{ stats.pointsBalance }}</text>
<text class="profile-header__stat-label">累计积分</text>
</view>
</view>
</view>
<view class="profile-info">
<text class="profile-info__name">小伙伴点我登录</text>
<text class="profile-info__phone">陪你遇见更好的自己 ~</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
userInfo: { type: Object, required: true },
stats: { type: Object, required: true }
},
emits: ['user-info'],
computed: {
displayAvatar() {
return this.userInfo.avatar || 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AvatarEditWrap.png'
}
},
data() {
return {
toolbarStyle: {},
toolbarSpacerStyle: {},
navRightStyle: {}
}
},
mounted() {
this.syncNavSafeArea()
},
methods: {
syncNavSafeArea() {
try {
const sys = uni.getSystemInfoSync()
const statusBarHeight = sys.statusBarHeight || 0
const navHeight = 44
const menu = uni.getMenuButtonBoundingClientRect?.()
<script setup>
import { computed } from 'vue'
this.toolbarStyle = {
paddingTop: `${statusBarHeight}px`
}
const props = defineProps({
userInfo: { type: Object, required: true },
stats: { type: Object, required: true },
isLogin: { type: Boolean }
})
this.toolbarSpacerStyle = {
height: `${statusBarHeight + navHeight}px`
}
const emit = defineEmits(['user-info', 'guest-login'])
if (menu && menu.width) {
const capsuleGap = sys.windowWidth - menu.left + 8
this.navRightStyle = {
width: `${capsuleGap}px`,
minWidth: `${capsuleGap}px`
}
}
} catch (e) {
this.toolbarSpacerStyle = { height: '44px' }
}
}
const displayAvatar = computed(() => {
return props.userInfo.avatar || '/static/OC-default-headIamge.png'
})
function handleUserTap() {
if (props.isLogin) {
emit('user-info')
}
}
function handleGuestLogin() {
emit('guest-login')
}
</script>
<style>
@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,4 +1,4 @@
<template>
<template>
<view class="logout-section">
<view
class="logout-section__btn"
@@ -16,16 +16,10 @@
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
emits: ['logout']
}
<script setup>
defineEmits(['logout'])
</script>
<style>
<style lang="scss">
@import '@/common/style/memberInfo/member-info-logout.css';
</style>
@@ -1,135 +0,0 @@
<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__link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="$emit('view-all')"
>
<text
class="member-card-section__link-text"
>
查看全部
</text>
<image class="member-card-section__link-arrow" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright12.png" mode="aspectFit" />
</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.name }}
</text>
</view>
<view
class="member-card-preview__tag"
>
<text class="member-card-preview__tag-text">
{{ cardInfo.detailTag || '详情' }}
</text>
</view>
</view>
</view>
<text class="member-card-preview__expire">
{{ cardInfo.expireDate }}
</text>
<view class="member-card-preview__footer">
<view class="member-card-preview__footer-inner">
<view
class="member-card-preview__days"
>
<text
class="member-card-preview__days-num"
>
{{ cardInfo.remainingDays }}
</text>
<text
class="member-card-preview__days-unit"
>
天剩余
</text>
</view>
<view
class="member-card-preview__renew"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap.stop="$emit('renew')"
>
<text
class="member-card-preview__renew-text"
>
续费
</text>
</view>
</view>
</view>
</view>
</view>
<view class="member-card-tip">
<view class="member-card-tip__inner">
<view class="member-card-tip__content">
<image class="member-card-tip__icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/clock1.png" mode="aspectFit" />
<text
class="member-card-tip__text"
>
{{ cardInfo.tip }}
</text>
</view>
</view>
<view class="member-card-tip__border"></view>
</view>
</view>
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
cardInfo: { type: Object, required: true }
},
emits: ['view-all', 'renew'],
}
</script>
<style>
@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';
</style>
@@ -1,63 +1,10 @@
<template>
<template>
<view class="quick-actions">
<view class="quick-actions__inner">
<view class="quick-actions__grid">
<view class="quick-actions__grid-inner">
<view
v-for="item in row1"
:key="item.key"
class="quick-actions__item"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="$emit('action', item.key)"
>
<view class="quick-actions__item-inner">
<view class="quick-actions__icon-wrap">
<view class="quick-actions__icon-wrap-inner">
<view v-if="item.key === 'booking'" class="quick-actions__icon">
<image
class="quick-actions__icon-part"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_490.png"
mode="aspectFit"
/>
<image
class="quick-actions__icon-part"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_491.png"
mode="aspectFit"
/>
<view class="quick-actions__border-wrap">
<view class="quick-actions__rect"></view>
<view class="quick-actions__border"></view>
</view>
<image
class="quick-actions__icon-part"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_493.png"
mode="aspectFit"
/>
<image
class="quick-actions__icon-part"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_494.png"
mode="aspectFit"
/>
</view>
<image
v-else
class="quick-actions__icon-img"
:src="item.icon"
mode="aspectFit"
/>
</view>
</view>
<text :class="item.textClass">{{ item.label }}</text>
</view>
</view>
</view>
</view>
<view class="quick-actions__divider"></view>
<view class="quick-actions__grid">
<view class="quick-actions__grid-inner">
<view
v-for="item in row2"
v-for="item in actions"
:key="item.key"
class="quick-actions__item"
hover-class="mi-tap--hover"
@@ -83,33 +30,18 @@
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
emits: ['action'],
data() {
return {
row1: [
{ key: 'booking', label: '预约课程', textClass: 'quick-actions__title', icon: '' },
{ key: 'bodyTest', label: '智能体测', textClass: 'quick-actions__title-2', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/mappin2.png' },
{ key: 'bodyReport', label: '体测报告', textClass: 'quick-actions__title-3', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/activity.png' },
{ key: 'trainReport', label: '训练报告', textClass: 'quick-actions__coach', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/usercheck.png' }
],
row2: [
{ key: 'coupon', label: '我的优惠券', textClass: 'quick-actions__text', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/ticket.png' },
{ key: 'points', label: '我的积分', textClass: 'quick-actions__points-desc', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/star.png' },
{ key: 'referral', label: '邀请好友', textClass: 'quick-actions__title-4', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/share2.png' },
{ key: 'course', label: '我的课程', textClass: 'quick-actions__text-2', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/play.png' }
]
}
}
}
<script setup>
import { ref } from 'vue'
defineEmits(['action'])
const actions = ref([
{ key: 'coupon', label: '我的优惠券', textClass: 'quick-actions__title', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/ticket.png' },
{ key: 'points', label: '我的积分', textClass: 'quick-actions__points-desc', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/star.png' }
])
</script>
<style>
<style lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-quick-actions.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@@ -1,4 +1,4 @@
<template>
<template>
<view class="referral-section">
<view class="referral-section__inner">
<view class="referral-section__header">
@@ -63,38 +63,34 @@
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
data: {
type: Object,
default: () => ({
code: 'FIT-ZXF-2024',
invited: 5,
registered: 3,
purchased: 2
})
}
},
emits: ['view-rules'],
methods: {
copyCode() {
uni.setClipboardData({
data: this.data.code,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
}
})
}
<script setup>
import { ref } from 'vue'
const props = defineProps({
data: {
type: Object,
default: () => ({
code: 'FIT-ZXF-2024',
invited: 5,
registered: 3,
purchased: 2
})
}
})
defineEmits(['view-rules'])
function copyCode() {
uni.setClipboardData({
data: props.data.code,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
}
})
}
</script>
<style>
<style lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-referral.css';
@import '@/common/style/memberInfo/member-info-tap.css';
@@ -1,4 +1,4 @@
<template>
<template>
<view class="settings-section">
<view class="settings-section__inner">
<text class="settings-section__title">设置与安全</text>
@@ -55,55 +55,35 @@
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
<script setup>
import { ref } from 'vue'
defineEmits(['setting'])
const items = ref([
{
key: 'password',
label: '修改密码',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_727.png',
iconWrapClass: 'settings-section__item-icon-wrap--blue'
},
emits: ['setting'],
data() {
return {
items: [
{
key: 'notify',
label: '通知设置',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/bell.png',
iconWrapClass: ''
},
{
key: 'password',
label: '修改密码',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_727.png',
iconWrapClass: 'settings-section__item-icon-wrap--blue'
},
{
key: 'privacy',
label: '隐私政策',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/shield.png',
iconWrapClass: 'settings-section__item-icon-wrap--green'
},
{
key: 'nfc',
label: 'NFC 门禁卡',
subtitle: '已绑定',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/ticket.png',
iconWrapClass: ''
},
{
key: 'delete',
label: '注销账户',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/userx.png',
iconWrapClass: 'settings-section__item-icon-wrap--red',
labelClass: 'settings-section__item-label--danger'
}
]
}
{
key: 'privacy',
label: '隐私政策',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/shield.png',
iconWrapClass: 'settings-section__item-icon-wrap--green'
},
{
key: 'delete',
label: '注销账户',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/userx.png',
iconWrapClass: 'settings-section__item-icon-wrap--red',
labelClass: 'settings-section__item-label--danger'
}
}
])
</script>
<style>
<style lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-settings.css';
</style>
@@ -1,4 +1,4 @@
<template>
<template>
<view class="status-bar">
<view class="status-bar__inner">
<text class="status-bar__time">{{ statusBarTime }}</text>
@@ -7,14 +7,8 @@
</view>
</template>
<script>
export default {
options: {
virtualHost: true,
styleIsolation: 'apply-shared'
},
props: {
statusBarTime: { type: String, default: '9:41' }
},
}
<script setup>
defineProps({
statusBarTime: { type: String, default: '9:41' }
})
</script>
@@ -1,4 +1,4 @@
<template>
<template>
<view class="sub-nav">
<view class="sub-nav__toolbar" :style="toolbarStyle">
<view class="sub-nav__nav">
@@ -27,80 +27,75 @@
</view>
</template>
<script>
export default {
options: {
virtualHost: false,
styleIsolation: 'apply-shared'
},
props: {
title: { type: String, required: true },
rightText: { type: String, default: '' },
actionButton: { type: Boolean, default: false }
},
emits: ['back', 'right-action'],
data() {
return {
toolbarStyle: {},
toolbarSpacerStyle: {},
capsuleStyle: {},
isH5: false
<script setup>
import { ref, onMounted, nextTick } from 'vue'
const props = defineProps({
title: { type: String, required: true },
rightText: { type: String, default: '' },
actionButton: { type: Boolean, default: false }
})
defineEmits(['back', 'right-action'])
const toolbarStyle = ref({})
const toolbarSpacerStyle = ref({})
const capsuleStyle = ref({})
const isH5 = ref(false)
function syncNavSafeArea() {
try {
const sys = uni.getSystemInfoSync()
const statusBarHeight = sys.statusBarHeight || 0
const navHeight = 44
const extraGap = 4
const menu = uni.getMenuButtonBoundingClientRect?.()
// #ifdef H5
isH5.value = true
// #endif
// #ifndef H5
isH5.value = false
// #endif
if (!isH5.value && typeof window !== 'undefined' && !menu?.width) {
isH5.value = sys.uniPlatform === 'web' || sys.platform === 'web'
}
},
mounted() {
this.syncNavSafeArea()
this.$nextTick(() => {
setTimeout(() => this.syncNavSafeArea(), 50)
})
},
methods: {
syncNavSafeArea() {
try {
const sys = uni.getSystemInfoSync()
const statusBarHeight = sys.statusBarHeight || 0
const navHeight = 44
const extraGap = 4
const menu = uni.getMenuButtonBoundingClientRect?.()
// #ifdef H5
this.isH5 = true
// #endif
// #ifndef H5
this.isH5 = false
// #endif
if (!this.isH5 && typeof window !== 'undefined' && !menu?.width) {
this.isH5 = sys.uniPlatform === 'web' || sys.platform === 'web'
}
this.toolbarStyle = {
paddingTop: `${statusBarHeight}px`
}
toolbarStyle.value = {
paddingTop: `${statusBarHeight}px`
}
this.toolbarSpacerStyle = {
height: `${statusBarHeight + navHeight + extraGap}px`
}
toolbarSpacerStyle.value = {
height: `${statusBarHeight + navHeight + extraGap}px`
}
if (!this.isH5 && menu && menu.width) {
const capsuleGap = sys.windowWidth - menu.left + 8
this.capsuleStyle = {
width: `${capsuleGap}px`,
minWidth: `${capsuleGap}px`
}
} else {
this.capsuleStyle = {
width: '0px',
minWidth: '0px'
}
}
} catch (e) {
this.toolbarSpacerStyle = { height: '44px' }
this.isH5 = true
this.capsuleStyle = { width: '0px', minWidth: '0px' }
if (!isH5.value && menu && menu.width) {
const capsuleGap = sys.windowWidth - menu.left + 8
capsuleStyle.value = {
width: `${capsuleGap}px`,
minWidth: `${capsuleGap}px`
}
} else {
capsuleStyle.value = {
width: '0px',
minWidth: '0px'
}
}
} catch (e) {
toolbarSpacerStyle.value = { height: '44px' }
isH5.value = true
capsuleStyle.value = { width: '0px', minWidth: '0px' }
}
}
onMounted(() => {
syncNavSafeArea()
nextTick(() => {
setTimeout(() => syncNavSafeArea(), 50)
})
})
</script>
<style>
<style lang="scss">
@import '@/common/style/memberInfo/member-info-sub-nav.css';
</style>
@@ -0,0 +1,326 @@
<template>
<!-- 推荐团课卡片 -->
<view class="recommend-card" :style="cardStyle" @click="handleCardClick">
<!-- 卡片头部图片 + 推荐标题 -->
<view class="card-header">
<!-- 团课封面 -->
<view class="course-image-wrapper">
<image :src="courseImage" mode="aspectFill" class="course-image" />
<view class="image-overlay"></view>
<!-- 状态标签 -->
<text :class="['status-tag', tagType]">{{ tagText }}</text>
</view>
<!-- 推荐标题区 -->
<view class="header-info">
<text class="recommend-title">{{ recommend.recommendTitle || '推荐课程' }}</text>
<text class="recommend-content">{{ recommend.recommendContent }}</text>
</view>
</view>
<!-- 课程信息栏 -->
<view class="info-bar">
<view class="info-item">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/time.png" class="info-icon" />
<text class="info-text">{{ formattedTime }}</text>
</view>
<view class="info-item" v-if="courseLocation">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/intensity.png" class="info-icon" />
<text class="info-text">{{ courseLocation }}</text>
</view>
<view class="info-item">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/hot.png" class="info-icon" />
<text class="info-text">{{ courseCurrentMembers || 0 }}/{{ courseMaxMembers || 0 }}</text>
</view>
</view>
<!-- 推荐理由 -->
<view class="card-reason" v-if="recommend.recommendReason">
<text class="reason-text">{{ recommend.recommendReason }}</text>
</view>
<!-- 卡片底部操作按钮 -->
<view class="card-footer" @click.stop="handleJoinCourse">
<view class="action-btn">
<text>立即预约</text>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
import { getCourseCoverUrl } from '@/utils/request.js'
const props = defineProps({
recommend: {
type: Object,
required: true,
default: () => ({})
},
width: {
type: [Number, String],
default: '100%',
description: '卡片宽度'
},
borderRadius: {
type: [Number, String],
default: 24,
description: '卡片圆角,单位 rpx'
}
})
// 计算卡片样式
const cardStyle = computed(() => {
const style = {}
if (props.width) {
style.width = typeof props.width === 'number' ? `${props.width}rpx` : props.width
}
if (props.borderRadius) {
style.borderRadius = typeof props.borderRadius === 'number' ? `${props.borderRadius}rpx` : props.borderRadius
}
return style
})
// 团课信息
const courseStartTime = computed(() => props.recommend.groupCourse?.startTime)
const courseEndTime = computed(() => props.recommend.groupCourse?.endTime)
const courseMaxMembers = computed(() => props.recommend.groupCourse?.maxMembers)
const courseCurrentMembers = computed(() => props.recommend.groupCourse?.currentMembers)
const courseLocation = computed(() => props.recommend.groupCourse?.location)
const courseId = computed(() => props.recommend.groupCourse?.id)
// 课程封面图片
const courseImage = computed(() => {
const coverImage = props.recommend.groupCourse?.coverImage
const url = getCourseCoverUrl(coverImage) || 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80'
console.log('[RecommendCourseCard] 封面URL:', {
'courseName': props.recommend.groupCourse?.courseName,
'原始coverImage': coverImage,
'转换后URL': url,
'是否完整URL': url && url.startsWith('http')
})
return url
})
// 课程标签文本
const tagText = computed(() => {
const current = courseCurrentMembers.value || 0
const max = courseMaxMembers.value || 0
if (current >= max) return '已满员'
if (props.recommend.groupCourse?.status === 2) return '已结束'
if (props.recommend.groupCourse?.status === 1) return '已取消'
if (max > 0 && current / max >= 0.8) return '热门'
return '推荐'
})
// 课程标签类型
const tagType = computed(() => {
const current = courseCurrentMembers.value || 0
const max = courseMaxMembers.value || 0
if (current >= max) return 'full'
if (props.recommend.groupCourse?.status === 2) return 'ended'
if (props.recommend.groupCourse?.status === 1) return 'ended'
if (max > 0 && current / max >= 0.8) return 'hot'
return 'default'
})
// 格式化课程时间
const formattedTime = computed(() => {
if (!courseStartTime.value) return ''
const start = new Date(courseStartTime.value)
const date = `${start.getMonth() + 1}${start.getDate()}`
const time = `${String(start.getHours()).padStart(2, '0')}:${String(start.getMinutes()).padStart(2, '0')}`
if (courseEndTime.value) {
const end = new Date(courseEndTime.value)
const endTime = `${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}`
return `${date} ${time}-${endTime}`
}
return `${date} ${time}`
})
// 处理卡片点击(跳转到详情页)
const handleCardClick = () => {
if (!props.recommend.groupCourse) {
uni.showToast({ title: '课程信息不完整', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/groupCourse/detail?id=${courseId.value}` })
}
// 处理预约课程点击
const handleJoinCourse = () => {
if (props.recommend.groupCourse?.status === 2) {
uni.showToast({ title: '课程已结束', icon: 'none' })
return
}
if (props.recommend.groupCourse?.status === 1) {
uni.showToast({ title: '课程已取消', icon: 'none' })
return
}
const current = courseCurrentMembers.value || 0
const max = courseMaxMembers.value || 0
if (current >= max) {
uni.showToast({ title: '课程已满员', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/groupCourse/detail?id=${courseId.value}` })
}
</script>
<style lang="scss">
.recommend-card {
width: 100%;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 8rpx 32rpx rgba(45, 74, 90, 0.1);
border: 1rpx solid rgba(124, 181, 204, 0.2);
margin-bottom: 24rpx;
}
// 卡片头部:左图右文
.card-header {
display: flex;
gap: 24rpx;
padding: 24rpx;
}
.course-image-wrapper {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
overflow: hidden;
position: relative;
flex-shrink: 0;
}
.course-image {
width: 100%;
height: 100%;
}
.image-overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: linear-gradient(to top, rgba(45, 74, 90, 0.3) 0%, transparent 60%);
}
// 状态标签(图片左下角)
.status-tag {
position: absolute;
left: 8rpx;
bottom: 8rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
font-size: 18rpx;
font-weight: 600;
color: #ffffff;
background: linear-gradient(135deg, #7CB5CC, #9CCFDF);
&.hot {
background: linear-gradient(135deg, #FF8C69, #FFA07A);
}
&.full {
background: linear-gradient(135deg, #A0B8C8, #B8CCD8);
}
&.ended {
background: linear-gradient(135deg, #C0CCD0, #D0D8DC);
}
}
// 推荐标题区
.header-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.recommend-title {
font-size: 32rpx;
font-weight: 700;
color: #2D4A5A;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recommend-content {
font-size: 24rpx;
color: #5A7A8A;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
// 信息栏:时间 | 地点 | 人数
.info-bar {
display: flex;
align-items: center;
gap: 24rpx;
padding: 0 24rpx 16rpx;
}
.info-item {
display: flex;
align-items: center;
gap: 6rpx;
}
.info-icon {
width: 28rpx;
height: 28rpx;
}
.info-text {
font-size: 22rpx;
color: #5A7A8A;
}
// 推荐理由
.card-reason {
margin: 0 24rpx;
padding: 16rpx;
background: rgba(124, 181, 204, 0.06);
border-radius: 12rpx;
border-left: 4rpx solid #7CB5CC;
}
.reason-text {
font-size: 22rpx;
color: #6A8A9A;
line-height: 1.5;
}
// 卡片底部
.card-footer {
padding: 16rpx 24rpx 24rpx;
}
.action-btn {
width: 100%;
height: 72rpx;
background: linear-gradient(135deg, #82DC82, #66CC66);
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(130, 220, 130, 0.35);
text {
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
}
}
</style>
@@ -1,32 +1,30 @@
import { ref, computed } from 'vue'
import { groupCourseService } from '@/request_api/groupCourse.mock.js'
import { ref, computed, watch } from 'vue'
import { getGroupCoursePage, getTypeLabels, searchGroupCourse } from '@/api/groupCourse.js'
export function useGroupCourseList() {
// 分页相关
const pageNum = ref(1)
const pageNum = ref(0)
const pageSize = ref(10)
const total = ref(0)
const totalPages = ref(0)
const loading = ref(false)
const hasMore = ref(true)
// 团课列表数据
const courseList = ref([])
// 搜索相关
const searchKeyword = ref('')
const hotKeywords = ref(['燃脂', '瑜伽', '单车', '普拉提', '高强度'])
// 排序相关
const courseType = ref(null)
const courseTypes = ref([])
const sortOptions = ref([
{ label: '默认排序', value: 'default' },
{ label: '价格从低到高', value: 'priceAsc' },
{ label: '价格从高到低', value: 'priceDesc' },
{ label: '剩余名额最多', value: 'spotsDesc' }
{ label: '默认排序', value: 'default', priceSort: null, remainingMost: false },
{ label: '价格从低到高', value: 'priceAsc', priceSort: 'asc', remainingMost: false },
{ label: '价格从高到低', value: 'priceDesc', priceSort: 'desc', remainingMost: false },
{ label: '剩余名额最多', value: 'remainingMost', priceSort: null, remainingMost: true }
])
const sortIndex = ref(0)
// 时间段选择相关
const timePeriodOptions = ref([
{ label: '全部', value: 'all' },
{ label: '早上 (6-12 点)', value: 'morning', startHour: 6, endHour: 12 },
@@ -35,24 +33,25 @@ export function useGroupCourseList() {
])
const timePeriodIndex = ref(0)
// 时间筛选相关
const isRecurring = ref(false) // 常态化团课筛选
const showTimePicker = ref(false)
const startDate = ref('')
const endDate = ref('')
const timeRangeText = ref('')
// 筛选后的课程列表
const filteredCourseList = computed(() => {
let result = [...courseList.value]
// 关键词搜索
// 只显示可预约的团课(状态码为0,兼容字符串和数字)
result = result.filter(course => Number(course.status) === 0)
if (searchKeyword.value) {
result = result.filter(course =>
course.courseName.includes(searchKeyword.value)
)
}
// 时间范围筛选
if (startDate.value || endDate.value) {
result = result.filter(course => {
const courseDate = course.startTime.split('T')[0]
@@ -62,7 +61,6 @@ export function useGroupCourseList() {
})
}
// 时间段筛选
const timePeriod = timePeriodOptions.value[timePeriodIndex.value]
if (timePeriod.value !== 'all') {
result = result.filter(course => {
@@ -71,20 +69,25 @@ export function useGroupCourseList() {
})
}
// 排序
const sortType = sortOptions.value[sortIndex.value].value
if (sortType === 'priceAsc') {
result.sort((a, b) => (a.storedValueAmount || a.pointCardAmount) - (b.storedValueAmount || b.pointCardAmount))
} else if (sortType === 'priceDesc') {
result.sort((a, b) => (b.storedValueAmount || b.pointCardAmount) - (a.storedValueAmount || a.pointCardAmount))
} else if (sortType === 'spotsDesc') {
result.sort((a, b) => (b.maxMembers - b.currentMembers) - (a.maxMembers - a.currentMembers))
const sortType = sortOptions.value[sortIndex.value]
if (sortType.value !== 'id' || sortType.order !== 'asc') {
result.sort((a, b) => {
const valA = a[sortType.value] || 0
const valB = b[sortType.value] || 0
return sortType.order === 'asc' ? valA - valB : valB - valA
})
}
return result
})
// 获取所有搜索参数
// 监听排序方式变化,重新获取数据
watch(sortIndex, () => {
console.log('[useGroupCourseList] 排序方式变化,触发重新查询')
pageNum.value = 0
fetchCourseList()
})
const getAllSearchParams = (searchBarRef, filterSectionRef, timePeriodRef, timeRangePickerRef) => {
const searchParams = searchBarRef?.getSearchParams?.() || { keyword: searchKeyword.value }
const filterParams = filterSectionRef?.getFilterParams?.() || { sortType: sortOptions.value[sortIndex.value].value }
@@ -102,36 +105,46 @@ export function useGroupCourseList() {
return allParams
}
// 搜索处理
const handleSearch = (params) => {
console.log('[useGroupCourseList] 搜索触发:', params)
uni.showToast({
title: params.keyword ? `搜索:${params.keyword}` : '请输入关键词',
icon: 'none'
})
searchKeyword.value = params.keyword || ''
pageNum.value = 0
fetchCourseList()
}
// 时间段变化处理
const onTimePeriodChange = (option) => {
console.log('[useGroupCourseList] 时间段选择:', option)
pageNum.value = 0
fetchCourseList()
}
// 时间范围确认处理
const onTimeRangeConfirm = (params) => {
console.log('[useGroupCourseList] 时间范围确认:', params)
startDate.value = params.startDate || ''
endDate.value = params.endDate || ''
timeRangeText.value = params.timeRangeText
}
// 预约处理
const onCourseTypeChange = (typeId) => {
console.log('[useGroupCourseList] 课程类型选择:', typeId)
courseType.value = typeId
pageNum.value = 0
fetchCourseList()
}
const clearCourseType = () => {
courseType.value = null
pageNum.value = 0
fetchCourseList()
}
const handleBooking = (course) => {
console.log('[useGroupCourseList] 预约课程:', course)
uni.showToast({
title: `预约课程:${course.courseName}`,
icon: 'success'
uni.navigateTo({
url: `/pages/groupCourse/detail?id=${course.id}`
})
}
// 跳转详情
const goDetail = (courseId) => {
console.log('[useGroupCourseList] 跳转到课程详情:', courseId)
uni.navigateTo({
@@ -139,20 +152,82 @@ export function useGroupCourseList() {
})
}
// 获取团课列表
const buildSearchParams = () => {
const sortOption = sortOptions.value[sortIndex.value]
const timePeriod = timePeriodOptions.value[timePeriodIndex.value]
const params = {
page: pageNum.value,
size: pageSize.value
}
if (searchKeyword.value) {
params.courseName = searchKeyword.value
}
if (courseType.value) {
params.courseType = courseType.value
}
if (startDate.value) {
params.startDate = startDate.value
}
if (endDate.value) {
params.endDate = endDate.value
}
if (timePeriod.value && timePeriod.value !== 'all') {
params.timePeriod = timePeriod.value
}
if (isRecurring.value) {
params.isRecurring = true
}
if (sortOption.priceSort) {
params.priceSort = sortOption.priceSort
}
if (sortOption.remainingMost) {
params.remainingMost = sortOption.remainingMost
}
return params
}
const hasActiveFilters = () => {
return searchKeyword.value ||
courseType.value ||
startDate.value ||
endDate.value ||
timePeriodOptions.value[timePeriodIndex.value].value !== 'all' ||
sortIndex.value !== 0
}
const fetchCourseList = async (isLoadMore = false) => {
if (loading.value) return
loading.value = true
try {
const result = await groupCourseService.getList({
pageNum: pageNum.value,
pageSize: pageSize.value
})
const currentPage = isLoadMore ? pageNum.value + 1 : pageNum.value
pageNum.value = currentPage
if (result.code === 0 && result.data) {
const { list, total: totalCount, pageNum: currentPage, totalPages: pages } = result.data
const searchParams = buildSearchParams()
searchParams.page = currentPage
console.log('[useGroupCourseList] 请求参数:', JSON.stringify(searchParams, null, 2))
const result = await searchGroupCourse(searchParams)
console.log('[useGroupCourseList] 响应结果:', JSON.stringify(result, null, 2))
// 兼容后端统一响应格式 { data/result, message, success }
const wrapped = result?.result || result?.data || result
if (wrapped && wrapped.content) {
const { content: list, totalElements: totalCount, totalPages: pages } = wrapped
if (isLoadMore) {
courseList.value = [...courseList.value, ...list]
@@ -161,9 +236,8 @@ export function useGroupCourseList() {
}
total.value = totalCount
pageNum.value = currentPage
totalPages.value = pages
hasMore.value = pageNum.value < totalPages.value
hasMore.value = currentPage < pages - 1
console.log('[useGroupCourseList] 团课列表获取成功:', {
total: total.value,
@@ -172,29 +246,41 @@ export function useGroupCourseList() {
hasMore: hasMore.value
})
} else {
console.error('[useGroupCourseList] 获取团课列表失败:', result.message)
console.error('[useGroupCourseList] 获取团课列表失败:', {
result: result,
message: result?.message || '未知错误',
code: result?.code,
success: result?.success
})
}
} catch (error) {
console.error('[useGroupCourseList] 获取团课列表异常:', error)
console.error('[useGroupCourseList] 获取团课列表异常 - 错误详情:', {
error: error,
message: error?.message || '无错误信息',
code: error?.code,
statusCode: error?.statusCode,
response: error?.response,
stack: error?.stack
})
uni.showToast({
title: `获取课程列表失败: ${error?.message || '网络错误'}`,
icon: 'none'
})
} finally {
loading.value = false
}
}
// 加载更多
const loadMore = () => {
if (!hasMore.value || loading.value) return
pageNum.value++
fetchCourseList(true)
}
// 滚动到底部触发
const onScrollToLower = () => {
loadMore()
}
return {
// 状态
pageNum,
pageSize,
total,
@@ -204,25 +290,31 @@ export function useGroupCourseList() {
courseList,
searchKeyword,
hotKeywords,
courseType,
courseTypes,
sortOptions,
sortIndex,
timePeriodOptions,
timePeriodIndex,
isRecurring,
showTimePicker,
startDate,
endDate,
timeRangeText,
filteredCourseList,
// 方法
getAllSearchParams,
handleSearch,
onTimePeriodChange,
onTimeRangeConfirm,
onCourseTypeChange,
clearCourseType,
handleBooking,
goDetail,
fetchCourseList,
buildSearchParams,
hasActiveFilters,
loadMore,
onScrollToLower
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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": "错误描述信息"
}
```
---
*文档结束*
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,4 +1,4 @@
import App from './App'
import App from './App'
// #ifndef VUE3
import Vue from 'vue'
@@ -30,4 +30,4 @@ export function createApp() {
app
}
}
// #endif
// #endif
+64 -7
View File
@@ -1,9 +1,9 @@
{
"name" : "gym-manage-uniapp",
"appid" : "__UNI__1F1874C",
"name" : "活氧舱",
"appid" : "__UNI__52E2F0D",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"versionName" : "1.0.1",
"versionCode" : 101,
"transformPx" : false,
/* 5+App */
"app-plus" : {
@@ -17,7 +17,10 @@
"delay" : 0
},
/* */
"modules" : {},
"modules" : {
"Barcode" : {},
"OAuth" : {}
},
/* */
"distribute" : {
/* android */
@@ -41,9 +44,63 @@
]
},
/* ios */
"ios" : {},
"ios" : {
"dSYMs" : false
},
/* SDK */
"sdkConfigs" : {}
"sdkConfigs" : {
"oauth" : {
"univerify" : {}
}
},
"icons" : {
"android" : {
"hdpi" : "unpackage/res/icons/72x72.png",
"xhdpi" : "unpackage/res/icons/96x96.png",
"xxhdpi" : "unpackage/res/icons/144x144.png",
"xxxhdpi" : "unpackage/res/icons/192x192.png"
},
"ios" : {
"appstore" : "unpackage/res/icons/1024x1024.png",
"ipad" : {
"app" : "unpackage/res/icons/76x76.png",
"app@2x" : "unpackage/res/icons/152x152.png",
"notification" : "unpackage/res/icons/20x20.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"proapp@2x" : "unpackage/res/icons/167x167.png",
"settings" : "unpackage/res/icons/29x29.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"spotlight" : "unpackage/res/icons/40x40.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png"
},
"iphone" : {
"app@2x" : "unpackage/res/icons/120x120.png",
"app@3x" : "unpackage/res/icons/180x180.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"notification@3x" : "unpackage/res/icons/60x60.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"settings@3x" : "unpackage/res/icons/87x87.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png",
"spotlight@3x" : "unpackage/res/icons/120x120.png"
}
}
}
},
"nativePlugins" : {
"AliCloud-NirvanaPns" : {
"__plugin_info__" : {
"name" : "阿里云号码认证SDK",
"description" : "阿里云号码认证SDK,包含一键登录和本机号码校验两个功能。",
"platforms" : "Android,iOS",
"url" : "https://ext.dcloud.net.cn/plugin?id=4297",
"android_package_name" : "gym.manage.uniapp",
"ios_bundle_id" : "",
"isCloud" : true,
"bought" : 1,
"pid" : "4297",
"parameters" : {}
}
}
}
},
/* */
+34 -37
View File
@@ -1,19 +1,28 @@
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationStyle":"custom",
"navigationBarTitleText": "健身房",
"app-plus": {
"animationType": "fade-in",
"animationDuration": 200
}
"app-plus": {
"animationType": "fade-in",
"animationDuration": 200
}
}
},
{
"path": "pages/memberInfo/login",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/course/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "课程",
"app-plus": {
"animationType": "fade-in",
@@ -21,26 +30,7 @@
}
}
},
{
"path": "pages/train/index",
"style": {
"navigationBarTitleText": "训练",
"app-plus": {
"animationType": "fade-in",
"animationDuration": 200
}
}
},
{
"path": "pages/discover/index",
"style": {
"navigationBarTitleText": "发现",
"app-plus": {
"animationType": "fade-in",
"animationDuration": 200
}
}
},
{
"path": "pages/memberInfo/memberInfo",
"style": {
@@ -52,13 +42,6 @@
}
}
},
{
"path": "pages/memberInfo/memberCard",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的会员卡"
}
},
{
"path": "pages/memberInfo/userInfo",
"style": {
@@ -237,12 +220,14 @@
{
"path": "pages/checkIn/checkIn",
"style": {
"navigationBarTitleText": "会员签到"
}
},
{
"path": "pages/groupCourse/list",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "团课列表"
}
},
@@ -260,9 +245,10 @@
}
},
{
"path": "pages/searchCourse/searchCourse",
"path": "pages/recommendCourses/index",
"style": {
"navigationBarTitleText": "搜索课程"
"navigationBarTitleText": "推荐课程",
"navigationStyle": "custom"
}
},
{
@@ -270,6 +256,19 @@
"style": {
"navigationBarTitleText": ""
}
},
{
"path": "pages/message/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/message/detail",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "消息详情"
}
}
],
"globalStyle": {
@@ -284,13 +283,11 @@
},
"uniIdRouter": {},
"tabBar": {
"custom": true, // 启用自定义 tabBar
"custom": true,
"list": [
{ "pagePath": "pages/index/index", "text": "首页" },
{ "pagePath": "pages/course/index", "text": "课程" },
{ "pagePath": "pages/train/index", "text": "训练" },
{ "pagePath": "pages/discover/index", "text": "发现" },
{ "pagePath": "pages/memberInfo/memberInfo", "text": "我的" }
]
}
}
}
@@ -1,4 +1,4 @@
<!-- components/LoadingOverlay.vue -->
<!-- components/LoadingOverlay.vue -->
<template>
<view v-if="visible" class="loading-overlay">
<view class="loading-content">
+117 -143
View File
@@ -35,7 +35,8 @@
<view class="QR">
<image :src="image" mode="" :style="{width: Math.min(width, 500) + 'rpx',height: Math.min(height, 500) + 'rpx' } "></image>
</view>
<view v-if="!image || STQRC" class="loadingBox" :style="{width: Math.min(width, 500) + 'rpx',height: Math.min(height, 500) + 'rpx' }">
<!-- 加载动画区域 - 设置最小尺寸确保进入页面时可见 -->
<view v-if="(!image || STQRC) && isHaveCard" class="loadingBox" :style="{width: (width > 0 ? Math.min(width, 500) : 500) + 'rpx',height: (height > 0 ? Math.min(height, 500) : 500) + 'rpx' }">
<view class="loading-spinner">
<view v-if="!isCheckIn" class="spinner-circle"></view>
<view v-else>
@@ -46,7 +47,7 @@
</view>
<!-- 二维码装饰边框 -->
<view v-else class="qr-border" :style="{width: Math.min(width, 500) + 80 + 'rpx',height: Math.min(height, 500) + 80 + 'rpx' }">
<view v-else-if="image" class="qr-border" :style="{width: Math.min(width, 500) + 80 + 'rpx',height: Math.min(height, 500) + 80 + 'rpx' }">
<view class="corner top-left"></view>
<view class="corner top-right"></view>
<view class="corner bottom-left"></view>
@@ -56,7 +57,7 @@
<!-- 状态组件传递状态和自定义错误文案- 已签到时不显示 -->
<QrStatus
v-if="!isCheckIn"
v-if="isHaveCard && !isCheckIn"
:status="status"
:errorText="errorText"
/>
@@ -81,19 +82,12 @@
</view>
</view>
<!-- 底部按钮z-index永久置顶 -->
<!-- 底部按钮 -->
<view class="bottom-actions">
<button @tap="handleLongPress">签到</button>
<button class="btn-refresh" @tap="refreshQR">
<uni-icons type="refresh" size="36rpx" color="#5E6F8D"></uni-icons>
<text>刷新二维码</text>
</button>
<!-- 测试用手动清除缓存按钮 -->
<button class="btn-clear-cache" @tap="handleClearCache">
<uni-icons type="trash" size="36rpx" color="#ef4444"></uni-icons>
<text>清除缓存</text>
</button>
</view>
</view>
</template>
@@ -103,8 +97,9 @@ import { ref } from 'vue';
import { onLoad, onUnload } from '@dcloudio/uni-app'
// 引入状态组件(路径与你保持一致)
import QrStatus from '@/components/QRCode/StatusCard.vue'
// 引入API封装
import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
import { qrSignInGroupCourse } from '@/api/groupCourse.js'
import { getMemberId } from '@/utils/request.js'
let image = ref("")
let width = ref(0)
@@ -115,7 +110,8 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
const QRStatus = ref("生成中...")
const STQRC = ref(false)//是否扫码
const isCheckIn = ref(false)
const webSoketURL = "ws://localhost:8084/webSocket/checkIn"
const isHaveCard = ref(true)
const webSoketURL = "ws://192.168.5.15:8084/webSocket/checkIn"
const qrcode = ref("")
@@ -209,115 +205,64 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
}
}
/**
* 清除所有与签到相关的缓存(用于测试阶段)
*/
const clearQRCache = () => {
try {
const keys = uni.getStorageInfoSync().keys || []
let clearedCount = 0
for (const key of keys) {
// 清除 QR_ 开头的缓存(页面内部缓存)
if (key.startsWith(CACHE_PREFIX)) {
uni.removeStorageSync(key)
clearedCount++
}
// 清除 API_CACHE_ 开头的缓存(通过 utils/cache.js 缓存的接口数据)
if (key.startsWith('API_CACHE_')) {
// 只清除与签到相关的 API 缓存
if (key.includes('checkIn') || key.includes('qrcode')) {
uni.removeStorageSync(key)
clearedCount++
}
}
}
console.log(`已清除 ${clearedCount} 个签到相关缓存`)
return clearedCount
} catch (e) {
console.error('清除 QR 缓存失败:', e)
return 0
}
}
/**
* 测试用:手动清除缓存按钮点击事件
*/
const handleClearCache = () => {
uni.showModal({
title: '清除缓存',
content: '确定要清除所有签到相关的缓存吗?(测试用)',
confirmText: '确定',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
const clearedCount = clearQRCache()
// 重置页面状态
image.value = ""
width.value = 0
height.value = 0
status.value = 'loading'
QRStatus.value = "生成中..."
STQRC.value = false
isCheckIn.value = false
uni.showToast({
title: `已清除 ${clearedCount} 个缓存`,
icon: 'success',
duration: 2000
})
// 重置页面状态后不再自动请求二维码
uni.hideLoading()
}
}
})
}
onLoad(() => {
onLoad(async () => {
uni.showLoading({
title: '生成签到二维码...',
title: '加载中...',
mask: true
})
// 读取签到状态缓存(自动检查过期)
// isCheckIn 代表签到状态,从缓存读取
const cachedIsCheckIn = getCacheData("isCheckIn")
// checkInTime 代表具体签到时间,从缓存读取(显示在 loading-text 中)
const cachedCheckInTime = getCacheData("checkInTime")
if(cachedIsCheckIn != null) {
console.log("进入缓存 - 签到状态")
isCheckIn.value = cachedIsCheckIn
STQRC.value = true
}
// 如果已经签到成功,直接显示成功状态,不需要请求后端
if(isCheckIn.value) {
console.log("已签到且有缓存,无需请求后端")
// 读取二维码图片缓存用于显示
const cachedQRInfo = getCacheData("QRInfo")
if(cachedQRInfo) {
image.value = cachedQRInfo.qrCodeBase64
width.value = cachedQRInfo.width * 2
height.value = cachedQRInfo.height * 2
try {
const memberId = getMemberId()
console.log('[签到页面] 会员ID:', memberId)
if (!memberId) {
throw new Error('未获取到会员ID')
}
// QRStatus 显示具体签到时间(从缓存读取,显示在 loading-text 中)
QRStatus.value = cachedCheckInTime || "已完成签到"
isHaveCard.value = true
uni.showLoading({
title: '生成签到二维码...',
mask: true
})
const cachedIsCheckIn = getCacheData("isCheckIn")
const cachedCheckInTime = getCacheData("checkInTime")
if(cachedIsCheckIn != null) {
console.log("进入缓存 - 签到状态")
isCheckIn.value = cachedIsCheckIn
STQRC.value = true
}
if(isCheckIn.value) {
console.log("已签到且有缓存,无需请求后端")
const cachedQRInfo = getCacheData("QRInfo")
if(cachedQRInfo) {
image.value = cachedQRInfo.qrCodeBase64
width.value = cachedQRInfo.width * 2
height.value = cachedQRInfo.height * 2
}
QRStatus.value = cachedCheckInTime || "已完成签到"
uni.hideLoading()
return
}
QRStatus.value = "生成中..."
getStorage(null)
} catch (err) {
console.error('[签到页面] 查询会员卡失败:', err)
uni.hideLoading()
return
uni.showToast({
title: '获取会员卡信息失败',
icon: 'none'
})
}
// 未签到或缓存失效,需要请求后端获取二维码
// QRStatus 重置为默认的请求状态
QRStatus.value = "生成中..."
getStorage(null)
})
// 页面卸载时关闭WebSocket连接(不清除缓存,让缓存自然过期)
onUnload(() => {
closeWebSocket()
// 缓存会在当日23:59:59自动过期,页面卸载时不主动清除
// 如需测试,使用页面上的"清除缓存"按钮手动清除
})
// 获取二维码接口
@@ -357,8 +302,10 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
return
}
// 清空图片,显示加载状态
image.value = ""
QRStatus.value = "正在刷新二维码..."
setTimeout(() => {
getStorage(null)
}, 500)
@@ -395,7 +342,7 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
onlyFromCamera: false,
scanType: ['qrCode'],
success: (res) => {
console.log(res)
console.log('扫码结果:', res)
checkIn(res.result)
},
fail: (err) => {
@@ -408,9 +355,53 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
})
}
// 手动签到接口
const checkIn = (qrContent) => {
console.log(qrContent)
// 手动签到接口(兼容到店签到和团课签到两种二维码)
const checkIn = async (qrContent) => {
console.log('扫码结果:', qrContent)
// 尝试解析为 JSON,判断是否为团课二维码(包含 id/courseName 字段)
let parsedQr = null
try {
parsedQr = JSON.parse(qrContent)
} catch (_) {
// 非 JSON,按到店签到处理
}
if (parsedQr && parsedQr.id && parsedQr.courseName) {
// 团课签到:使用团课签到接口
const memberId = getMemberId()
if (!memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
const courseId = Number(parsedQr.id)
uni.showLoading({ title: '团课签到中...' })
try {
const result = await qrSignInGroupCourse(courseId, memberId)
uni.hideLoading()
if (result.success) {
status.value = 'scanned'
errorText.value = ''
QRStatus.value = '团课签到成功'
isCheckIn.value = true
setCacheData("checkInTime", QRStatus.value)
setCacheData("isCheckIn", isCheckIn.value)
uni.showToast({ title: '团课签到成功', icon: 'success' })
} else {
status.value = 'error'
errorText.value = result.message || '团课签到失败'
uni.showToast({ title: result.message || '签到失败', icon: 'none' })
}
} catch (err) {
uni.hideLoading()
status.value = 'error'
errorText.value = err.message || '团课签到失败'
uni.showToast({ title: err.message || '签到失败', icon: 'none' })
}
return
}
// 到店签到:使用原有签到接口
apiCheckIn({ qrContent }).then(res => {
closeWebSocket()
console.log(res)
@@ -572,6 +563,7 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
socketTask = null
}
}
</script>
<style lang="scss" scoped>
@@ -590,13 +582,13 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
left: 0;
right: 0;
z-index: 99998; /* 低于底部按钮,确保底部按钮永远最上层 */
background: linear-gradient(135deg, #0B2B4B 0%, #1A4A6F 100%);
background: linear-gradient(135deg, #7AB5CC 0%, #9CCFDF 100%);
padding: 64rpx 48rpx 48rpx;
text-align: center;
color: #FFFFFF;
border-bottom-left-radius: 56rpx;
border-bottom-right-radius: 56rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
box-shadow: 0 4rpx 20rpx rgba(120, 185, 215, 0.3);
.header-title {
font-size: 45rpx;
@@ -632,7 +624,7 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
height: 120rpx;
border-radius: 999px;
overflow: hidden;
border: 4rpx solid #FF6B35;
border: 4rpx solid #7AB5CC;
flex-shrink: 0;
image {
@@ -664,7 +656,7 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
flex-wrap: wrap;
.level-badge {
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
background: linear-gradient(135deg, #7AB5CC 0%, #9CCFDF 100%);
color: white;
padding: 4rpx 16rpx;
border-radius: 24rpx;
@@ -691,7 +683,7 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
.points-value {
font-size: 38rpx;
font-weight: 700;
color: #FF6B35;
color: #7AB5CC;
}
.points-label {
@@ -732,7 +724,8 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
align-items: center;
margin: 0 auto 64rpx;
max-width: 100%;
min-height: 580rpx; /* 设置最小高度确保加载动画区域可见 */
.QR {
position: relative;
z-index: 2;
@@ -763,7 +756,7 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
width: 80rpx;
height: 80rpx;
border: 6rpx solid #E9EDF2;
border-top-color: #FF6B35;
border-top-color: #7AB5CC;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@@ -788,7 +781,7 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
position: absolute;
width: 48rpx;
height: 48rpx;
border: 6rpx solid #FF6B35;
border: 6rpx solid #7AB5CC;
&.top-left {
top: 0;
@@ -855,7 +848,7 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
.tip-dot {
width: 12rpx;
height: 12rpx;
background: #FF6B35;
background: #7AB5CC;
border-radius: 50%;
margin-top: 12rpx;
flex-shrink: 0;
@@ -902,25 +895,6 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
}
}
/* 测试用:清除缓存按钮 */
.btn-clear-cache {
width: calc(100% - 96rpx);
height: 88rpx;
background: #FEF2F2;
color: #EF4444;
border: 1rpx solid #FECACA;
border-radius: 999px;
font-size: 29rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
&:active {
background: #FEE2E2;
}
}
/* 旋转动画 */
@keyframes spin {
0% { transform: rotate(0deg); }
@@ -934,7 +908,7 @@ import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
}
.header {
background: linear-gradient(135deg, #123A5E 0%, #1A4A6F 100%);
background: linear-gradient(135deg, #5A98B0 0%, #7AB5CC 100%);
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.3);
}
+99 -88
View File
@@ -1,48 +1,49 @@
<!-- pages/course/index.vue -->
<!-- pages/course/index.vue -->
<template>
<!-- <view class="bg-wrapper">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/wave_top.png" mode="widthFix" class="wave-bg wave-top" />
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/wave_bottom.png" mode="widthFix" class="wave-bg wave-bottom" />
</view> -->
<view class="tab-page">
<view class="tab-page__header">
<text class="tab-page__title">课程</text>
<text class="tab-page__subtitle">精品团课 · 私教 · 线上课</text>
</view>
<view class="scroll-container">
<view class="tab-page">
<PageHeader title="课程" subtitle="精品团课 · 私教 · 线上课" />
<!-- 骨架屏 -->
<view v-if="loading" class="skeleton-container">
<view class="skeleton-item" v-for="i in 3" :key="i">
<view class="skeleton-img"></view>
<view class="skeleton-text"></view>
</view>
</view>
<!-- 真实内容 -->
<template v-else>
<RecommendCourses :data="courseData" />
<view class="tab-page__actions">
<view class="tab-page__btn" hover-class="tab-page__btn--hover" @tap="goCourseList">
<text class="tab-page__btn-text">预约课程</text>
</view>
<view class="tab-page__btn tab-page__btn--ghost" hover-class="tab-page__btn--hover" @tap="goMyCourses">
<text class="tab-page__btn-text tab-page__btn-text--ghost">我的课程</text>
<!-- 骨架屏 -->
<view v-if="loading" class="skeleton-container">
<view class="skeleton-item" v-for="i in 3" :key="i">
<view class="skeleton-img"></view>
<view class="skeleton-text"></view>
</view>
</view>
</template>
<view class="bottom-placeholder"></view>
<!-- 真实内容 -->
<template v-else>
<RecommendCourses :data="courseData" />
<view class="tab-page__actions">
<view class="tab-page__btn" hover-class="tab-page__btn--hover" @tap="goCourseList">
<text class="tab-page__btn-text">预约课程</text>
</view>
<view class="tab-page__btn tab-page__btn--ghost" hover-class="tab-page__btn--hover" @tap="goMyCourses">
<text class="tab-page__btn-text tab-page__btn-text--ghost">我的课程</text>
</view>
</view>
</template>
<view class="bottom-placeholder"></view>
</view>
</view>
<!-- 固定 TabBar -->
<view class="tabbar-fixed">
<TabBar @update:active="handleTabActive" />
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import RecommendCourses from '@/components/index/RecommendCourses.vue'
import PageHeader from '@/components/index/PageHeader.vue'
import TabBar from '@/components/TabBar.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { getActiveRecommendCourses } from '@/api/groupCourse.js'
const loading = ref(true)
const courseData = ref(null)
@@ -52,41 +53,71 @@ function loadFromCache() {
try {
const cached = uni.getStorageSync('course_cache')
if (cached && Date.now() - cached.time < 5 * 60 * 1000) {
console.log('[Course Page] 从缓存加载数据,缓存时间:', new Date(cached.time).toLocaleString())
courseData.value = cached.data
loading.value = false
return true
}
} catch (e) {
console.error('读取缓存失败', e)
console.error('[Course Page] 读取缓存失败', e)
}
console.log('[Course Page] 缓存不存在或已过期,准备从网络加载')
return false
}
// 从网络加载数据
async function loadFromNetwork() {
loading.value = true
console.log('[Course Page] 开始从后端获取团课推荐数据...')
try {
// 模拟 API 请求
const res = await new Promise((resolve) => {
setTimeout(() => {
resolve({ code: 0, data: { list: [] } })
}, 500)
})
// 获取启用的团课推荐列表(按优先级从高到低排序)
console.log('[Course Page] 发起 API 请求: GET /groupCourse/recommend/active')
const res = await getActiveRecommendCourses()
if (res.code === 0) {
courseData.value = res.data
console.log('[Course Page] API 响应数据:', res)
if (res && Array.isArray(res)) {
console.log('[Course Page] 获取到', res.length, '条团课推荐数据')
// 取优先级最高的5个团课
const top5Courses = res.slice(0, 5).map(recommend => ({
id: recommend.groupCourse.id,
courseName: recommend.groupCourse.courseName,
courseType: recommend.groupCourse.courseType,
startTime: recommend.groupCourse.startTime,
endTime: recommend.groupCourse.endTime,
maxMembers: recommend.groupCourse.maxMembers,
currentMembers: recommend.groupCourse.currentMembers,
status: recommend.groupCourse.status,
coverImage: recommend.groupCourse.coverImage,
description: recommend.groupCourse.description,
recommendTitle: recommend.recommendTitle,
recommendContent: recommend.recommendContent,
recommendReason: recommend.recommendReason,
priority: recommend.priority
}))
console.log('[Course Page] 筛选出前5个优先级最高的团课:', top5Courses)
courseData.value = { list: top5Courses }
// 更新缓存
uni.setStorageSync('course_cache', {
data: res.data,
data: courseData.value,
time: Date.now()
})
console.log('[Course Page] 数据已缓存')
} else {
console.log('[Course Page] API 响应为空或格式不正确')
courseData.value = { list: [] }
}
} catch (err) {
console.error('加载失败', err)
console.error('[Course Page] 加载失败:', err)
uni.showToast({ title: '加载失败', icon: 'none' })
courseData.value = { list: [] }
} finally {
loading.value = false
console.log('[Course Page] 数据加载完成,loading:', loading.value)
}
}
@@ -99,6 +130,7 @@ function handleTabActive(index) {
}
onLoad(() => {
uni.hideLoading()
// 优先显示缓存
const hasCache = loadFromCache()
if (!hasCache) {
@@ -112,8 +144,9 @@ onLoad(() => {
})
onShow(() => {
// 每次显示时确保加载完成
if (loading.value && !courseData.value) {
uni.hideLoading()
// 每次显示时尝试刷新数据(后台静默更新)
if (!loading.value) {
loadFromNetwork()
}
})
@@ -128,58 +161,15 @@ function goMyCourses() {
</script>
<style lang="scss" scoped>
.bg-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
}
.wave-bg {
position: fixed;
left: 0;
width: 100%;
pointer-events: none;
opacity: 0.6;
}
.wave-top {
top: 0;
opacity: 0.5;
}
.wave-bottom {
bottom: 100rpx;
opacity: 0.35;
}
.tab-page {
min-height: 100vh;
padding-bottom: 160rpx;
position: relative;
z-index: 2;
background: #F5F7FA;
}
.tab-page__header {
padding: 48rpx 32rpx 16rpx;
}
.tab-page__title {
display: block;
font-size: 40rpx;
font-weight: $font-weight-bold;
color: $text-dark;
}
.tab-page__subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: $text-muted;
}
.tab-page__actions {
display: flex;
@@ -259,4 +249,25 @@ function goMyCourses() {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
/* 滚动容器 */
.scroll-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
z-index: 1;
}
/* 固定 TabBar */
.tabbar-fixed {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
background-color: transparent;
}
</style>
-142
View File
@@ -1,142 +0,0 @@
<template>
<!-- <view class="bg-wrapper">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/wave_top.png" mode="widthFix" class="wave-bg wave-top" />
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/wave_bottom.png" mode="widthFix" class="wave-bg wave-bottom" />
</view> -->
<view class="tab-page">
<view class="tab-page__header">
<text class="tab-page__title">发现</text>
<text class="tab-page__subtitle">活动 · 资讯 · 今日推荐</text>
</view>
<TodayRecommend />
<view class="discover-links">
<view class="discover-link" hover-class="discover-link--hover" @tap="goReferral">
<text class="discover-link__title">邀请好友</text>
<text class="discover-link__desc">邀请注册/购课双方得积分</text>
</view>
<view class="discover-link" hover-class="discover-link--hover" @tap="goCouponCenter">
<text class="discover-link__title">领券中心</text>
<text class="discover-link__desc">限时优惠券先到先得</text>
</view>
<view class="discover-link" hover-class="discover-link--hover" @tap="goPointsMall">
<text class="discover-link__title">积分商城</text>
<text class="discover-link__desc">积分兑换好礼</text>
</view>
</view>
<view class="bottom-placeholder"></view>
</view>
<TabBar :active="3" />
</template>
<script setup>
import TodayRecommend from '@/components/index/TodayRecommend.vue'
import TabBar from '@/components/TabBar.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
function goReferral() {
navigateToPage(PAGE.REFERRAL)
}
function goCouponCenter() {
navigateToPage(PAGE.COUPON_CENTER)
}
function goPointsMall() {
navigateToPage(PAGE.POINTS_MALL)
}
</script>
<style lang="scss" scoped>
.bg-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
}
.wave-bg {
position: fixed;
left: 0;
width: 100%;
pointer-events: none;
opacity: 0.6;
}
.wave-top {
top: 0;
opacity: 0.5;
}
.wave-bottom {
bottom: 100rpx;
opacity: 0.35;
}
.tab-page {
min-height: 100vh;
padding-bottom: 160rpx;
position: relative;
z-index: 1;
}
.tab-page__header {
padding: 48rpx 32rpx 16rpx;
}
.tab-page__title {
display: block;
font-size: 40rpx;
font-weight: $font-weight-bold;
color: $text-dark;
}
.tab-page__subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: $text-muted;
}
.discover-links {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 24rpx 32rpx;
}
.discover-link {
padding: 24rpx 28rpx;
border-radius: $radius-md;
background: $bg-white;
box-shadow: $shadow-sm;
border: 1px solid $border-light;
transition: all 0.2s ease;
}
.discover-link:active {
transform: scale(0.98);
}
.discover-link__title {
display: block;
font-size: 28rpx;
font-weight: $font-weight-bold;
color: $text-dark;
}
.discover-link__desc {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: $text-muted;
}
.bottom-placeholder {
height: 40rpx;
}
</style>
+132 -218
View File
@@ -1,7 +1,7 @@
<template>
<view class="course-detail-page">
<!-- 顶部导航 -->
<MemberInfoSubNav :title="course.courseName || '课程详情'" @back="goBack" />
<!-- 页面头部 -->
<PageHeader title="课程详情" subtitle="团课预约" :show-back="true" />
<!-- 骨架屏 -->
<view v-if="loading" class="skeleton">
@@ -64,7 +64,7 @@
<view class="cover-image-wrapper">
<image
v-if="course.coverImage"
:src="course.coverImage"
:src="getCourseCoverUrl(course.coverImage)"
mode="aspectFill"
class="cover-image"
/>
@@ -102,6 +102,26 @@
<text class="meta-text">{{ course.location }}</text>
</view>
</view>
<!-- 课程标签 -->
<view class="course-tags">
<view class="tags-label">
<uni-icons type="tag" size="20" color="#8A99B4" />
</view>
<view class="tags-list">
<view
v-for="label in courseLabels"
:key="label.id"
class="tag-item"
:style="{ background: `${label.color}15`, color: label.color }"
>
<text>{{ label.labelName }}</text>
</view>
<view v-if="courseLabels.length === 0" class="no-tags">
<text>暂无标签</text>
</view>
</view>
</view>
</view>
<!-- 课程详情卡片 -->
@@ -138,39 +158,6 @@
</view>
<text class="info-value">ID: {{ course.coachId }}</text>
</view>
<view class="info-row">
<view class="info-label">
<uni-icons type="credit-card" size="24" color="#5E6F8D" />
<text>支付方式</text>
</view>
<view class="payment-options">
<view
v-if="course.storedValueAmount > 0"
:class="['payment-item', { active: selectedPayment === 'storedValue' || (!selectedPayment && course.pointCardAmount === 0) }]"
@click="selectPayment('storedValue')"
>
<text class="payment-label">储值卡</text>
<text class="payment-value">¥{{ course.storedValueAmount }}</text>
<view v-if="selectedPayment === 'storedValue' || (!selectedPayment && course.pointCardAmount === 0)" class="payment-check">
<uni-icons type="checkmark" size="20" color="#ffffff" />
</view>
</view>
<view
v-if="course.pointCardAmount > 0"
:class="['payment-item', { active: selectedPayment === 'pointCard' || (!selectedPayment && course.storedValueAmount === 0) }]"
@click="selectPayment('pointCard')"
>
<text class="payment-label">次卡</text>
<text class="payment-value">{{ course.pointCardAmount }}</text>
<view v-if="selectedPayment === 'pointCard' || (!selectedPayment && course.storedValueAmount === 0)" class="payment-check">
<uni-icons type="checkmark" size="20" color="#ffffff" />
</view>
</view>
<view v-if="course.storedValueAmount === 0 && course.pointCardAmount === 0" class="payment-item free">
<text class="payment-value">免费</text>
</view>
</view>
</view>
</view>
<!-- 预约须知 -->
@@ -201,16 +188,7 @@
<!-- 底部操作栏 -->
<view class="bottom-bar">
<view class="price-display">
<text v-if="selectedPrice.type === 'storedValue'" class="price">
<text class="currency">¥</text>{{ selectedPrice.amount }}
</text>
<text v-else-if="selectedPrice.type === 'pointCard'" class="price points">
<uni-icons type="shop" size="20" color="#FF6B35" />
<text>{{ selectedPrice.amount }}</text>
</text>
<text v-else class="price free">免费</text>
</view>
<!-- 右侧预约按钮 -->
<view :class="['booking-btn', { disabled: !canBook }]" @click="handleBooking">
<text>{{ canBook ? '立即预约' : (course.currentMembers >= course.maxMembers ? '已满员' : statusText) }}</text>
</view>
@@ -220,9 +198,12 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { groupCourseService } from '@/request_api/groupCourse.mock.js'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getGroupCourseDetail, bookGroupCourse } from '@/api/groupCourse.js'
import { getCourseCoverUrl } from '@/utils/request.js'
import { getMemberId } from '@/utils/request.js'
import PageHeader from '@/components/index/PageHeader.vue'
// 加载状态
const loading = ref(true)
@@ -245,8 +226,8 @@ const course = ref({
storedValueAmount: 0
})
// 选中的支付方式 ('storedValue' | 'pointCard' | 'free')
const selectedPayment = ref('')
// 课程标签数据
const courseLabels = ref([])
// 课程状态文本
const statusText = computed(() => {
@@ -291,33 +272,6 @@ const canBook = computed(() => {
return status === '0' && !isFull
})
// 是否有多种支付方式
const hasMultiplePayment = computed(() => {
return course.value.storedValueAmount > 0 && course.value.pointCardAmount > 0
})
// 当前选中的支付方式价格显示
const selectedPrice = computed(() => {
if (selectedPayment.value === 'storedValue') {
return { type: 'storedValue', amount: course.value.storedValueAmount }
} else if (selectedPayment.value === 'pointCard') {
return { type: 'pointCard', amount: course.value.pointCardAmount }
} else if (course.value.storedValueAmount > 0 && course.value.pointCardAmount > 0) {
// 默认优先显示储值卡
return { type: 'storedValue', amount: course.value.storedValueAmount }
} else if (course.value.storedValueAmount > 0) {
return { type: 'storedValue', amount: course.value.storedValueAmount }
} else if (course.value.pointCardAmount > 0) {
return { type: 'pointCard', amount: course.value.pointCardAmount }
}
return { type: 'free', amount: 0 }
})
// 选择支付方式
const selectPayment = (type) => {
selectedPayment.value = type
}
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return ''
@@ -348,39 +302,52 @@ const formatDuration = (startStr, endStr) => {
return `${minutes}分钟`
}
// 返回上一页
const goBack = () => {
uni.navigateBack()
}
// 预约处理
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: '预约中...' })
groupCourseService.book({
courseId: course.value.id,
memberId: '1' // 模拟会员ID
}).then(() => {
uni.hideLoading()
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '预约中...' })
try {
const result = await bookGroupCourse({
courseId: Number(course.value.id),
memberId: Number(memberId)
})
uni.hideLoading()
if (result.success) {
uni.showToast({
title: '预约成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
uni.redirectTo({ url: '/pages/memberInfo/myCourses' })
}, 1500)
}).catch(() => {
uni.hideLoading()
} else {
uni.showToast({
title: '预约失败',
icon: 'none'
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
})
}
}
@@ -390,9 +357,16 @@ const handleBooking = () => {
// 获取课程详情
const fetchCourseDetail = async (id) => {
try {
const result = await groupCourseService.getDetail(id)
console.log('[detail.vue] 开始获取课程详情, id:', id)
const result = await getGroupCourseDetail(id)
course.value = result
console.log('[detail.vue] 课程详情获取成功:', course.value)
// 从完整信息中提取标签
if (result.labels && Array.isArray(result.labels)) {
courseLabels.value = result.labels
}
console.log('[detail.vue] 课程标签获取成功:', courseLabels.value)
} catch (error) {
console.error('[detail.vue] 获取课程详情失败:', error)
uni.showToast({
@@ -404,15 +378,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>
@@ -550,6 +522,42 @@ onMounted(() => {
flex: 1;
}
/* 课程标签 */
.course-tags {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid #F3F4F6;
}
.tags-label {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 26rpx;
color: #6B7280;
margin-bottom: 16rpx;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.tag-item {
display: inline-flex;
align-items: center;
padding: 10rpx 24rpx;
border-radius: 24rpx;
font-size: 24rpx;
font-weight: 500;
}
.no-tags {
font-size: 24rpx;
color: #9CA3AF;
}
/* 详情卡片 */
.detail-card {
background: #ffffff;
@@ -620,71 +628,6 @@ onMounted(() => {
text-align: right;
}
.payment-options {
display: flex;
gap: 20rpx;
flex: 1;
justify-content: flex-end;
}
.payment-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 16rpx 28rpx;
background: linear-gradient(135deg, #F9FAFB 0%, #F3F4F6 100%);
border-radius: 16rpx;
border: 2rpx solid transparent;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:active {
transform: scale(0.97);
}
&.active {
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
border-color: #FF6B35;
.payment-label,
.payment-value {
color: #ffffff;
}
}
&.free {
background: linear-gradient(135deg, #ECFDF5 0%, #D1FAE5 100%);
}
}
.payment-label {
font-size: 20rpx;
color: #6B7280;
}
.payment-value {
font-size: 28rpx;
font-weight: 600;
color: #FF6B35;
.free & {
color: #059669;
}
}
.payment-check {
position: absolute;
top: -8rpx;
right: -8rpx;
width: 36rpx;
height: 36rpx;
background: #059669;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
/* 预约须知 */
.notice-card {
background: #ffffff;
@@ -719,43 +662,17 @@ onMounted(() => {
left: 0;
right: 0;
background: #ffffff;
padding: 20rpx 24rpx;
padding-bottom: calc(20rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: 24rpx;
padding: 16rpx 24rpx;
padding-bottom: calc(16rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.08);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
}
.price-display {
flex: 1;
padding-left: 20rpx;
.price {
font-size: 40rpx;
font-weight: 700;
color: #FF6B35;
display: flex;
align-items: baseline;
gap: 4rpx;
.currency {
font-size: 26rpx;
font-weight: 600;
}
&.points {
gap: 8rpx;
}
&.free {
color: #10B981;
}
}
}
/* 预约按钮 */
.booking-btn {
padding: 24rpx 64rpx;
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
@@ -764,21 +681,18 @@ onMounted(() => {
font-weight: 600;
color: #ffffff;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.3);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:active {
transform: scale(0.97);
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.2);
}
&.disabled {
background: linear-gradient(135deg, #F3F4F6 0%, #E5E7EB 100%);
color: #9CA3AF;
box-shadow: none;
}
}
/* 骨架屏样式 */
.booking-btn.disabled {
background: linear-gradient(135deg, #D1D5DB 0%, #9CA3AF 100%);
color: #ffffff;
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));
+165 -5
View File
@@ -1,5 +1,8 @@
<template>
<view class="group-course-page">
<!-- 页面头部 -->
<PageHeader title="团课" subtitle="精品团课 · 专业教练 · 小班教学" :show-back="true" />
<!-- 搜索区域 -->
<view class="search-section">
<!-- 搜索框组件 -->
@@ -15,7 +18,10 @@
:time-range-text="timeRangeText"
:sort-options="sortOptions"
v-model:sort-index="sortIndex"
:course-types="courseTypes"
:current-course-type-id="courseType"
@time-pick="showTimePicker = true"
@course-type-change="onCourseTypeChange"
ref="filterSectionRef"
/>
@@ -26,10 +32,30 @@
@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>
<scroll-view
v-if="!pageReady"
class="course-list-skel"
>
<ListSkeleton :count="4" layout="card" :show-tabs="false" />
</scroll-view>
<!-- 团课列表 -->
<scroll-view
v-else
scroll-y
class="course-list"
@scrolltolower="onScrollToLower"
@@ -39,6 +65,8 @@
v-for="course in filteredCourseList"
:key="course.id"
:course="course"
:courseTypeLabels="getCourseLabels(course)"
:booked="isCourseBooked(course.id)"
@booking="handleBooking"
@detail="goDetail"
></GroupCourseCard>
@@ -66,14 +94,19 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
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'
import FilterSection from '@/components/groupCourse/FilterSection.vue'
import TimePeriodSelector from '@/components/groupCourse/TimePeriodSelector.vue'
import TimeRangePicker from '@/components/groupCourse/TimeRangePicker.vue'
import PageHeader from '@/components/index/PageHeader.vue'
import ListSkeleton from '@/components/Skeleton/ListSkeleton.vue'
import { useGroupCourseList } from '@/composables/useGroupCourseList.js'
import { getTypeLabels, getGroupCourseTypes, getMemberBookings } from '@/api/groupCourse.js'
import { getMemberId } from '@/utils/request.js'
//
const searchBarRef = ref(null)
@@ -81,6 +114,15 @@ const filterSectionRef = ref(null)
const timePeriodRef = ref(null)
const timeRangePickerRef = ref(null)
//
const courseTypeLabelsCache = ref({})
// ID
const bookedCourseIds = ref(new Set())
//
const pageReady = ref(false)
// 使
const {
//
@@ -88,10 +130,13 @@ const {
hasMore,
searchKeyword,
hotKeywords,
courseType,
courseTypes,
sortOptions,
sortIndex,
timePeriodOptions,
timePeriodIndex,
isRecurring,
showTimePicker,
startDate,
endDate,
@@ -103,6 +148,8 @@ const {
handleSearch,
onTimePeriodChange,
onTimeRangeConfirm,
onCourseTypeChange,
clearCourseType,
handleBooking,
goDetail,
fetchCourseList,
@@ -110,10 +157,24 @@ const {
onScrollToLower
} = useGroupCourseList()
//
onMounted(() => {
console.log('[list.vue] 页面组件已挂载,开始获取团课列表')
//
const toggleRecurring = () => {
isRecurring.value = !isRecurring.value
fetchCourseList()
}
//
onMounted(async () => {
console.log('[list.vue] 页面组件已挂载,开始获取团课列表')
//
await Promise.all([
fetchCourseTypes(),
fetchCourseList(),
fetchBookedCourses()
])
//
await fetchAllCourseTypeLabels()
pageReady.value = true
console.log('[list.vue] 可用的搜索参数获取方法:')
console.log(' - searchBarRef.getSearchParams()')
console.log(' - filterSectionRef.getFilterParams()')
@@ -122,6 +183,75 @@ onMounted(() => {
console.log(' - getAllSearchParams() 获取所有参数')
})
//
onShow(async () => {
console.log('[list.vue] onShow 刷新已预约状态')
await fetchBookedCourses()
})
const fetchCourseTypes = async () => {
try {
const result = await getGroupCourseTypes()
if (result && Array.isArray(result)) {
courseTypes.value = result.map(type => ({
id: type.id,
label: type.typeName
}))
console.log('[list.vue] 获取课程类型成功:', courseTypes.value)
}
} catch (error) {
console.error('[list.vue] 获取课程类型失败:', error)
}
}
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) {
if (!courseTypeLabelsCache.value[type]) {
try {
const result = await getTypeLabels(type)
if (result) {
courseTypeLabelsCache.value[type] = result
}
} catch (error) {
console.error(`[list.vue] 获取类型标签失败,type=${type}`, error)
}
}
}
}
//
const getCourseLabels = (course) => {
return courseTypeLabelsCache.value[course.courseType] || []
}
//
defineExpose({
getAllSearchParams: () => getAllSearchParams(searchBarRef.value, filterSectionRef.value, timePeriodRef.value, timeRangePickerRef.value),
@@ -151,7 +281,7 @@ defineExpose({
/* 课程列表 */
.course-list {
height: calc(100vh - 380rpx);
height: calc(100vh - 480rpx);
padding: 24rpx 24rpx;
padding-bottom: calc(160rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
@@ -169,4 +299,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>
+28 -139
View File
@@ -1,19 +1,10 @@
<template>
<!-- 水波纹背景 - 顶层显示 -->
<!-- <view class="bg-wrapper">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/wave_top.png" mode="widthFix" class="wave-bg wave-top" />
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/wave_bottom.png" mode="widthFix" class="wave-bg wave-bottom" />
</view> -->
<!-- 固定白色块滚动时显示 -->
<view class="hand" :style="{height : handHeight + 'rpx'}" v-show="isShow"></view>
<template>
<!-- 页面头部 -->
<PageHeader title="活氧舱" subtitle="科学训练 · 遇见更好的自己" />
<!-- 滚动内容区域 -->
<scroll-view
scroll-y
refresher-enabled
:refresher-triggered="isRefreshing"
refresher-default-style="none"
@refresherrefresh="onRefresh"
@scroll="handleScroll"
class="scroll-container"
>
@@ -23,8 +14,8 @@
<template v-else>
<BannerSwiper />
<QuickEntry />
<RecommendCourses />
<TodayRecommend />
<RecommendCourses ref="recommendCoursesRef" />
<TodayRecommend ref="todayRecommendRef" />
<view class="bottom-placeholder"></view>
</template>
</view>
@@ -38,142 +29,50 @@
<script setup>
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import BannerSwiper from '@/components/index/BannerSwiper.vue'
import QuickEntry from '@/components/index/QuickEntry.vue'
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
//
const scrollViewHeight = e.detail.scrollHeight
scrollContentHeight.value = scrollViewHeight
//
// = / ( - ) * 100
const maxScrollDistance = scrollContentHeight.value - windowHeight.value
let scrollPercent = 0
if (maxScrollDistance > 0) {
scrollPercent = (distance / maxScrollDistance) * 100
}
console.log(`滚动距离: ${distance}, 滚动百分比: ${scrollPercent.toFixed(2)}%`)
// 10%
const SHOW_THRESHOLD = 40 // 10%
isShow.value = scrollPercent > SHOW_THRESHOLD
scrollDistance.value = e.detail.scrollTop
console.log(`滚动距离: ${scrollDistance.value}`)
}
//
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)
})
}
onShow(() => {
//
uni.$emit('pageShow')
})
onMounted(() => {
// tokenuserId
const testUserId = 1
uni.setStorageSync('userId', testUserId)
//
setTimeout(() => {
uni.$emit('pageShow')
}, 500)
setTimeout(() => {
loading.value = false
}, 1500)
//
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
const navTotalHeight = menuButtonInfo.top + menuButtonInfo.height
handHeight.value = navTotalHeight * 2.5
//
uni.getSystemInfo({
success: (res) => {
windowHeight.value = res.windowHeight
console.log('可视窗口高度:', windowHeight.value)
}
})
// 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>
<style lang="scss" scoped>
/* 背景包装器 - 固定在最底层 */
.bg-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
}
.wave-bg {
position: fixed;
left: 0;
width: 100%;
pointer-events: none;
opacity: 0.6;
}
.wave-top {
top: 0;
opacity: 0.5;
}
.wave-bottom {
bottom: 100rpx;
opacity: 0.35;
}
/* 滚动容器 */
.scroll-container {
position: relative;
@@ -188,16 +87,6 @@ onMounted(() => {
padding-bottom: 160rpx;
}
/* 固定白色块 */
.hand {
position: fixed;
top: 0;
left: 0;
z-index: 100;
background-color: white;
width: 100%;
}
/* 固定 TabBar */
.tabbar-fixed {
position: fixed;
@@ -211,4 +100,4 @@ onMounted(() => {
.bottom-placeholder {
height: 120rpx;
}
</style>
</style>
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="历史对比" @back="onBack" />
@@ -74,84 +74,83 @@
</view>
</template>
<script>
<script setup>
import { ref, computed } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getBodyTestHistory, getCompareData } from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return {
records: [],
recordA: null,
recordB: null,
compareData: null
}
},
computed: {
scoreDiff() {
if (!this.compareData) return 0
return this.compareData.recordA.score - this.compareData.recordB.score
}
},
onShow() {
const store = loadMemberStore()
this.records = getBodyTestHistory(store)
if (this.records.length >= 2 && !this.recordA) {
this.recordA = this.records[0]
this.recordB = this.records[1]
this.refreshCompare()
}
},
methods: {
onBack() {
goBackOrTab(PAGE.BODY_TEST_HISTORY)
},
pickRecord(which) {
const labels = this.records.map(
(r) => `${r.date} · ${r.score}分 · ${r.gradeLabel}`
)
uni.showActionSheet({
itemList: labels,
success: (res) => {
const picked = this.records[res.tapIndex]
if (which === 'a') this.recordA = picked
else this.recordB = picked
this.refreshCompare()
}
})
},
refreshCompare() {
if (!this.recordA || !this.recordB) {
this.compareData = null
return
}
if (this.recordA.id === this.recordB.id) {
uni.showToast({ title: '请选择两条不同记录', icon: 'none' })
this.compareData = null
return
}
const store = loadMemberStore()
this.compareData = getCompareData(store, this.recordA.id, this.recordB.id)
},
formatDiff(diff, key) {
const sign = diff > 0 ? '+' : ''
const units = { bodyFat: '%', weight: '', bmi: '', muscleMass: '', visceralFat: '', bmr: '' }
return `${sign}${diff}${units[key] || ''}`
},
diffColor(row) {
const lowerBetter = ['weight', 'bodyFat', 'visceralFat'].includes(row.key)
const good = lowerBetter ? row.diff < 0 : row.diff > 0
if (row.diff === 0) return '#8A99B4'
return good ? '#2ECC71' : '#F39C12'
}
const records = ref([])
const recordA = ref(null)
const recordB = ref(null)
const compareData = ref(null)
const scoreDiff = computed(() => {
if (!compareData.value) return 0
return compareData.value.recordA.score - compareData.value.recordB.score
})
function refreshFromStore() {
const store = loadMemberStore()
records.value = getBodyTestHistory(store)
if (records.value.length >= 2 && !recordA.value) {
recordA.value = records.value[0]
recordB.value = records.value[1]
refreshCompare()
}
}
function onBack() {
goBackOrTab(PAGE.BODY_TEST_HISTORY)
}
function pickRecord(which) {
const labels = records.value.map(
(r) => `${r.date} · ${r.score}分 · ${r.gradeLabel}`
)
uni.showActionSheet({
itemList: labels,
success: (res) => {
const picked = records.value[res.tapIndex]
if (which === 'a') recordA.value = picked
else recordB.value = picked
refreshCompare()
}
})
}
function refreshCompare() {
if (!recordA.value || !recordB.value) {
compareData.value = null
return
}
if (recordA.value.id === recordB.value.id) {
uni.showToast({ title: '请选择两条不同记录', icon: 'none' })
compareData.value = null
return
}
const store = loadMemberStore()
compareData.value = getCompareData(store, recordA.value.id, recordB.value.id)
}
function formatDiff(diff, key) {
const sign = diff > 0 ? '+' : ''
const units = { bodyFat: '%', weight: '', bmi: '', muscleMass: '', visceralFat: '', bmr: '' }
return `${sign}${diff}${units[key] || ''}`
}
function diffColor(row) {
const lowerBetter = ['weight', 'bodyFat', 'visceralFat'].includes(row.key)
const good = lowerBetter ? row.diff < 0 : row.diff > 0
if (row.diff === 0) return '#8A99B4'
return good ? '#2ECC71' : '#F39C12'
}
refreshFromStore()
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="连接设备" @back="onBack" />
@@ -73,7 +73,8 @@
</view>
</template>
<script>
<script setup>
import { ref } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import {
@@ -81,52 +82,52 @@ import {
persistMemberStore
} from '@/common/memberInfo/store.js'
import {
connectBodyTestDevice,
bodyTestMock
connectBodyTestDevice
} from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return {
device: {},
steps: bodyTestMock.connectSteps,
searching: false,
connected: false,
searchHint: '请保持手机蓝牙已开启'
}
},
onShow() {
const store = loadMemberStore()
this.device = { ...store.bodyTest.device }
this.connected = store.bodyTest.device.connected
},
methods: {
onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
},
searchDevice() {
if (this.searching) return
this.searching = true
this.searchHint = '发现 InBody 270…'
setTimeout(() => {
const store = loadMemberStore()
connectBodyTestDevice(store)
persistMemberStore(store)
this.device = { ...store.bodyTest.device }
this.connected = true
this.searching = false
uni.showToast({ title: '设备已连接', icon: 'success' })
}, 1800)
},
goMeasuring() {
navigateToPage(PAGE.BODY_TEST_MEASURING)
}
}
const device = ref({})
const steps = ref([
{ step: 1, text: '打开体测仪电源' },
{ step: 2, text: '站在体测仪上' },
{ step: 3, text: '等待测量完成' }
])
const searching = ref(false)
const connected = ref(false)
const searchHint = ref('请保持手机蓝牙已开启')
function refreshFromStore() {
const store = loadMemberStore()
device.value = { ...store.bodyTest.device }
connected.value = store.bodyTest.device.connected
}
function onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
}
function searchDevice() {
if (searching.value) return
searching.value = true
searchHint.value = '发现 InBody 270…'
setTimeout(() => {
const store = loadMemberStore()
connectBodyTestDevice(store)
persistMemberStore(store)
device.value = { ...store.bodyTest.device }
connected.value = true
searching.value = false
uni.showToast({ title: '设备已连接', icon: 'success' })
}, 1800)
}
function goMeasuring() {
navigateToPage(PAGE.BODY_TEST_MEASURING)
}
refreshFromStore()
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="体测报告" @back="onBack" />
@@ -59,53 +59,52 @@
</view>
</template>
<script>
<script setup>
import { ref, computed, watch } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getBodyTestHistory, getBodyTestYears, getBodyTestChangeBadge } from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { activeYear: 'all', years: [], allRecords: [] }
},
computed: {
records() {
const list = this.activeYear === 'all'
? this.allRecords
: this.allRecords.filter((r) => r.date.startsWith(this.activeYear))
return list.map((item, index) => {
const previous = list[index + 1]
return {
...item,
changeBadge: getBodyTestChangeBadge(item, previous)
}
})
const activeYear = ref('all')
const years = ref([])
const allRecords = ref([])
const records = computed(() => {
const list = activeYear.value === 'all'
? allRecords.value
: allRecords.value.filter((r) => r.date.startsWith(activeYear.value))
return list.map((item, index) => {
const previous = list[index + 1]
return {
...item,
changeBadge: getBodyTestChangeBadge(item, previous)
}
},
watch: {
activeYear() { this.loadList() }
},
onShow() { this.loadList() },
methods: {
loadList() {
const store = loadMemberStore()
this.years = getBodyTestYears(store)
this.allRecords = getBodyTestHistory(store)
},
onBack() { goBackOrTab(PAGE.MEMBER) },
viewReport(item) {
navigateToPage(`${PAGE.BODY_TEST_REPORT}?id=${item.id}`)
},
goCompare() {
navigateToPage(PAGE.BODY_TEST_COMPARE)
}
}
})
})
function loadList() {
const store = loadMemberStore()
years.value = getBodyTestYears(store)
allRecords.value = getBodyTestHistory(store)
}
function onBack() { goBackOrTab(PAGE.MEMBER) }
function viewReport(item) {
navigateToPage(`${PAGE.BODY_TEST_REPORT}?id=${item.id}`)
}
function goCompare() {
navigateToPage(PAGE.BODY_TEST_COMPARE)
}
watch(activeYear, () => { loadList() })
loadList()
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@@ -196,15 +195,7 @@ export default {
background: rgba(46, 204, 113, 0.12);
}
.mi-timeline-card__badge--good text {
font-size: 10px;
color: var(--success-green, #2ECC71);
font-weight: 600;
}
.mi-timeline-card__badge--warn text {
font-size: 10px;
color: var(--warning-amber, #F39C12);
font-weight: 600;
.mi-timeline-card__badge--warn {
background: rgba(243, 156, 18, 0.12);
}
</style>
@@ -1,47 +1,28 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="智能体测" @back="goBack" />
<view class="bt-page__action-bar bt-page__action-bar--end">
<text
class="bt-page__action-link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="goSettings"
>
体测设置
</text>
</view>
<MemberInfoSubNav title="体测" @back="goBack" />
<view class="bt-page__body">
<view class="bt-hero">
<view class="bt-hero__top">
<text class="bt-hero__label">最新体测评分</text>
<view class="bt-hero__badge">
<text class="bt-hero__badge-text">{{ latest?.status || '暂无数据' }}</text>
<view v-if="latest" class="bt-hero">
<view class="bt-hero__time">
<image class="bt-hero__time-icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/clock.png" mode="aspectFit" />
<text class="bt-hero__time-text">{{ latest.testTime }}</text>
</view>
<view class="bt-hero__metrics">
<view
v-for="m in previewMetrics"
:key="m.key"
class="bt-hero__metric"
>
<text class="bt-hero__metric-value">{{ m.value }}</text>
<text class="bt-hero__metric-label">{{ m.label }}</text>
</view>
</view>
<view class="bt-hero__score-row">
<text class="bt-hero__score">{{ latest?.score ?? '--' }}</text>
<text class="bt-hero__grade">{{ latest?.grade ?? '' }} {{ latest?.gradeLabel ?? '' }}</text>
</view>
<text class="bt-hero__meta">
{{ latest ? `最近测量 · ${latest.date} ${latest.time}` : '完成首次体测,获取健康画像' }}
</text>
<view class="bt-hero__actions">
<view
class="bt-btn bt-btn--primary"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="startMeasure"
>
<image class="bt-btn__icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/activity.png" mode="aspectFit" />
<text class="bt-btn__text">开始体测</text>
</view>
<view
v-if="latest"
class="bt-btn bt-btn--ghost"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="viewLatestReport"
>
<text class="bt-btn__text">查看报告</text>
@@ -50,142 +31,145 @@
</view>
<view class="bt-card">
<text class="bt-card__title">设备状态</text>
<view class="bt-device">
<view class="bt-device__icon-wrap">
<image class="bt-device__icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/mappin2.png" mode="aspectFit" />
</view>
<view class="bt-device__info">
<text class="bt-device__name">{{ device.name }}</text>
<text
class="bt-device__status"
:class="{ 'bt-device__status--on': device.connected }"
>
{{ deviceStatusText }}
</text>
</view>
<view class="bt-card__header">
<text class="bt-card__title">快捷操作</text>
</view>
<view class="bt-quick-grid">
<view
class="bt-device__dot"
:class="{ 'bt-device__dot--on': device.connected }"
></view>
v-for="link in quickLinks"
:key="link.key"
class="bt-quick-item"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="onQuickLink(link.key)"
>
<image class="bt-quick-item__icon" :src="link.icon" mode="aspectFit" />
<text class="bt-quick-item__label">{{ link.label }}</text>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">快捷入口</text>
<view class="bt-grid">
<view class="bt-card__header">
<text class="bt-card__title">开始体测</text>
</view>
<view class="bt-device-status" @tap="startMeasure">
<view class="bt-device-status__icon-wrap">
<image
class="bt-device-status__icon"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/bluetooth.png"
mode="aspectFit"
/>
</view>
<view class="bt-device-status__info">
<text class="bt-device-status__name">{{ device.name || '体脂秤' }}</text>
<text class="bt-device-status__text">{{ deviceStatusText }}</text>
</view>
<image
class="bt-device-status__arrow"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/rightarrow.png"
mode="aspectFit"
/>
</view>
<view class="bt-card__actions">
<view
v-for="item in quickLinks"
:key="item.key"
class="bt-grid__item"
hover-class="mi-tap--hover"
class="bt-btn bt-btn--primary"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="onQuickLink(item.key)"
@tap="startMeasure"
>
<image class="bt-grid__icon" :src="item.icon" mode="aspectFit" />
<text class="bt-grid__label">{{ item.label }}</text>
<text class="bt-btn__text">{{ device.connected ? '开始测量' : '连接设备' }}</text>
</view>
</view>
</view>
<view v-if="latest" class="bt-card">
<text class="bt-card__title">核心指标概览</text>
<view class="bt-metrics">
<view
v-for="m in previewMetrics"
:key="m.key"
class="bt-metric"
>
<text class="bt-metric__value">{{ m.value }}</text>
<text class="bt-metric__label">{{ m.label }}</text>
</view>
</view>
<view class="bt-settings-link" @tap="goSettings">
<text class="bt-settings-link__text">体测设置</text>
<image class="bt-settings-link__arrow" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/rightarrow.png" mode="aspectFit" />
</view>
</view>
</view>
</view>
</template>
<script>
<script setup>
import { ref, computed } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { PAGE, navigateToPage, backToMemberCenter } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getLatestBodyTestRecord } from '@/common/memberInfo/bodyTestStore.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
latest: null,
device: {},
quickLinks: [
{ key: 'history', label: '历史记录', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/clock.png' },
{ key: 'compare', label: '历史对比', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/trendingdown.png' },
{ key: 'trend', label: '趋势分析', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/activity.png' },
{ key: 'report', label: '体测报告', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/filetext.png' }
]
}
},
computed: {
deviceStatusText() {
if (this.device.connected) {
return `已连接 · 电量 ${this.device.battery}%`
}
return '未连接 · 点击开始体测进行配对'
},
previewMetrics() {
if (!this.latest?.metrics) return []
const m = this.latest.metrics
return [
{ key: 'weight', label: '体重(kg)', value: m.weight },
{ key: 'bmi', label: 'BMI', value: m.bmi },
{ key: 'bodyFat', label: '体脂率(%)', value: m.bodyFat },
{ key: 'muscleMass', label: '肌肉量(kg)', value: m.muscleMass }
]
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
this.latest = getLatestBodyTestRecord(store)
this.device = { ...store.bodyTest.device }
},
startMeasure() {
const store = loadMemberStore()
if (store.bodyTest.device.connected) {
navigateToPage(PAGE.BODY_TEST_MEASURING)
} else {
navigateToPage(PAGE.BODY_TEST_CONNECT)
}
},
viewLatestReport() {
if (!this.latest) return
navigateToPage(`${PAGE.BODY_TEST_REPORT}?id=${this.latest.id}`)
},
goSettings() {
navigateToPage(PAGE.BODY_TEST_SETTINGS)
},
onQuickLink(key) {
const routes = {
history: PAGE.BODY_TEST_HISTORY,
compare: PAGE.BODY_TEST_COMPARE,
trend: PAGE.BODY_TEST_TREND,
report: this.latest
? `${PAGE.BODY_TEST_REPORT}?id=${this.latest.id}`
: PAGE.BODY_TEST_HISTORY
}
navigateToPage(routes[key] || PAGE.BODY_TEST_HOME)
}
const latest = ref(null)
const device = ref({})
const quickLinks = ref([
{ key: 'history', label: '历史记录', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/clock.png' },
{ key: 'compare', label: '历史对比', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/trendingdown.png' },
{ key: 'trend', label: '趋势分析', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/activity.png' },
{ key: 'report', label: '体测报告', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/filetext.png' }
])
const deviceStatusText = computed(() => {
if (device.value.connected) {
return `已连接 · 电量 ${device.value.battery}%`
}
return '未连接 · 点击开始体测进行配对'
})
const previewMetrics = computed(() => {
if (!latest.value?.metrics) return []
const m = latest.value.metrics
return [
{ key: 'weight', label: '体重(kg)', value: m.weight },
{ key: 'bmi', label: 'BMI', value: m.bmi },
{ key: 'bodyFat', label: '体脂率(%)', value: m.bodyFat },
{ key: 'muscleMass', label: '肌肉量(kg)', value: m.muscleMass }
]
})
function refreshFromStore() {
const store = loadMemberStore()
latest.value = getLatestBodyTestRecord(store)
device.value = { ...store.bodyTest.device }
}
function goBack() {
backToMemberCenter()
}
function startMeasure() {
const store = loadMemberStore()
if (store.bodyTest.device.connected) {
navigateToPage(PAGE.BODY_TEST_MEASURING)
} else {
navigateToPage(PAGE.BODY_TEST_CONNECT)
}
}
function viewLatestReport() {
if (!latest.value) return
navigateToPage(`${PAGE.BODY_TEST_REPORT}?id=${latest.value.id}`)
}
function goSettings() {
navigateToPage(PAGE.BODY_TEST_SETTINGS)
}
function onQuickLink(key) {
const routes = {
history: PAGE.BODY_TEST_HISTORY,
compare: PAGE.BODY_TEST_COMPARE,
trend: PAGE.BODY_TEST_TREND,
report: latest.value
? `${PAGE.BODY_TEST_REPORT}?id=${latest.value.id}`
: PAGE.BODY_TEST_HISTORY
}
navigateToPage(routes[key] || PAGE.BODY_TEST_HOME)
}
refreshFromStore()
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="测量中" @back="onCancel" />
@@ -38,7 +38,8 @@
</view>
</template>
<script>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import {
@@ -57,108 +58,106 @@ const PHASES = [
{ until: 100, hint: '即将完成', text: '生成健康报告中…' }
]
export default {
components: { MemberInfoSubNav },
data() {
return {
progress: 0,
liveMetrics: {},
timer: null,
finished: false
}
},
computed: {
ringRotation() {
return -90 + (this.progress / 100) * 360
},
phaseHint() {
const phase = PHASES.find((p) => this.progress <= p.until)
return phase?.hint || '完成'
},
statusText() {
const phase = PHASES.find((p) => this.progress <= p.until)
return phase?.text || '测量完成'
},
liveDisplay() {
const m = this.liveMetrics
return [
{ key: 'weight', label: '体重(kg)', value: m.weight ?? '--' },
{ key: 'bodyFat', label: '体脂率(%)', value: m.bodyFat ?? '--' },
{ key: 'muscleMass', label: '肌肉量(kg)', value: m.muscleMass ?? '--' },
{ key: 'bmr', label: '基础代谢', value: m.bmr ?? '--' }
]
}
},
onLoad() {
const store = loadMemberStore()
if (!store.bodyTest.device.connected) {
uni.showToast({ title: '请先连接设备', icon: 'none' })
setTimeout(() => {
navigateToPage(PAGE.BODY_TEST_CONNECT)
}, 800)
const progress = ref(0)
const liveMetrics = ref({})
let timer = null
const finished = ref(false)
const ringRotation = computed(() => {
return -90 + (progress.value / 100) * 360
})
const phaseHint = computed(() => {
const phase = PHASES.find((p) => progress.value <= p.until)
return phase?.hint || '完成'
})
const statusText = computed(() => {
const phase = PHASES.find((p) => progress.value <= p.until)
return phase?.text || '测量完成'
})
const liveDisplay = computed(() => {
const m = liveMetrics.value
return [
{ key: 'weight', label: '体重(kg)', value: m.weight ?? '--' },
{ key: 'bodyFat', label: '体脂率(%)', value: m.bodyFat ?? '--' },
{ key: 'muscleMass', label: '肌肉量(kg)', value: m.muscleMass ?? '--' },
{ key: 'bmr', label: '基础代谢', value: m.bmr ?? '--' }
]
})
function clearTimer() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function startMeasurement() {
const store = loadMemberStore()
liveMetrics.value = interpolateMeasuringMetrics(0, store.profile)
timer = setInterval(() => {
if (progress.value >= 100) {
completeMeasurement()
return
}
this.startMeasurement()
},
onUnload() {
this.clearTimer()
},
methods: {
clearTimer() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
progress.value = Math.min(100, progress.value + 2)
const s = loadMemberStore()
liveMetrics.value = interpolateMeasuringMetrics(progress.value, s.profile)
}, 120)
}
function completeMeasurement() {
if (finished.value) return
finished.value = true
clearTimer()
const store = loadMemberStore()
const record = saveSimulatedBodyTestRecord(store, {
...liveMetrics.value,
visceralFat: 6,
boneMass: 2.42,
bodyWater: liveMetrics.value.bodyWater || 52.8,
protein: 16.4
})
persistMemberStore(store)
uni.showToast({ title: '测量完成', icon: 'success' })
setTimeout(() => {
navigateToPage(`${PAGE.BODY_TEST_REPORT}?id=${record.id}&new=1`)
}, 600)
}
function onCancel() {
if (finished.value) return
uni.showModal({
title: '取消测量',
content: '确定要中断当前体测吗?',
success: (res) => {
if (res.confirm) {
clearTimer()
goBackOrTab(PAGE.BODY_TEST_HOME)
}
},
startMeasurement() {
const store = loadMemberStore()
this.liveMetrics = interpolateMeasuringMetrics(0, store.profile)
this.timer = setInterval(() => {
if (this.progress >= 100) {
this.completeMeasurement()
return
}
this.progress = Math.min(100, this.progress + 2)
const s = loadMemberStore()
this.liveMetrics = interpolateMeasuringMetrics(this.progress, s.profile)
}, 120)
},
completeMeasurement() {
if (this.finished) return
this.finished = true
this.clearTimer()
const store = loadMemberStore()
const record = saveSimulatedBodyTestRecord(store, {
...this.liveMetrics,
visceralFat: 6,
boneMass: 2.42,
bodyWater: this.liveMetrics.bodyWater || 52.8,
protein: 16.4
})
persistMemberStore(store)
uni.showToast({ title: '测量完成', icon: 'success' })
setTimeout(() => {
navigateToPage(`${PAGE.BODY_TEST_REPORT}?id=${record.id}&new=1`)
}, 600)
},
onCancel() {
if (this.finished) return
uni.showModal({
title: '取消测量',
content: '确定要中断当前体测吗?',
success: (res) => {
if (res.confirm) {
this.clearTimer()
goBackOrTab(PAGE.BODY_TEST_HOME)
}
}
})
}
}
})
}
onUnmounted(() => {
clearTimer()
})
// Initialize
const store = loadMemberStore()
if (!store.bodyTest.device.connected) {
uni.showToast({ title: '请先连接设备', icon: 'none' })
setTimeout(() => {
navigateToPage(PAGE.BODY_TEST_CONNECT)
}, 800)
} else {
startMeasurement()
}
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="体测报告" @back="onBack" />
@@ -169,7 +169,8 @@
</view>
</template>
<script>
<script setup>
import { ref, computed } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import BodyTestRadarChart from '@/components/memberInfo/BodyTestRadarChart.vue'
import BodyTestTrendChart from '@/components/memberInfo/BodyTestTrendChart.vue'
@@ -180,127 +181,139 @@ import {
getBodyTestTrendData,
getRecommendedCourses,
computeChanges,
formatChangeValue,
bodyTestMock
formatChangeValue
} from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav, BodyTestRadarChart, BodyTestTrendChart },
data() {
const recordId = ref(null)
const record = ref(null)
const previous = ref(null)
const chartWidth = ref(300)
const radarLabels = computed(() => {
return ['体重', 'BMI', '体脂率', '肌肉量', '内脏脂肪', '基础代谢']
})
const radarKeys = ['weight', 'bmi', 'bodyFat', 'muscleMass', 'visceralFat', 'bmr']
const radarValues = computed(() => {
if (!record.value?.radar) return []
return radarKeys.map((k) => record.value.radar[k] || 0)
})
const metricDefs = [
{ key: 'weight', label: '体重', unit: 'kg' },
{ key: 'bmi', label: 'BMI' },
{ key: 'bodyFat', label: '体脂率', unit: '%' },
{ key: 'muscleMass', label: '肌肉量', unit: 'kg' },
{ key: 'visceralFat', label: '内脏脂肪', unit: '级' },
{ key: 'bmr', label: '基础代谢', unit: 'kcal' },
{ key: 'bodyWater', label: '体水分', unit: '%' },
{ key: 'boneMass', label: '骨量', unit: 'kg' }
]
const metricCards = computed(() => {
if (!record.value?.metrics) return []
const changesVal = changes.value
const defs = metricDefs.slice(0, 8)
return defs.map((def) => {
const val = record.value.metrics[def.key]
const diff = changesVal[def.key]
let changeText = ''
let changeClass = ''
if (diff !== undefined && diff !== null) {
changeText = formatChangeValue(def.key, diff)
const lowerBetter = ['weight', 'bodyFat', 'visceralFat'].includes(def.key)
const isGood = lowerBetter ? diff < 0 : diff > 0
changeClass = isGood ? 'bt-metric__change--down' : 'bt-metric__change--up'
}
const display = def.unit ? `${val}${def.unit === '%' ? '%' : def.unit === 'kg' ? '' : ` ${def.unit}`}` : val
return {
recordId: null,
record: null,
previous: null,
chartWidth: 300
}
},
computed: {
radarLabels() {
return bodyTestMock.radarLabels.map((l) => l.label)
},
radarValues() {
if (!this.record?.radar) return []
const keys = bodyTestMock.radarLabels.map((l) => l.key)
return keys.map((k) => this.record.radar[k] || 0)
},
metricCards() {
if (!this.record?.metrics) return []
const changes = this.changes
const defs = bodyTestMock.metricDefs.slice(0, 8)
return defs.map((def) => {
const val = this.record.metrics[def.key]
const diff = changes[def.key]
let changeText = ''
let changeClass = ''
if (diff !== undefined && diff !== null) {
changeText = formatChangeValue(def.key, diff)
const lowerBetter = ['weight', 'bodyFat', 'visceralFat'].includes(def.key)
const isGood = lowerBetter ? diff < 0 : diff > 0
changeClass = isGood ? 'bt-metric__change--down' : 'bt-metric__change--up'
}
const display = def.unit ? `${val}${def.unit === '%' ? '%' : def.unit === 'kg' ? '' : ` ${def.unit}`}` : val
return {
key: def.key,
label: def.label + (def.unit && def.unit !== '%' && def.unit !== 'kg' ? `(${def.unit})` : def.unit === 'kg' ? '(kg)' : def.unit === '%' ? '(%)' : ''),
display: def.key === 'bodyFat' ? `${val}%` : def.key === 'bodyWater' ? `${val}%` : def.unit === 'kg' ? val : def.unit ? `${val}` : val,
changeText,
changeClass
}
})
},
changes() {
if (this.record?.changes) return this.record.changes
if (this.previous) return computeChanges(this.record, this.previous)
return {}
},
trendPreview() {
const store = loadMemberStore()
return getBodyTestTrendData(store, 'weight', 4)
},
courses() {
return getRecommendedCourses(this.record)
}
},
onLoad(options) {
this.recordId = options?.id ? Number(options.id) : null
this.chartWidth = uni.getSystemInfoSync().windowWidth - 64
this.loadRecord()
},
methods: {
loadRecord() {
const store = loadMemberStore()
const records = store.bodyTest.records
if (this.recordId) {
this.record = getBodyTestRecordById(store, this.recordId)
} else {
this.record = records.length ? { ...records[0] } : null
}
if (this.record) {
const idx = records.findIndex((r) => r.id === this.record.id)
this.previous = idx >= 0 && records[idx + 1] ? records[idx + 1] : null
}
},
onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
},
goTrend() {
navigateToPage(`${PAGE.BODY_TEST_TREND}?metric=weight`)
},
exportReport() {
uni.showLoading({ title: '生成中…' })
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: '报告已保存到相册', icon: 'success' })
}, 1200)
},
shareReport() {
uni.showActionSheet({
itemList: ['分享给微信好友', '生成分享海报', '复制报告链接'],
success: (res) => {
const msgs = ['已唤起微信分享', '海报已生成', '链接已复制']
uni.showToast({ title: msgs[res.tapIndex] || '分享成功', icon: 'none' })
}
})
},
onCourseTap(course) {
uni.showModal({
title: course.title,
content: `${course.coach}\n${course.schedule}\n\n是否前往预约?`,
success: (res) => {
if (res.confirm) {
navigateToPage(PAGE.COURSE_LIST)
}
}
})
},
retest() {
navigateToPage(PAGE.BODY_TEST_HOME)
key: def.key,
label: def.label + (def.unit && def.unit !== '%' && def.unit !== 'kg' ? `(${def.unit})` : def.unit === 'kg' ? '(kg)' : def.unit === '%' ? '(%)' : ''),
display: def.key === 'bodyFat' ? `${val}%` : def.key === 'bodyWater' ? `${val}%` : def.unit === 'kg' ? val : def.unit ? `${val}` : val,
changeText,
changeClass
}
})
})
const changes = computed(() => {
if (record.value?.changes) return record.value.changes
if (previous.value) return computeChanges(record.value, previous.value)
return {}
})
const trendPreview = computed(() => {
const store = loadMemberStore()
return getBodyTestTrendData(store, 'weight', 4)
})
const courses = computed(() => {
return getRecommendedCourses(record.value)
})
function loadRecord() {
const store = loadMemberStore()
const records = store.bodyTest.records
if (recordId.value) {
record.value = getBodyTestRecordById(store, recordId.value)
} else {
record.value = records.length ? { ...records[0] } : null
}
if (record.value) {
const idx = records.findIndex((r) => r.id === record.value.id)
previous.value = idx >= 0 && records[idx + 1] ? records[idx + 1] : null
}
}
function onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
}
function goTrend() {
navigateToPage(`${PAGE.BODY_TEST_TREND}?metric=weight`)
}
function exportReport() {
uni.showLoading({ title: '生成中…' })
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: '报告已保存到相册', icon: 'success' })
}, 1200)
}
function shareReport() {
uni.showActionSheet({
itemList: ['分享给微信好友', '生成分享海报', '复制报告链接'],
success: (res) => {
const msgs = ['已唤起微信分享', '海报已生成', '链接已复制']
uni.showToast({ title: msgs[res.tapIndex] || '分享成功', icon: 'none' })
}
})
}
function onCourseTap(course) {
uni.showModal({
title: course.title,
content: `${course.coach}\n${course.schedule}\n\n是否前往预约?`,
success: (res) => {
if (res.confirm) {
navigateToPage(PAGE.COURSE_LIST)
}
}
})
}
function retest() {
navigateToPage(PAGE.BODY_TEST_HOME)
}
// Initialize
chartWidth.value = uni.getSystemInfoSync().windowWidth - 64
loadRecord()
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@@ -1,99 +1,101 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="体测设置" @back="onBack" />
<view class="bt-page__body">
<view class="bt-card">
<text class="bt-card__title">连接与同步</text>
<view class="bt-setting">
<view>
<text class="bt-setting__label">蓝牙自动连接</text>
<text class="bt-setting__desc">进入体测页时自动搜索已配对设备</text>
</view>
<switch
:checked="settings.bluetoothEnabled"
color="#FF6B35"
@change="onSwitch('bluetoothEnabled', $event)"
/>
</view>
<view class="bt-setting">
<view>
<text class="bt-setting__label">测量完成自动同步</text>
<text class="bt-setting__desc">结果自动保存至云端与本地</text>
</view>
<switch
:checked="settings.autoSync"
color="#FF6B35"
@change="onSwitch('autoSync', $event)"
/>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">通知与隐私</text>
<view class="bt-setting">
<view>
<text class="bt-setting__label">测量完成通知</text>
<text class="bt-setting__desc">体测结束后推送报告摘要</text>
</view>
<switch
:checked="settings.notifyOnComplete"
color="#FF6B35"
@change="onSwitch('notifyOnComplete', $event)"
/>
</view>
<view class="bt-setting">
<view>
<text class="bt-setting__label">分享时匿名化</text>
<text class="bt-setting__desc">隐藏姓名与手机号</text>
</view>
<switch
:checked="settings.shareAnonymous"
color="#FF6B35"
@change="onSwitch('shareAnonymous', $event)"
/>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">单位制</text>
<view class="bt-setting">
<view>
<text class="bt-setting__label">度量单位</text>
<text class="bt-setting__desc">{{ unitLabel }}</text>
</view>
<view
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="toggleUnit"
>
<text style="font-size: 14px; color: #1A4A6F; font-weight: 600;">切换</text>
<view class="bt-setting-row" @tap="toggleUnit">
<text class="bt-setting-row__label">单位制</text>
<view class="bt-setting-row__value">
<text class="bt-setting-row__text">{{ unitLabel }}</text>
<image
class="bt-setting-row__arrow"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/rightarrow.png"
mode="aspectFit"
/>
</view>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">设备管理</text>
<view class="bt-device">
<view class="bt-device__icon-wrap">
<image class="bt-device__icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/mappin2.png" mode="aspectFit" />
<view class="bt-device-row">
<view class="bt-device-row__icon">
<image
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/bluetooth.png"
mode="aspectFit"
/>
</view>
<view class="bt-device__info">
<text class="bt-device__name">{{ device.name }}</text>
<text class="bt-device__status">
{{ device.connected ? '已连接' : '未连接' }}
· 上次 {{ device.lastConnected || '--' }}
<view class="bt-device-row__info">
<text class="bt-device-row__name">{{ device.name || '未连接设备' }}</text>
<text class="bt-device-row__status">
{{ device.connected ? `已连接 · 电量 ${device.battery}%` : '点击连接体脂秤' }}
</text>
</view>
<view
v-if="device.connected"
class="bt-device-row__disconnect"
hover-class="mi-tap-btn--hover"
@tap="disconnect"
>
<text class="bt-device-row__disconnect-text">解除</text>
</view>
</view>
<view
class="bt-btn bt-btn--outline"
style="margin-top: 12px;"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="disconnect"
>
<text class="bt-btn__text">解除设备配对</text>
</view>
<view class="bt-card">
<text class="bt-card__title">通知提醒</text>
<view class="bt-setting-row">
<text class="bt-setting-row__label">体测提醒</text>
<switch
:checked="settings.remindEnabled"
@change="(e) => onSwitch('remindEnabled', e)"
/>
</view>
<view v-if="settings.remindEnabled" class="bt-setting-row">
<text class="bt-setting-row__label">提醒周期</text>
<picker
mode="selector"
:range="['每周', '每两周', '每月']"
:value="settings.remindInterval"
@change="(e) => onSwitch('remindInterval', e)"
>
<view class="bt-setting-row__value">
<text class="bt-setting-row__text">
{{ ['每周', '每两周', '每月'][settings.remindInterval] }}
</text>
<image
class="bt-setting-row__arrow"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/rightarrow.png"
mode="aspectFit"
/>
</view>
</picker>
</view>
</view>
<view class="bt-card">
<text class="bt-card__title">隐私设置</text>
<view class="bt-setting-row">
<text class="bt-setting-row__label">数据可见性</text>
<picker
mode="selector"
:range="['仅本人', '教练可见', '完全公开']"
:value="settings.privacy"
@change="(e) => onSwitch('privacy', e)"
>
<view class="bt-setting-row__value">
<text class="bt-setting-row__text">
{{ ['仅本人', '教练可见', '完全公开'][settings.privacy] }}
</text>
<image
class="bt-setting-row__arrow"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/rightarrow.png"
mode="aspectFit"
/>
</view>
</picker>
</view>
</view>
</view>
@@ -101,7 +103,8 @@
</view>
</template>
<script>
<script setup>
import { ref, computed } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import {
@@ -113,65 +116,59 @@ import {
disconnectBodyTestDevice
} from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return {
settings: {},
device: {}
}
},
computed: {
unitLabel() {
return this.settings.unitSystem === 'imperial' ? '英制 (lb / in)' : '公制 (kg / cm)'
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
this.settings = { ...store.bodyTest.settings }
this.device = { ...store.bodyTest.device }
},
onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
},
onSwitch(key, e) {
const store = loadMemberStore()
updateBodyTestSettings(store, { [key]: e.detail.value })
persistMemberStore(store)
this.settings = { ...store.bodyTest.settings }
uni.showToast({ title: '已保存', icon: 'success' })
},
toggleUnit() {
const store = loadMemberStore()
const next = this.settings.unitSystem === 'metric' ? 'imperial' : 'metric'
updateBodyTestSettings(store, { unitSystem: next })
persistMemberStore(store)
this.settings = { ...store.bodyTest.settings }
uni.showToast({ title: `已切换为${this.unitLabel}`, icon: 'none' })
},
disconnect() {
uni.showModal({
title: '解除配对',
content: '解除后下次体测需重新连接设备,确定继续?',
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
disconnectBodyTestDevice(store)
persistMemberStore(store)
this.refreshFromStore()
uni.showToast({ title: '已解除配对', icon: 'success' })
}
})
}
}
const settings = ref({})
const device = ref({})
const unitLabel = computed(() => {
return settings.value.unitSystem === 'imperial' ? '英制 (lb / in)' : '公制 (kg / cm)'
})
function refreshFromStore() {
const store = loadMemberStore()
settings.value = { ...store.bodyTest.settings }
device.value = { ...store.bodyTest.device }
}
function onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
}
function onSwitch(key, e) {
const store = loadMemberStore()
updateBodyTestSettings(store, { [key]: e.detail.value })
persistMemberStore(store)
refreshFromStore()
uni.showToast({ title: '已保存', icon: 'success' })
}
function toggleUnit() {
const store = loadMemberStore()
const next = settings.value.unitSystem === 'metric' ? 'imperial' : 'metric'
updateBodyTestSettings(store, { unitSystem: next })
persistMemberStore(store)
refreshFromStore()
uni.showToast({ title: `已切换为${unitLabel.value}`, icon: 'none' })
}
function disconnect() {
uni.showModal({
title: '解除配对',
content: '解除后下次体测需重新连接设备,确定继续?',
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
disconnectBodyTestDevice(store)
persistMemberStore(store)
refreshFromStore()
uni.showToast({ title: '已解除配对', icon: 'success' })
}
})
}
refreshFromStore()
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="趋势分析" @back="onBack" />
@@ -56,91 +56,100 @@
</view>
</template>
<script>
<script setup>
import { ref, computed, onMounted } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import BodyTestTrendChart from '@/components/memberInfo/BodyTestTrendChart.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getBodyTestTrendData, bodyTestMock } from '@/common/memberInfo/bodyTestStore.js'
import { getBodyTestTrendData } from '@/common/memberInfo/bodyTestStore.js'
export default {
components: { MemberInfoSubNav, BodyTestTrendChart },
data() {
return {
tabs: bodyTestMock.trendMetrics,
activeMetric: 'weight',
trendPoints: [],
chartWidth: 300
}
},
computed: {
activeLabel() {
return this.tabs.find((t) => t.key === this.activeMetric)?.label || ''
},
activeUnit() {
const units = {
weight: 'kg',
bodyFat: '%',
muscleMass: 'kg',
bmi: ''
}
return units[this.activeMetric] || ''
},
trendPointsReversed() {
return [...this.trendPoints].reverse()
},
summaryText() {
if (this.trendPoints.length < 2) return ''
const first = this.trendPoints[0].value
const last = this.trendPoints[this.trendPoints.length - 1].value
const diff = Math.round((last - first) * 10) / 10
const sign = diff > 0 ? '上升' : diff < 0 ? '下降' : '持平'
const abs = Math.abs(diff)
const lowerBetter = ['weight', 'bodyFat'].includes(this.activeMetric)
let advice = ''
if (diff !== 0) {
const good = lowerBetter ? diff < 0 : diff > 0
advice = good ? ',变化方向符合健康目标' : ',建议关注饮食与训练计划'
}
return `期间${this.activeLabel}${sign} ${abs}${this.activeUnit}${advice}`
}
},
onLoad(options) {
if (options?.metric) {
this.activeMetric = options.metric
}
this.chartWidth = uni.getSystemInfoSync().windowWidth - 64
this.loadTrend()
},
methods: {
onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
},
switchMetric(key) {
this.activeMetric = key
this.loadTrend()
},
loadTrend() {
const store = loadMemberStore()
this.trendPoints = getBodyTestTrendData(store, this.activeMetric, 6)
},
rowDiffText(current, older) {
const diff = Math.round((current.value - older.value) * 10) / 10
const sign = diff > 0 ? '+' : ''
return `${sign}${diff}${this.activeUnit}`
},
rowDiffColor(current, older) {
const diff = current.value - older.value
if (diff === 0) return '#8A99B4'
const lowerBetter = ['weight', 'bodyFat'].includes(this.activeMetric)
const good = lowerBetter ? diff < 0 : diff > 0
return good ? '#2ECC71' : '#F39C12'
}
}
const tabs = ref([
{ key: 'weight', label: '体重' },
{ key: 'bodyFat', label: '体脂率' },
{ key: 'muscleMass', label: '肌肉量' },
{ key: 'bmi', label: 'BMI' }
])
const activeMetric = ref('weight')
const trendPoints = ref([])
const chartWidth = ref(300)
const units = {
weight: 'kg',
bodyFat: '%',
muscleMass: 'kg',
bmi: ''
}
const activeLabel = computed(() => {
return tabs.value.find((t) => t.key === activeMetric.value)?.label || ''
})
const activeUnit = computed(() => {
return units[activeMetric.value] || ''
})
const trendPointsReversed = computed(() => {
return [...trendPoints.value].reverse()
})
const summaryText = computed(() => {
if (trendPoints.value.length < 2) return ''
const first = trendPoints.value[0].value
const last = trendPoints.value[trendPoints.value.length - 1].value
const diff = Math.round((last - first) * 10) / 10
const sign = diff > 0 ? '上升' : diff < 0 ? '下降' : '持平'
const abs = Math.abs(diff)
const lowerBetter = ['weight', 'bodyFat'].includes(activeMetric.value)
let advice = ''
if (diff !== 0) {
const good = lowerBetter ? diff < 0 : diff > 0
advice = good ? ',变化方向符合健康目标' : ',建议关注饮食与训练计划'
}
return `期间${activeLabel.value}${sign} ${abs}${activeUnit.value}${advice}`
})
function onBack() {
goBackOrTab(PAGE.BODY_TEST_HOME)
}
function switchMetric(key) {
activeMetric.value = key
loadTrend()
}
function loadTrend() {
const store = loadMemberStore()
trendPoints.value = getBodyTestTrendData(store, activeMetric.value, 6)
}
function rowDiffText(current, older) {
const diff = Math.round((current.value - older.value) * 10) / 10
const sign = diff > 0 ? '+' : ''
return `${sign}${diff}${activeUnit.value}`
}
function rowDiffColor(current, older) {
const diff = current.value - older.value
if (diff === 0) return '#8A99B4'
const lowerBetter = ['weight', 'bodyFat'].includes(activeMetric.value)
const good = lowerBetter ? diff < 0 : diff > 0
return good ? '#2ECC71' : '#F39C12'
}
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage?.options || {}
if (options?.metric) {
activeMetric.value = options.metric
}
chartWidth.value = uni.getSystemInfoSync().windowWidth - 64
loadTrend()
})
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
+209 -111
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,18 @@
</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>
<!-- 骨架屏 -->
<ListSkeleton v-if="loading" :count="4" layout="card" />
<!-- 无数据 -->
<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"
@@ -97,113 +84,224 @@
</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
v-if="item.canCancel"
class="bk-card__cancel"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap.stop="cancelBooking(item)"
>
<text class="bk-card__cancel-text">取消预约</text>
<view class="bk-card__actions">
<view
v-if="item.canSignin"
class="bk-card__signin"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap.stop="signinCourse(item)"
>
<text class="bk-card__signin-text">签到</text>
</view>
<view
v-if="item.canCancel"
class="bk-card__cancel"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap.stop="cancelBooking(item)"
>
<text class="bk-card__cancel-text">取消预约</text>
</view>
</view>
</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>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { bookingMock } from '@/common/memberInfo/mockData.js'
import {
loadMemberStore,
cancelOngoingBooking,
formatUpcomingAlert
} from '@/common/memberInfo/store.js'
import { canCancelBooking } from '@/common/memberInfo/bookingStore.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
<script setup>
import { ref, computed, onMounted } from 'vue'
import PageHeader from '@/components/index/PageHeader.vue'
import ListSkeleton from '@/components/Skeleton/ListSkeleton.vue'
import { getMemberId } from '@/utils/request.js'
import { getCourseCoverUrl } from '@/utils/request.js'
import { getMemberBookings, signinGroupCourse, cancelBooking as cancelBookingApi } from '@/api/main.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
tabs: bookingMock.tabs,
ongoingBookings: [],
historyBookings: [],
activeTab: 'ongoing'
}
},
computed: {
displayedBookings() {
return this.activeTab === 'ongoing'
? this.ongoingBookings
: this.historyBookings
},
upcomingAlert() {
return formatUpcomingAlert(this.ongoingBookings[0])
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
this.ongoingBookings = store.ongoingBookings.map((item) => ({
...item,
canCancel: canCancelBooking(item)
}))
this.historyBookings = store.historyBookings.map((item) => ({ ...item }))
},
goCourseList() {
navigateToPage(PAGE.COURSE_LIST)
},
setActiveTab(tab) {
this.activeTab = tab
},
cancelBooking(item) {
if (!canCancelBooking(item)) {
uni.showToast({ title: '距开课不足2小时,无法取消', icon: 'none' })
return
}
uni.showModal({
title: '取消预约',
content: `确定要取消「${item.title}」吗?`,
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
const result = cancelOngoingBooking(store, item.id)
if (!result.ok) {
uni.showToast({ title: result.message, icon: 'none' })
return
}
this.refreshFromStore()
uni.showToast({ title: '已取消', icon: 'success' })
}
})
const tabs = ref([
{ key: 'ongoing', label: '进行中' },
{ key: 'history', label: '历史记录' }
])
const activeTab = ref('ongoing')
const ongoingBookings = ref([])
const historyBookings = ref([])
const loading = ref(false)
const displayedBookings = computed(() => {
return activeTab.value === 'ongoing'
? ongoingBookings.value
: historyBookings.value
})
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 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: getCourseCoverUrl(b.coverImage) || 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/default-course.png',
coach: b.coachName || b.coach || '教练',
footerText: status === 'ongoing' ? '请准时参加课程' : '',
canSignin: status === 'ongoing',
canCancel: status === 'ongoing'
}
}
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 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
}
}
//
async function signinCourse(item) {
const memberId = getMemberId()
if (!memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
uni.showModal({
title: '确认签到',
content: `确认签到「${item.title}」吗?`,
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '签到中...', mask: true })
try {
const result = await signinGroupCourse(Number(memberId), Number(item.courseId || item.id))
uni.hideLoading()
if (result.code === 200 || result.success) {
uni.showToast({ title: '签到成功', icon: 'success' })
fetchBookings()
} else {
uni.showToast({ title: result.message || '签到失败', icon: 'none' })
}
} catch (e) {
uni.hideLoading()
console.error('[booking] 签到失败:', e)
uni.showToast({ title: e.message || '签到失败', icon: 'none' })
}
}
})
}
//
async function cancelBooking(item) {
uni.showModal({
title: '取消预约',
content: `确定要取消「${item.title}」吗?`,
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '取消中...', mask: true })
try {
const result = await cancelBookingApi(Number(item.id))
uni.hideLoading()
if (result.code === 200 || result.success) {
uni.showToast({ title: '已取消', icon: 'success' })
fetchBookings()
} else {
uni.showToast({ title: result.message || '取消失败', icon: 'none' })
}
} catch (e) {
uni.hideLoading()
console.error('[booking] 取消预约失败:', e)
uni.showToast({ title: e.message || '取消失败', icon: 'none' })
}
}
})
}
onMounted(() => {
fetchBookings()
})
</script>
<style>
<style lang="scss">
@import '@/common/style/base.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/booking-page.css';
</style>
@@ -1,7 +1,7 @@
<template>
<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>
@@ -46,50 +46,97 @@
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { moduleMock, getCheckInHistory } 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 { getCheckInRecords } from '@/api/main.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
tabs: moduleMock.checkInTabs,
activeFilter: 'all',
list: []
}
},
computed: {
filteredList() {
if (this.activeFilter === 'all') return this.list
return this.list.filter((i) => i.tagTheme === this.activeFilter)
}
},
onShow() {
const store = loadMemberStore()
this.list = getCheckInHistory(store, 'all')
},
methods: {
showDetail(item) {
uni.showModal({
title: item.title,
content: `${item.time}\n地点:${item.location}\n类型:${item.tag}`,
showCancel: false
})
const tabs = ref([
{ key: 'all', 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)
})
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 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 ? '地点:' + item.location + '\n' : ''}类型:${item.tag}`,
showCancel: false
})
}
onMounted(() => {
fetchCheckInHistory()
})
</script>
<style>
<style lang="scss">
@import '@/common/style/base.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/body-test-common.css';
@import '@/common/style/memberInfo/pages/module-pages-common.css';
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="领券中心" @back="onBack" />
@@ -33,38 +33,37 @@
</view>
</template>
<script>
<script setup>
import { ref, onMounted } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import { getCouponCenterList, claimCouponFromCenter } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { list: [] }
},
onShow() {
const store = loadMemberStore()
this.list = getCouponCenterList(store)
},
methods: {
onBack() { goBackOrTab(PAGE.COUPONS) },
claim(item) {
if (item.claimed) return
const store = loadMemberStore()
const result = claimCouponFromCenter(store, item.id)
uni.showToast({ title: result.message, icon: result.ok ? 'success' : 'none' })
if (result.ok) {
persistMemberStore(store)
this.list = getCouponCenterList(store)
}
}
const list = ref([])
onMounted(() => {
const store = loadMemberStore()
list.value = getCouponCenterList(store)
})
function onBack() {
goBackOrTab(PAGE.COUPONS)
}
function claim(item) {
if (item.claimed) return
const store = loadMemberStore()
const result = claimCouponFromCenter(store, item.id)
uni.showToast({ title: result.message, icon: result.ok ? 'success' : 'none' })
if (result.ok) {
persistMemberStore(store)
list.value = getCouponCenterList(store)
}
}
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page" v-if="coupon">
<MemberInfoSubNav title="优惠券详情" @back="onBack" />
@@ -36,31 +36,33 @@
</view>
</template>
<script>
<script setup>
import { ref, onMounted } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { getCouponById } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { coupon: null }
},
onLoad(options) {
const store = loadMemberStore()
this.coupon = getCouponById(store, options?.id)
},
methods: {
onBack() { goBackOrTab(PAGE.COUPONS) },
useNow() {
navigateToPage(PAGE.COURSE_LIST)
}
}
const coupon = ref(null)
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage?.options || {}
const store = loadMemberStore()
coupon.value = getCouponById(store, options?.id)
})
function onBack() {
goBackOrTab(PAGE.COUPONS)
}
function useNow() {
navigateToPage(PAGE.COURSE_LIST)
}
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
+72 -69
View File
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="我的优惠券" @back="goBack" />
@@ -61,81 +61,84 @@
</view>
</template>
<script>
<script setup>
import { ref, computed } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { moduleMock, getCouponsByStatus, useCoupon, deleteExpiredCoupon } from '@/common/memberInfo/moduleStore.js'
import { PAGE, navigateToPage, backToMemberCenter } from '@/common/constants/routes.js'
import { getCouponsByStatus, useCoupon, deleteExpiredCoupon } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
tabs: moduleMock.couponTabs,
activeTab: 'available',
coupons: []
}
},
computed: {
displayedCoupons() {
return this.coupons.filter((c) => c.status === this.activeTab)
},
countByTab() {
return {
available: this.coupons.filter((c) => c.status === 'available').length,
used: this.coupons.filter((c) => c.status === 'used').length,
expired: this.coupons.filter((c) => c.status === 'expired').length
}
},
activeTabLabel() {
return this.tabs.find((t) => t.key === this.activeTab)?.label || ''
}
},
onShow() {
this.refreshList()
},
methods: {
refreshList() {
const store = loadMemberStore()
this.coupons = [
...getCouponsByStatus(store, 'available'),
...getCouponsByStatus(store, 'used'),
...getCouponsByStatus(store, 'expired')
]
},
onCouponTap(item) {
navigateToPage(`${PAGE.COUPON_DETAIL}?id=${item.id}`)
},
useNow(item) {
uni.showModal({
title: '使用优惠券',
content: `确认使用「${item.title}」?`,
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
useCoupon(store, item.id)
persistMemberStore(store)
this.refreshList()
navigateToPage(PAGE.COURSE_LIST)
}
})
},
removeExpired(item) {
const store = loadMemberStore()
deleteExpiredCoupon(store, item.id)
persistMemberStore(store)
this.refreshList()
},
goCenter() {
navigateToPage(PAGE.COUPON_CENTER)
}
const tabs = ref([
{ key: 'available', label: '可用' },
{ key: 'used', label: '已用' },
{ key: 'expired', label: '已失效' }
])
const activeTab = ref('available')
const coupons = ref([])
const displayedCoupons = computed(() => {
return coupons.value.filter((c) => c.status === activeTab.value)
})
const countByTab = computed(() => {
return {
available: coupons.value.filter((c) => c.status === 'available').length,
used: coupons.value.filter((c) => c.status === 'used').length,
expired: coupons.value.filter((c) => c.status === 'expired').length
}
})
const activeTabLabel = computed(() => {
return tabs.value.find((t) => t.key === activeTab.value)?.label || ''
})
function refreshList() {
const store = loadMemberStore()
coupons.value = [
...getCouponsByStatus(store, 'available'),
...getCouponsByStatus(store, 'used'),
...getCouponsByStatus(store, 'expired')
]
}
function goBack() {
backToMemberCenter()
}
function onCouponTap(item) {
navigateToPage(`${PAGE.COUPON_DETAIL}?id=${item.id}`)
}
function useNow(item) {
uni.showModal({
title: '使用优惠券',
content: `确认使用「${item.title}」?`,
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
useCoupon(store, item.id)
persistMemberStore(store)
refreshList()
navigateToPage(PAGE.COURSE_LIST)
}
})
}
function removeExpired(item) {
const store = loadMemberStore()
deleteExpiredCoupon(store, item.id)
persistMemberStore(store)
refreshList()
}
function goCenter() {
navigateToPage(PAGE.COUPON_CENTER)
}
refreshList()
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page" v-if="course">
<MemberInfoSubNav title="课程详情" @back="onBack" />
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="课程评价" @back="onBack" />
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page mi-course-list">
<MemberInfoSubNav title="预约课程" @back="goBack" />
@@ -0,0 +1,943 @@
<template>
<view class="login-page">
<!-- 自定义Toast弹窗 -->
<view class="custom-toast" :class="{ show: toastShow }">
<text class="toast-text">{{ toastMessage }}</text>
</view>
<!-- ==================== 微信小程序登录 ==================== -->
<template v-if="isWechatMiniProgram">
<view class="login-header" :style="{ paddingTop: statusBarHeightPx }">
<view class="logo-wrapper">
<image class="logo-image" src="/static/logo.png" mode="aspectFit" />
<text class="app-name">活氧舱欢迎您</text>
<text class="app-slogan">享受运动拥抱健康</text>
</view>
</view>
<view class="wechat-card">
<view class="card-body wechat-login-body">
<text class="phone-display">微信一键登录</text>
<text class="auth-tip">授权获取您的微信信息</text>
<!-- 获取微信头像昵称 -->
<button
v-if="canUseGetUserProfile"
class="login-btn wechat-btn"
hover-class="wechat-btn-hover"
@tap="handleGetUserProfile"
:disabled="isLoading"
>
<text>{{ isLoading ? '登录中...' : '微信一键登录' }}</text>
</button>
<!-- 低版本兼容 -->
<button
v-else
class="login-btn wechat-btn"
hover-class="wechat-btn-hover"
open-type="getUserInfo"
@getuserinfo="handleGetUserInfo"
:disabled="isLoading"
>
<text>{{ isLoading ? '登录中...' : '微信一键登录' }}</text>
</button>
<view class="agreement-text" :class="{ shake: shakeAgreement }">
<checkbox-group @change="onAgreementChange">
<checkbox :checked="agreed" value="agreed" color="#07C160" />
</checkbox-group>
<text>登录即表示同意</text>
<text class="link" @tap="showAgreement">用户协议</text>
<text></text>
<text class="link" @tap="showPrivacy">隐私政策</text>
</view>
</view>
</view>
</template>
<!-- ==================== App/其他平台登录原有 ==================== -->
<template v-else>
<view class="login-header">
<view class="logo-wrapper">
<image class="logo-image" src="/static/logo.png" mode="aspectFit" />
<text class="app-name">活氧舱欢迎您</text>
<text class="app-slogan">享受运动拥抱健康</text>
</view>
</view>
<view class="card-container">
<!-- 手机号+验证码登录卡片 -->
<view class="login-card sms-card" :class="{ active: activeCard === 'sms' }">
<view class="card-body">
<text class="phone-display">验证码登录</text>
<text class="auth-tip">认证服务由中国移动提供</text>
<view class="form-group phone-form-group">
<input
v-model="smsPhone"
type="number"
placeholder="请输入手机号"
maxlength="11"
class="underline-input"
confirm-type="next"
@focus="isSmsPhoneFocused = true"
@blur="isSmsPhoneFocused = false"
/>
</view>
<view class="form-group code-form-group">
<VerifyCodeInput
ref="codeInputRef"
v-model="code"
:length="4"
:mask="false"
type="underline"
activeColor="#FF8C42"
@complete="handleCodeComplete"
@focus="onInputFocus"
@blur="onInputBlur"
/>
</view>
<button
class="login-btn primary"
:class="{ disabled: countdown > 0 || isLoading }"
@tap="handleSendCode"
:disabled="countdown > 0 || isLoading"
>
<text>{{ countdown > 0 ? `${countdown}秒后重发` : '发送验证码' }}</text>
</button>
<button class="login-btn secondary" @tap="switchToPhone">
<text>其他登录方式</text>
</button>
<view class="agreement-text" :class="{ shake: shakeAgreement }">
<checkbox-group @change="onAgreementChange">
<checkbox :checked="agreed" value="agreed" color="#FF8C42" />
</checkbox-group>
<text>登录即表示同意</text>
<text class="link" @tap="showAgreement">用户协议</text>
<text></text>
<text class="link" @tap="showPrivacy">隐私政策</text>
</view>
</view>
</view>
<!-- 手机号一键登录卡片 -->
<view class="login-card phone-card" :class="{ active: activeCard === 'phone' }">
<view class="card-body">
<text class="phone-display">本机号码登录</text>
<text class="auth-tip">认证服务由中国移动提供</text>
<button class="login-btn primary" :class="{ disabled: isLoading, loading: isLoading }" @tap="handlePhoneLogin" :disabled="isLoading">
<text>{{ isLoading ? '登录中...' : '一键登录' }}</text>
</button>
<button class="login-btn secondary" @tap="switchToSms">
<text>其他登录方式</text>
</button>
<view class="agreement-text" :class="{ shake: shakeAgreement }">
<checkbox-group @change="onAgreementChange">
<checkbox :checked="agreed" value="agreed" color="#FF8C42" />
</checkbox-group>
<text>登录即表示同意</text>
<text class="link" @tap="showAgreement">用户协议</text>
<text></text>
<text class="link" @tap="showPrivacy">隐私政策</text>
</view>
</view>
</view>
</view>
</template>
</view>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { setToken, setRefreshToken } from '@/utils/request.js'
import { loginWithPhone, oneClickLogin, sendCode, login } from '@/api/main.js'
import VerifyCodeInput from '@/components/global/VerifyCodeInput.vue'
// ===== =====
const isWechatMiniProgram = ref(false)
const statusBarHeightPx = ref('0px')
onMounted(() => {
//
const sysInfo = uni.getSystemInfoSync()
statusBarHeightPx.value = (sysInfo.statusBarHeight || 0) + 'px'
//
// #ifdef MP-WEIXIN
isWechatMiniProgram.value = true
// #endif
if (!isWechatMiniProgram.value) {
//
activeCard.value = 'phone'
}
})
const activeCard = ref('phone')
const isLoading = ref(false)
const agreed = ref(true)
const shakeAgreement = ref(false)
const smsPhone = ref('')
const isSmsPhoneFocused = ref(false)
const code = ref('')
const isCodeFocused = ref(false)
const countdown = ref(0)
let timer = null
// Toast
const toastShow = ref(false)
const toastMessage = ref('')
let toastTimer = null
function showToast(msg) {
if (toastTimer) clearTimeout(toastTimer)
toastMessage.value = msg
toastShow.value = true
toastTimer = setTimeout(() => {
toastShow.value = false
}, 2000)
}
/**
* 保存登录信息到本地存储
*/
function saveLoginInfo(loginInfo) {
if (!loginInfo) return
// token ( request.js setToken )
//
if (loginInfo.memberId || loginInfo.id) {
const memberInfo = {
id: loginInfo.memberId || loginInfo.id,
memberId: loginInfo.memberId || loginInfo.id,
phone: loginInfo.phone || '',
nickname: loginInfo.nickname || '',
avatar: loginInfo.avatar || ''
}
uni.setStorageSync('loginMemberInfo', memberInfo)
console.log('[login] 登录信息已保存:', memberInfo)
}
}
function onAgreementChange(e) {
agreed.value = e.detail.value.includes('agreed')
}
function switchToSms() {
activeCard.value = 'sms'
}
function switchToPhone() {
activeCard.value = 'phone'
}
function showAgreement() {
uni.showModal({ title: '用户协议', content: '这里是用户协议内容...', showCancel: false })
}
function showPrivacy() {
uni.showModal({ title: '隐私政策', content: '这里是隐私政策内容...', showCancel: false })
}
function handleCodeComplete(val) {
if (!isLoading.value) {
setTimeout(() => {
handleSmsLogin()
}, 100)
}
}
function onInputFocus() {
isCodeFocused.value = true
}
function onInputBlur() {
isCodeFocused.value = false
}
function validateSmsPhone() {
if (!smsPhone.value) {
showToast('请输入手机号')
return false
}
if (!/^1[3-9]\d{9}$/.test(smsPhone.value)) {
showToast('请输入正确的手机号')
return false
}
return true
}
async function handlePhoneLogin() {
if (!agreed.value) {
triggerAgreementShake()
return
}
isLoading.value = true
try {
await new Promise((resolve, reject) => {
uni.preLogin({
provider: 'univerify',
success: resolve,
fail: (err) => {
console.warn('预登录失败,切换到验证码登录:', err)
reject(err)
}
})
})
uni.login({
provider: 'univerify',
loginBtnText: '本机号码一键登录',
univerifyStyle: {
fullScreen: true,
backgroundColor: '#ffffff',
icon: {
path: '/static/logo.png',
width: '60px',
height: '60px'
},
phoneNum: {
color: '#333333'
},
slogan: {
color: '#999999'
},
authButton: {
normalColor: '#FF8C42',
highlightColor: '#E07A38',
disabledColor: '#FFB88A',
textColor: '#ffffff',
title: '本机号码一键登录',
borderRadius: '24px'
},
otherLoginButton: {
visible: true,
normalColor: '#ffffff',
highlightColor: '#f5f5f5',
textColor: '#666666',
title: '其他登录方式',
borderColor: '#e5e5e5',
borderRadius: '24px'
},
privacyTerms: {
defaultCheckBoxState: true,
textColor: '#999999',
termsColor: '#FF8C42',
prefix: '我已阅读并同意',
suffix: '并使用本机号码登录',
privacyItems: [
{
url: 'https://example.com/agreement',
title: '用户服务协议'
},
{
url: 'https://example.com/privacy',
title: '隐私政策'
}
]
}
},
success: async (loginRes) => {
uni.closeAuthView()
try {
const authResult = loginRes.authResult
if (!authResult) {
console.error('authResult为空:', loginRes)
showToast('一键登录失败,请使用验证码登录')
switchToSms()
isLoading.value = false
return
}
const accessToken = authResult.accessToken || authResult.access_token || authResult.token
const openid = authResult.openid || authResult.openId
console.log('accessToken:', accessToken, 'openid:', openid)
if (!accessToken) {
console.error('accessToken为空,authResult:', authResult)
showToast('一键登录失败,请使用验证码登录')
switchToSms()
isLoading.value = false
return
}
const res = await oneClickLogin({
accessToken: accessToken,
openid: openid,
nickname: '',
avatar: ''
})
if (res && res.accessToken) {
setToken(res.accessToken)
if (res.refreshToken) {
setRefreshToken(res.refreshToken)
}
saveLoginInfo(res)
showToast('登录成功')
setTimeout(() => {
uni.navigateBack({ delta: 1 })
}, 1500)
} else {
showToast('一键登录失败,请使用验证码登录')
switchToSms()
}
} catch (e) {
console.error('一键登录处理失败:', e)
showToast(e?.message || '一键登录失败,请使用验证码登录')
switchToSms()
} finally {
isLoading.value = false
}
},
fail: (err) => {
uni.closeAuthView()
console.error('一键登录失败:', err)
isLoading.value = false
if (err.code === 30008 || err.errMsg && err.errMsg.includes('其他登录方式')) {
switchToSms()
} else {
showToast('一键登录失败,请使用验证码登录')
switchToSms()
}
}
})
} catch (err) {
console.error('一键登录调用失败:', err)
showToast('一键登录失败,请使用验证码登录')
isLoading.value = false
switchToSms()
}
}
function triggerAgreementShake() {
shakeAgreement.value = true
showToast('请先同意用户协议和隐私政策')
setTimeout(() => {
shakeAgreement.value = false
}, 500)
}
async function handleSendCode() {
if (countdown.value > 0) {
showToast('验证码在有效期内,请勿重复发送')
return
}
if (!validateSmsPhone()) return
isLoading.value = true
try {
const res = await sendCode({ phone: smsPhone.value })
if (res && res.success) {
countdown.value = 60
startCountdown()
showToast('验证码已发送')
} else {
showToast(res?.message || '验证码发送失败')
}
} catch (err) {
console.error('发送验证码失败:', err)
showToast(err?.message || '验证码发送失败')
} finally {
isLoading.value = false
}
}
function startCountdown() {
if (timer) clearInterval(timer)
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer)
timer = null
}
}, 1000)
}
async function handleSmsLogin() {
if (!agreed.value) {
triggerAgreementShake()
return
}
if (!validateSmsPhone()) return
if (code.value.length !== 4) {
showToast('请输入4位验证码')
return
}
isLoading.value = true
try {
const res = await loginWithPhone({
phone: smsPhone.value,
code: code.value
})
if (res && res.accessToken) {
setToken(res.accessToken)
if (res.refreshToken) {
setRefreshToken(res.refreshToken)
}
saveLoginInfo(res)
showToast('登录成功')
setTimeout(() => {
uni.switchTab({ url: '/pages/memberInfo/memberInfo' })
}, 1500)
} else {
showToast('登录失败,请重试')
}
} catch (err) {
console.error('验证码登录失败:', err)
showToast(err?.message || '登录失败,请重试')
} finally {
isLoading.value = false
}
}
// ===== =====
const canUseGetUserProfile = ref(false)
// getUserProfile 2.10.4+
onMounted(() => {
// #ifdef MP-WEIXIN
if (wx.getUserProfile) {
canUseGetUserProfile.value = true
}
// #endif
})
/**
* 微信小程序登录新版getUserProfile
*/
function handleGetUserProfile() {
if (!agreed.value) {
triggerAgreementShake()
return
}
isLoading.value = true
// #ifdef MP-WEIXIN
wx.getUserProfile({
desc: '用于完善会员资料',
success: (profileRes) => {
const userInfo = profileRes.userInfo
console.log('[微信登录] 获取用户信息成功:', userInfo)
//
performWechatLogin(userInfo)
},
fail: (err) => {
console.error('[微信登录] 获取用户信息失败:', err)
isLoading.value = false
//
performWechatLogin(null)
}
})
// #endif
}
/**
* 微信小程序登录旧版getUserInfo
*/
function handleGetUserInfo(e) {
if (!agreed.value) {
triggerAgreementShake()
return
}
isLoading.value = true
const userInfo = e.detail.userInfo
if (userInfo) {
console.log('[微信登录] 获取用户信息成功:', userInfo)
}
performWechatLogin(userInfo)
}
/**
* 执行微信登录调用 uni.login 获取 code发送到后端
*/
function performWechatLogin(userInfo) {
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',
success: async (loginRes) => {
const code = loginRes.code
console.log('[微信登录] 获取 code 成功:', code)
if (!code) {
showToast('微信登录失败,请重试')
isLoading.value = false
return
}
try {
const res = await login({
code: code,
nickname: userInfo?.nickName || '',
avatar: userInfo?.avatarUrl || ''
})
console.log('[微信登录] 后端返回:', res)
if (res && res.accessToken) {
setToken(res.accessToken)
if (res.refreshToken) {
setRefreshToken(res.refreshToken)
}
saveLoginInfo(res)
showToast('登录成功')
setTimeout(() => {
uni.switchTab({ url: '/pages/memberInfo/memberInfo' })
}, 1500)
} else {
showToast('登录失败,请重试')
}
} catch (e) {
console.error('[微信登录] 后端调用失败:', e)
showToast(e?.message || '登录失败,请重试')
} finally {
isLoading.value = false
}
},
fail: (err) => {
console.error('[微信登录] uni.login 失败:', err)
showToast('微信登录失败,请重试')
isLoading.value = false
}
})
// #endif
}
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<style lang="scss" scoped>
// Toast
.custom-toast {
position: fixed;
top: -100rpx;
left: 50%;
transform: translateX(-50%) translateY(0);
background: rgba(0, 0, 0, 0.8);
border-radius: $radius-lg;
padding: 24rpx 48rpx;
z-index: 9999;
opacity: 0;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
&.show {
top: 120rpx;
opacity: 1;
}
.toast-text {
color: $text-inverse;
font-size: 28rpx;
white-space: nowrap;
}
}
button::after {
display: none;
}
.login-page {
min-height: 100vh;
background: $bg-gradient-primary;
display: flex;
flex-direction: column;
padding: 48rpx;
box-sizing: border-box;
overflow: hidden;
}
.login-header {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 80rpx;
}
.logo-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.logo-image {
width: 140rpx;
height: 140rpx;
border-radius: $radius-lg;
box-shadow: $shadow-lg;
}
.app-name {
margin-top: 24rpx;
font-size: 42rpx;
font-weight: $font-weight-extrabold;
color: $primary-dark;
letter-spacing: 4rpx;
}
.app-slogan {
margin-top: 12rpx;
font-size: 26rpx;
color: $text-light;
}
.card-container {
position: relative;
width: 100%;
height: 500rpx;
}
/* ===== 微信小程序登录卡片 ===== */
.wechat-card {
width: 100%;
box-sizing: border-box;
background: $bg-white;
border-radius: $radius-lg;
box-shadow: 0 24rpx 60rpx rgba(0, 0, 0, 0.18), 0 8rpx 20rpx rgba(0, 0, 0, 0.1);
padding: 48rpx 40rpx;
}
.wechat-login-body {
min-height: 400rpx;
justify-content: flex-start;
gap: 24rpx;
}
.wechat-btn {
width: 100%;
height: 100rpx;
border-radius: $radius-md;
border: none;
background: #07C160;
color: #ffffff;
font-size: 34rpx;
font-weight: $font-weight-bold;
box-shadow: 0 8rpx 24rpx rgba(7, 193, 96, 0.3);
margin-top: 24rpx;
display: flex;
justify-content: center;
align-items: center;
&:active {
transform: scale(0.98);
opacity: 0.9;
}
&[disabled] {
background: #A0E0B0;
box-shadow: none;
}
}
.wechat-btn-hover {
opacity: 0.85;
transform: scale(0.98);
}
.login-card {
position: absolute;
height: 620rpx;
background: $bg-white;
border-radius: $radius-lg;
padding: 48rpx 40rpx;
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform, z-index, filter;
}
.card-container {
position: relative;
width: 100%;
height: 560rpx;
}
.sms-card {
z-index: 1;
top: -60rpx;
left: -100rpx;
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.12);
filter: blur(10rpx);
&.active {
z-index: 10;
top: 0;
left: 0;
box-shadow: 0 24rpx 60rpx rgba(0, 0, 0, 0.18), 0 8rpx 20rpx rgba(0, 0, 0, 0.1);
filter: blur(0);
}
}
.phone-card {
z-index: 10;
top: 0;
left: 0;
box-shadow: 0 24rpx 60rpx rgba(0, 0, 0, 0.18), 0 8rpx 20rpx rgba(0, 0, 0, 0.1);
filter: blur(0);
&:not(.active) {
z-index: 1;
top: -60rpx;
left: -100rpx;
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.12);
filter: blur(10rpx);
}
}
.card-body {
display: flex;
flex-direction: column;
align-items: center;
}
.phone-display {
font-size: 48rpx;
font-weight: $font-weight-bold;
color: $text-dark;
margin-bottom: 12rpx;
}
.auth-tip {
font-size: 24rpx;
color: $text-light;
margin-bottom: 40rpx;
}
.form-group {
width: 100%;
margin-bottom: 24rpx;
position: relative;
}
.phone-form-group {
height: 100rpx;
width: 360rpx;
display: flex;
align-items: center;
justify-content: center;
}
.underline-input {
width: 100%;
height: 100%;
text-align: center;
font-size: 32rpx;
color: $text-dark;
background: transparent;
border: none;
border-bottom: 2rpx solid $border-light;
padding: 0 20rpx;
transition: border-color 0.3s ease;
&:focus {
border-bottom-color: $accent-orange;
outline: none;
}
&::placeholder {
color: $text-light;
font-size: 28rpx;
}
}
.code-form-group {
height: 100rpx;
display: flex;
align-items: center;
justify-content: center;
}
.login-btn {
width: 100%;
height: 100rpx;
border-radius: $radius-md;
border: none;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
&.primary {
background: $gradient-orange;
color: $text-inverse;
font-size: 34rpx;
font-weight: $font-weight-bold;
box-shadow: $shadow-orange-glow;
&:active {
transform: scale(0.98);
opacity: 0.9;
}
&.disabled {
background: $border-light;
color: $text-light;
box-shadow: none;
}
&.loading {
background: $accent-orange;
}
}
&.secondary {
background: #F8F9FA;
color: $text-dark;
font-size: 30rpx;
font-weight: $font-weight-medium;
margin-top: 24rpx;
&:active {
background: #E9ECEF;
}
}
}
.agreement-text {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
font-size: 24rpx;
color: $text-light;
margin-top: 36rpx;
.link {
color: $accent-orange;
margin: 0 6rpx;
}
&.shake {
animation: shake 0.5s ease-in-out;
}
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-8rpx); }
20%, 40%, 60%, 80% { transform: translateX(8rpx); }
}
</style>
@@ -1,210 +0,0 @@
<template>
<view class="scroll-container theme-light">
<view class="member-card-page">
<MemberInfoSubNav title="我的会员卡" @back="goBack" />
<view class="member-card-page__body">
<view class="mc-hero">
<view class="mc-hero__top">
<view class="mc-hero__title-row">
<image
class="mc-hero__crown"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/crown.png"
mode="aspectFit"
/>
<text class="mc-hero__name">{{ card.name }}</text>
</view>
<view class="mc-hero__badge">
<text class="mc-hero__badge-text">{{ card.status }}</text>
</view>
</view>
<text class="mc-hero__validity">有效期{{ card.validity }}</text>
<view class="mc-hero__bottom">
<view class="mc-hero__days">
<text class="mc-hero__days-num">{{ remainingDays }}</text>
<text class="mc-hero__days-unit"></text>
</view>
<view
class="mc-hero__renew"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="renewCard"
>
<image
class="mc-hero__renew-icon"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/refreshcw.png"
mode="aspectFit"
/>
<text class="mc-hero__renew-text">立即续费</text>
</view>
</view>
</view>
<view class="mc-records">
<view class="mc-records__header">
<text class="mc-records__title">使用记录</text>
<view class="mc-records__tabs">
<view
v-for="tab in recordTabs"
:key="tab.key"
class="mc-records__tab"
:class="{ 'mc-records__tab--active': activeFilter === tab.key }"
hover-class="mi-tap-tab--hover"
:hover-stay-time="150"
@tap="switchFilter(tab.key)"
>
<text
class="mc-records__tab-text"
:class="{ 'mc-records__tab-text--active': activeFilter === tab.key }"
>
{{ tab.label }}
</text>
</view>
</view>
</view>
<view class="mc-records__divider"></view>
<view
v-for="(item, index) in filteredRecords"
:key="item.id"
class="mc-records__item"
>
<view v-if="index > 0" class="mc-records__divider"></view>
<view
class="mc-records__item-inner"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="showRecordDetail(item)"
>
<view
class="mc-records__icon-wrap"
:class="'mc-records__icon-wrap--' + item.iconTheme"
>
<image
class="mc-records__icon"
:src="item.icon"
mode="aspectFit"
/>
</view>
<view class="mc-records__info">
<text class="mc-records__item-title">{{ item.title }}</text>
<text class="mc-records__item-time">{{ item.time }}</text>
</view>
<text
class="mc-records__value"
:class="'mc-records__value--' + item.valueType"
>
{{ item.value }}
</text>
</view>
</view>
<view v-if="!filteredRecords.length" class="mc-records__empty">
<text class="mc-records__empty-text">暂无记录</text>
</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>
</view>
</view>
</template>
<script>
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { memberCardMock } from '@/common/memberInfo/mockData.js'
import {
loadMemberStore,
computeRemainingDays,
renewMemberCard
} from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
card: {},
recordTabs: memberCardMock.recordTabs,
records: [],
rules: memberCardMock.rules,
activeFilter: 'all'
}
},
computed: {
filteredRecords() {
if (this.activeFilter === 'all') {
return this.records
}
return this.records.filter((item) => item.type === this.activeFilter)
},
remainingDays() {
return computeRemainingDays(this.card.validityEnd)
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
this.card = { ...store.card }
this.records = store.records.map((item) => ({ ...item }))
},
switchFilter(filter) {
this.activeFilter = filter
},
renewCard() {
uni.showModal({
title: '续费会员卡',
content: '确认续费 90 天?',
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
renewMemberCard(store, 90)
this.activeFilter = 'all'
this.refreshFromStore()
uni.showToast({ title: '续费成功', icon: 'success' })
}
})
},
showRecordDetail(item) {
uni.showModal({
title: item.title,
content: `${item.time}\n变动:${item.value}`,
showCancel: false
})
}
}
}
</script>
<style>
@import '@/common/style/base.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';
.mc-records__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
}
.mc-records__empty-text {
font-size: 14px;
color: #8A99B4;
}
</style>
@@ -0,0 +1,689 @@
<template>
<scroll-view scroll-y class="scroll-container theme-light">
<view class="member-page">
<!-- Header: 用户信息区 -->
<view class="profile-header">
<!-- 使用公共页面头部背景色 #F5F7FA -->
<view class="member-center-page-header">
<PageHeader title="个人中心" subtitle="" />
</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-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>
</view>
<view class="member-page__body">
<view class="member-page__sections">
<!-- 快捷操作区域 -->
<view class="quick-actions">
<view class="quick-actions__inner">
<view class="quick-actions__grid">
<view class="quick-actions__grid-inner">
<view v-for="item in quickActionsRow1" :key="item.key" class="quick-actions__item" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="onQuickAction(item.key)">
<view class="quick-actions__item-inner">
<view class="quick-actions__icon-wrap">
<view class="quick-actions__icon-wrap-inner">
<view v-if="item.key === 'booking'" class="quick-actions__icon">
<image class="quick-actions__icon-part" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_490.png" mode="aspectFit" />
<image class="quick-actions__icon-part" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_491.png" mode="aspectFit" />
<view class="quick-actions__border-wrap">
<view class="quick-actions__rect"></view>
<view class="quick-actions__border"></view>
</view>
<image class="quick-actions__icon-part" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_493.png" mode="aspectFit" />
<image class="quick-actions__icon-part" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_494.png" mode="aspectFit" />
</view>
<image v-else class="quick-actions__icon-img" :src="item.icon" mode="aspectFit" />
</view>
</view>
<text :class="item.textClass">{{ item.label }}</text>
</view>
</view>
</view>
</view>
<view class="quick-actions__divider"></view>
<view class="quick-actions__grid">
<view class="quick-actions__grid-inner">
<view v-for="item in quickActionsRow2" :key="item.key" class="quick-actions__item" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="onQuickAction(item.key)">
<view class="quick-actions__item-inner">
<view class="quick-actions__icon-wrap">
<view class="quick-actions__icon-wrap-inner">
<image class="quick-actions__icon-img" :src="item.icon" mode="aspectFit" />
</view>
</view>
<text :class="item.textClass">{{ item.label }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 体测报告区域 -->
<view class="body-report-section">
<view class="body-report-section__inner">
<view class="body-report-section__header">
<view class="body-report-section__header-inner">
<text class="body-report-section__title">体测报告</text>
<view class="body-report-section__link" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="goBodyTestHistory">
<text class="body-report-section__history-link">历史记录</text>
<image class="body-report-section__link-arrow" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright3.png" mode="aspectFit" />
</view>
</view>
</view>
<view class="body-report-section__card">
<view class="body-report-section__card-inner">
<view class="body-report-section__card-head">
<view class="body-report-section__card-head-inner">
<text class="body-report-section__desc">最新数据 · {{ bodyReport.date }}</text>
<view class="body-report-section__view-btn" hover-class="mi-tap-btn--hover" :hover-stay-time="150" @tap="goBodyReport">
<image class="body-report-section__view-icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/filetext.png" mode="aspectFit" />
<text class="body-report-section__view-report">查看报告</text>
</view>
</view>
</view>
<view class="body-report-section__metrics">
<view class="body-report-section__metrics-inner">
<view class="body-report-section__metric">
<view class="body-report-section__metric-inner">
<text class="body-report-section__text">{{ bodyReport.weight }}</text>
<text class="body-report-section__metric-value">体重(kg)</text>
</view>
</view>
<view class="body-report-section__metric-divider"></view>
<view class="body-report-section__metric">
<view class="body-report-section__metric-inner">
<text class="body-report-section__text-2">{{ bodyReport.bmi }}</text>
<text class="body-report-section__text-3">BMI</text>
</view>
</view>
<view class="body-report-section__metric-divider"></view>
<view class="body-report-section__metric">
<view class="body-report-section__metric-inner">
<text class="body-report-section__text-4">{{ bodyReport.bodyFat }}</text>
<text class="body-report-section__metric-label">体脂率</text>
</view>
</view>
<view class="body-report-section__metric-divider"></view>
<view class="body-report-section__metric">
<view class="body-report-section__metric-inner">
<text class="body-report-section__num">{{ bodyReport.bmr }}</text>
<text class="body-report-section__text-5">BMR</text>
</view>
</view>
</view>
</view>
<view class="body-report-section__summary">
<view class="body-report-section__summary-inner">
<view class="body-report-section__goal">
<image class="body-report-section__goal-icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/target.png" mode="aspectFit" />
<text class="body-report-section__goal-text">状态{{ bodyReport.status }}</text>
</view>
<view class="body-report-section__change">
<image class="body-report-section__change-icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/trendingdown.png" mode="aspectFit" />
<text class="body-report-section__metric-value-2">较上次 {{ bodyReport.change }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 优惠券&积分区域 -->
<view class="coupon-section">
<view class="coupon-section__inner">
<view class="coupon-section__header">
<view class="coupon-section__header-inner">
<text class="coupon-section__title">优惠券 & 积分</text>
<view class="coupon-section__link" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="goCoupons">
<text class="coupon-section__view-all">更多详情</text>
<image class="coupon-section__link-arrow" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright5.png" mode="aspectFit" />
</view>
</view>
</view>
<view class="coupon-section__cards">
<view class="coupon-section__cards-inner">
<view class="coupon-section__coupon" hover-class="mi-tap-card--hover" :hover-stay-time="150" @tap="goCoupons">
<view class="coupon-section__coupon-inner">
<text class="coupon-section__amount">{{ couponPoints.amount }}</text>
<text class="coupon-section__desc">{{ couponPoints.couponDesc }}</text>
<view class="coupon-section__coupon-status">
<text class="coupon-section__status">{{ couponPoints.couponAction }}</text>
</view>
</view>
</view>
<view class="coupon-section__points" hover-class="mi-tap-card--hover" :hover-stay-time="150" @tap="goPoints">
<view class="coupon-section__points-inner">
<text class="coupon-section__num">{{ couponPoints.points }}</text>
<text class="coupon-section__points-label">{{ couponPoints.pointsLabel }}</text>
<view class="coupon-section__points-action">
<text class="coupon-section__text">{{ couponPoints.pointsAction }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 推荐奖励区域 -->
<view class="referral-section">
<view class="referral-section__inner">
<view class="referral-section__header">
<text class="referral-section__title">推荐奖励</text>
<view class="referral-section__link" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="goReferral">
<text class="referral-section__records-link">规则说明</text>
<image class="referral-section__link-arrow" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright11.png" mode="aspectFit" />
</view>
</view>
<view class="referral-section__code-row">
<view class="referral-section__code-box">
<text class="referral-section__code-label">我的邀请码</text>
<text class="referral-section__code-value">{{ referral.code }}</text>
</view>
<view class="referral-section__copy-btn" hover-class="mi-tap-btn--hover" :hover-stay-time="150" @tap="copyReferralCode">
<view class="referral-section__copy-icon">
<view class="referral-section__copy-sheet referral-section__copy-sheet--back"></view>
<view class="referral-section__copy-sheet referral-section__copy-sheet--front"></view>
</view>
<text class="referral-section__copy-text">复制</text>
</view>
</view>
<view class="referral-section__stats">
<view class="referral-section__stat">
<text class="referral-section__stat-num referral-section__stat-num--orange">{{ referral.invited }}</text>
<text class="referral-section__stat-label">已推荐</text>
</view>
<view class="referral-section__stat-divider"></view>
<view class="referral-section__stat">
<text class="referral-section__stat-num referral-section__stat-num--green">{{ referral.registered }}</text>
<text class="referral-section__stat-label">已注册</text>
</view>
<view class="referral-section__stat-divider"></view>
<view class="referral-section__stat">
<text class="referral-section__stat-num referral-section__stat-num--amber">{{ referral.purchased }}</text>
<text class="referral-section__stat-label">已购课</text>
</view>
</view>
</view>
</view>
<!-- 设置区域 -->
<view class="settings-section">
<view class="settings-section__inner">
<text class="settings-section__title">设置与安全</text>
<view class="settings-section__list">
<view class="settings-section__list-inner">
<view v-for="(item, index) in settingsItems" :key="item.key">
<view v-if="index > 0" class="settings-section__item-divider"></view>
<view class="settings-section__item" :class="{ 'settings-section__item--tall': item.subtitle }" hover-class="mi-tap-row--hover" :hover-stay-time="150" @tap="onSetting(item.key)">
<view class="settings-section__item-inner">
<view class="settings-section__item-icon-wrap" :class="item.iconWrapClass">
<image class="settings-section__item-icon" :src="item.icon" mode="aspectFit" />
</view>
<view v-if="item.subtitle" class="settings-section__item-texts">
<text class="settings-section__item-title">{{ item.label }}</text>
<text class="settings-section__item-desc">{{ item.subtitle }}</text>
</view>
<text v-else class="settings-section__item-label" :class="item.labelClass">{{ item.label }}</text>
<image class="settings-section__item-arrow" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright10.png" mode="aspectFit" />
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 退出登录 -->
<view class="logout-section">
<view class="logout-section__btn" hover-class="mi-tap-btn--hover" :hover-stay-time="150" @tap="handleLogout">
<image class="logout-section__icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/logout.png" mode="aspectFit" />
<text class="logout-section__text">退出登录</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 固定 TabBar -->
<view class="tabbar-fixed">
<TabBar />
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { getCenterPageData, saveMemberStore, loadMemberStore } from '@/common/memberInfo/store.js'
import { getMemberId } from '@/utils/request.js'
import { 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 bodyReport = ref({})
const couponPoints = ref({})
const referral = ref({})
//
const quickActionsRow1 = ref([
{ key: 'booking', label: '预约课程', textClass: 'quick-actions__title', icon: '' },
{ key: 'bodyTest', label: '智能体测', textClass: 'quick-actions__title-2', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/mappin2.png' },
{ key: 'bodyReport', label: '体测报告', textClass: 'quick-actions__title-3', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/activity.png' },
{ key: 'trainReport', label: '训练报告', textClass: 'quick-actions__coach', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/usercheck.png' }
])
const quickActionsRow2 = ref([
{ key: 'coupon', label: '我的优惠券', textClass: 'quick-actions__text', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/ticket.png' },
{ key: 'points', label: '我的积分', textClass: 'quick-actions__points-desc', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/star.png' },
{ key: 'referral', label: '邀请好友', textClass: 'quick-actions__title-4', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/share2.png' },
{ key: 'course', label: '我的课程', textClass: 'quick-actions__text-2', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/play.png' }
])
//
const settingsItems = ref([
{
key: 'notify',
label: '通知设置',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/bell.png',
iconWrapClass: ''
},
{
key: 'password',
label: '修改密码',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Vector_2_727.png',
iconWrapClass: 'settings-section__item-icon-wrap--blue'
},
{
key: 'privacy',
label: '隐私政策',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/shield.png',
iconWrapClass: 'settings-section__item-icon-wrap--green'
},
{
key: 'nfc',
label: 'NFC 门禁卡',
subtitle: '已绑定',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/ticket.png',
iconWrapClass: ''
},
{
key: 'delete',
label: '注销账户',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/userx.png',
iconWrapClass: 'settings-section__item-icon-wrap--red',
labelClass: 'settings-section__item-label--danger'
}
])
//
const displayAvatar = computed(() => {
return userInfo.value.avatar || 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AvatarEditWrap.png'
})
//
function refreshFromStore() {
const store = loadMemberStore()
const pageData = getCenterPageData(store)
userInfo.value = pageData.userInfo
stats.value = pageData.stats
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()
}
}
function goUserInfo() {
navigateToPage(PAGE.USER_INFO)
}
function goBodyTestHistory() {
navigateToPage(PAGE.BODY_TEST_HISTORY)
}
function goBodyReport() {
const id = bodyReport.value?.recordId
const url = id ? `${PAGE.BODY_TEST_REPORT}?id=${id}` : PAGE.BODY_TEST_HISTORY
navigateToPage(url)
}
function goCoupons() {
navigateToPage(PAGE.COUPONS)
}
function goPoints() {
navigateToPage(PAGE.POINTS)
}
function goReferral() {
navigateToPage(PAGE.REFERRAL)
}
function onQuickAction(type) {
const routes = {
booking: PAGE.COURSE_LIST,
bodyTest: PAGE.BODY_TEST_HOME,
bodyReport: PAGE.BODY_TEST_HISTORY,
trainReport: PAGE.TRAIN_REPORT,
coupon: PAGE.COUPONS,
points: PAGE.POINTS,
referral: PAGE.REFERRAL,
course: PAGE.MY_COURSES
}
if (routes[type]) {
navigateToPage(routes[type])
}
}
function copyReferralCode() {
uni.setClipboardData({
data: referral.value.code,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
}
})
}
function onSetting(key) {
const labels = {
notify: '通知设置',
password: '修改密码',
privacy: '隐私政策',
nfc: 'NFC 门禁卡',
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: '退出登录',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '已退出', icon: 'success' })
}
}
})
}
onMounted(() => {
refreshFromStore()
})
onShow(async () => {
await refreshStatsFromServer()
})
</script>
<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/member-info-page.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-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;
left: 0;
right: 0;
z-index: 1000;
background-color: transparent;
}
</style>
+256 -214
View File
@@ -1,241 +1,283 @@
<template>
<view class="scroll-container theme-light">
<view class="member-page">
<MemberInfoHeader
:user-info="userInfo"
:stats="stats"
@user-info="goUserInfo"
/>
<view class="member-page__body">
<view class="member-page__sections">
<MemberInfoMemberCard
:card-info="cardInfo"
@view-all="goMemberCard"
@renew="onRenewCard"
/>
<MemberInfoQuickActions @action="onQuickAction" />
<MemberInfoBookingList
:items="bookingPreview"
@view-all="goBooking"
@item-tap="goBooking"
/>
<MemberInfoCheckInList
:items="checkIns"
@view-all="onCheckInViewAll"
@item-tap="onCheckInTap"
/>
<MemberInfoBodyReport
:report="bodyReport"
@view-history="onBodyReportHistory"
@view-report="onBodyReportView"
/>
<MemberInfoCouponPoints
:data="couponPoints"
@view-all="onCouponViewAll"
@use-coupon="onUseCoupon"
@redeem-points="onRedeemPoints"
/>
<MemberInfoReferral
:data="referral"
@view-rules="onReferralRules"
/>
<MemberInfoSettings @setting="onSetting" />
<MemberInfoLogout @logout="handleLogout" />
</view>
</view>
</view>
<!-- TabBar -->
<TabBar />
<view class="member-info-page">
<!-- 固定页面头部 -->
<view class="member-info-page-header">
<PageHeader title="个人中心" subtitle="" />
</view>
<!-- 滚动内容区域 -->
<view class="member-scroll-wrap">
<!-- 骨架屏 -->
<MemberCenterSkeleton v-if="!pageReady" />
<scroll-view v-else scroll-y class="member-scroll">
<view class="member-page">
<MemberInfoHeader
:user-info="userInfo"
:stats="stats"
:is-login="isLogin"
@user-info="goUserInfo"
@guest-login="handleGuestLogin"
/>
<view class="member-page__body">
<view class="member-page__sections">
<MemberInfoLogout v-if="isLogin" @logout="handleLogout" />
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 固定 TabBar -->
<view class="tabbar-fixed">
<TabBar />
</view>
</view>
</template>
<script>
<script setup>
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import {
loadMemberStore,
getCenterPageData,
renewMemberCard
saveMemberStore,
getDefaultStore,
loadMemberStore
} from '@/common/memberInfo/store.js'
import { getMemberId } from '@/utils/request.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 MemberInfoBodyReport from '@/components/memberInfo/MemberInfoBodyReport.vue'
import MemberInfoCouponPoints from '@/components/memberInfo/MemberInfoCouponPoints.vue'
import MemberInfoReferral from '@/components/memberInfo/MemberInfoReferral.vue'
import MemberInfoSettings from '@/components/memberInfo/MemberInfoSettings.vue'
import MemberInfoLogout from '@/components/memberInfo/MemberInfoLogout.vue'
import MemberCenterSkeleton from '@/components/Skeleton/MemberCenterSkeleton.vue'
import TabBar from '@/components/TabBar.vue'
import PageHeader from '@/components/index/PageHeader.vue'
export default {
components: {
MemberInfoHeader,
MemberInfoMemberCard,
MemberInfoQuickActions,
MemberInfoBookingList,
MemberInfoCheckInList,
MemberInfoBodyReport,
MemberInfoCouponPoints,
MemberInfoReferral,
MemberInfoSettings,
MemberInfoLogout,
TabBar
},
data() {
return {
userInfo: {},
stats: {},
cardInfo: {},
bookingPreview: [],
checkIns: [],
bodyReport: {},
couponPoints: {},
referral: {}
const userInfo = ref({})
const stats = ref({})
const cardInfo = ref({})
const loading = ref(false)
const pageReady = ref(false)
const hasActiveCard = ref(false)
const isLogin = ref(false)
onMounted(() => {
const token = uni.getStorageSync('token')
const loginMember = uni.getStorageSync('loginMemberInfo')
if (token || loginMember) {
isLogin.value = true
}
})
// 访
function handleGuestLogin() {
console.log('[memberInfo] 用户点击登录区域')
uni.navigateTo({
url: '/pages/memberInfo/login'
})
}
async function fetchMemberInfo() {
if (loading.value) return
loading.value = true
console.log('[memberInfo] fetchMemberInfo 开始执行')
try {
// loginMemberInfo
const loginMember = uni.getStorageSync('loginMemberInfo')
if (loginMember?.userInfo) {
console.log('[memberInfo] 从loginMemberInfo获取用户信息:', loginMember.userInfo)
const apiData = loginMember.userInfo
// userInfo
userInfo.value = {
id: apiData.id,
name: apiData.nickname || apiData.name,
phone: apiData.phone,
avatar: apiData.avatar,
memberLevel: apiData.memberLevel || '普通会员'
}
}
},
onShow() {
this.refreshFromStore()
},
methods: {
refreshFromStore() {
const store = loadMemberStore()
const pageData = getCenterPageData(store)
this.userInfo = pageData.userInfo
this.stats = pageData.stats
this.cardInfo = pageData.cardInfo
this.bookingPreview = pageData.bookingPreview
this.checkIns = pageData.checkIns
this.bodyReport = pageData.bodyReport
this.couponPoints = pageData.couponPoints
this.referral = pageData.referral
},
goMemberCard() {
navigateToPage(PAGE.MEMBER_CARD)
},
goBooking() {
navigateToPage(PAGE.BOOKING)
},
goUserInfo() {
navigateToPage(PAGE.USER_INFO)
},
onRenewCard() {
uni.showModal({
title: '续费会员卡',
content: '确认续费 90 天?',
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
renewMemberCard(store, 90)
this.refreshFromStore()
uni.showToast({ title: '续费成功', icon: 'success' })
}
})
},
onQuickAction(type) {
const routes = {
booking: PAGE.COURSE_LIST,
bodyTest: PAGE.BODY_TEST_HOME,
bodyReport: PAGE.BODY_TEST_HISTORY,
trainReport: PAGE.TRAIN_REPORT,
coupon: PAGE.COUPONS,
points: PAGE.POINTS,
referral: PAGE.REFERRAL,
course: PAGE.MY_COURSES
//
try {
const detailRes = await getMemberDetail()
const detailData = detailRes.data || {}
stats.value = {
...stats.value,
pointsBalance: detailData.pointsBalance || 0
}
if (routes[type]) {
navigateToPage(routes[type])
}
},
onCheckInViewAll() {
navigateToPage(PAGE.CHECK_IN_HISTORY)
},
onCheckInTap(item) {
uni.showModal({
title: item.title,
content: item.time,
showCancel: false
})
},
onBodyReportHistory() {
navigateToPage(PAGE.BODY_TEST_HISTORY)
},
onBodyReportView() {
const id = this.bodyReport?.recordId
const url = id
? `${PAGE.BODY_TEST_REPORT}?id=${id}`
: PAGE.BODY_TEST_HISTORY
navigateToPage(url)
},
onCouponViewAll() {
navigateToPage(PAGE.COUPONS)
},
onUseCoupon() {
navigateToPage(PAGE.COUPONS)
},
onRedeemPoints() {
navigateToPage(PAGE.POINTS)
},
onReferralRules() {
navigateToPage(PAGE.REFERRAL)
},
onSetting(key) {
const labels = {
notify: '通知设置',
password: '修改密码',
privacy: '隐私政策',
nfc: 'NFC 门禁卡',
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'
})
},
handleLogout() {
uni.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '已退出', icon: 'success' })
}
}
})
} catch (e) {
console.error('[memberInfo] 获取积分信息失败:', e)
}
//
try {
const statsRes = await getCheckInStats()
console.log('[memberInfo] getCheckInStats 返回:', JSON.stringify(statsRes, null, 2))
const statsData = statsRes.data || {}
stats.value.checkInCount = statsData.totalCount || 0
} catch (e) {
console.error('[memberInfo] 获取签到统计失败:', e)
stats.value.checkInCount = 0
}
//
try {
const memberIdForBooking = getMemberId()
console.log('[memberInfo] getMemberId 结果:', memberIdForBooking)
if (memberIdForBooking) {
const bookingsRes = await getMemberBookings(memberIdForBooking)
console.log('[memberInfo] getMemberBookings 原始响应:', JSON.stringify(bookingsRes))
// / data / data.content / content / data.data.content
let bookings = []
const raw = bookingsRes || {}
if (Array.isArray(raw)) {
bookings = raw
} else if (Array.isArray(raw.data)) {
bookings = raw.data
} else if (raw.data && Array.isArray(raw.data.content)) {
bookings = raw.data.content
} else if (Array.isArray(raw.content)) {
bookings = raw.content
} else if (raw.data && typeof raw.data === 'object' && raw.data.data && Array.isArray(raw.data.data)) {
bookings = raw.data.data
} else if (raw.data && typeof raw.data === 'object' && raw.data.data && Array.isArray(raw.data.data.content)) {
bookings = raw.data.data.content
}
stats.value.courseCount = bookings.length
console.log('[memberInfo] 解析后 courseCount:', stats.value.courseCount)
} else {
stats.value.courseCount = 0
console.warn('[memberInfo] getMemberId 返回 null/undefined')
}
} catch (e) {
console.error('[memberInfo] 获取报课次数失败:', e)
stats.value.courseCount = 0
}
// storememberCenter使
const store = loadMemberStore()
store.stats = {
...store.stats,
checkInCount: stats.value.checkInCount ?? 0,
courseCount: stats.value.courseCount ?? 0,
pointsBalance: stats.value.pointsBalance ?? 0
}
saveMemberStore(store)
// store
refreshFromStore()
pageReady.value = true
console.log('[memberInfo] fetchMemberInfo 执行完成')
} catch (err) {
console.error('[memberInfo] 获取会员信息失败:', err)
pageReady.value = true
} finally {
loading.value = false
}
}
function refreshFromStore() {
const store = loadMemberStore()
const pageData = getCenterPageData(store)
console.log('[memberInfo] refreshFromStore - pageData:', JSON.stringify(pageData))
stats.value = pageData.stats
cardInfo.value = pageData.cardInfo
console.log('[memberInfo] refreshFromStore - userInfo.value:', JSON.stringify(userInfo.value))
}
function goUserInfo() {
navigateToPage(PAGE.USER_INFO)
}
function handleLogout() {
uni.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
// token
clearToken()
//
isLogin.value = false
// store
const defaultStore = getDefaultStore()
saveMemberStore(defaultStore)
//
userInfo.value = {}
stats.value = {}
cardInfo.value = {}
hasActiveCard.value = false
refreshFromStore()
uni.showToast({ title: '已退出', icon: 'success' })
}
}
})
}
refreshFromStore()
onShow(() => {
console.log('[memberInfo] onShow 被调用')
//
const token = getToken()
const isLoginStorage = uni.getStorageSync('isLogin')
if (token || isLoginStorage) {
isLogin.value = true
} else {
isLogin.value = false
}
// loading
loading.value = false
fetchMemberInfo()
})
</script>
<style>
<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/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';
/* 页面布局:scroll-view + tabbar */
.member-info-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: #F5F7FA;
}
/* 固定页面头部 */
.member-info-page-header {
flex-shrink: 0;
position: relative;
z-index: 10;
background: #F5F7FA;
}
.member-scroll-wrap {
flex: 1;
overflow: hidden;
}
.member-scroll {
height: 100%;
overflow-y: auto;
}
/* 固定 TabBar */
.tabbar-fixed {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
background-color: transparent;
}
</style>
+316 -175
View File
@@ -1,189 +1,264 @@
<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>
<!-- 骨架屏 -->
<ListSkeleton v-if="loading" :count="4" layout="card" />
<!-- 预约列表 -->
<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"
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>
</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 ListSkeleton from '@/components/Skeleton/ListSkeleton.vue'
import { getMemberId } from '@/utils/request.js'
import { getCourseCoverUrl } from '@/utils/request.js'
import { getMemberBookings, qrSignInGroupCourse } from '@/api/groupCourse.js'
import { cancelBooking as cancelBookingApi } from '@/api/groupCourse.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 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)) {
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: getCourseCoverUrl(b.coverImage) || 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/default-course.png'
}
}
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
uni.showLoading({ title: '取消中...' })
try {
const result = await cancelBookingApi(Number(item.id), {
memberId: getMemberId()
})
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 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.id || 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()
})
</script>
<style>
@@ -191,7 +266,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 +287,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>
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page" v-if="course">
<MemberInfoSubNav title="线上课程" @back="onBack" />
+25 -23
View File
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="我的积分" @back="goBack" />
@@ -52,35 +52,37 @@
</view>
</template>
<script>
<script setup>
import { ref } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { PAGE, navigateToPage, backToMemberCenter } from '@/common/constants/routes.js'
import { getPointsPageData } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return { balance: 0, config: {}, historyPreview: [] }
},
onShow() {
const data = getPointsPageData(loadMemberStore())
this.balance = data.balance
this.config = data.config
this.historyPreview = data.history.slice(0, 5)
},
methods: {
goMall() { navigateToPage(PAGE.POINTS_MALL) },
goHistory() { navigateToPage(PAGE.POINTS_HISTORY) },
goReferral() { navigateToPage(PAGE.REFERRAL) },
checkIn() { uni.showToast({ title: '签到成功 +10 积分', icon: 'success' }) }
}
const balance = ref(0)
const config = ref({})
const historyPreview = ref([])
function refresh() {
const data = getPointsPageData(loadMemberStore())
balance.value = data.balance
config.value = data.config
historyPreview.value = data.history.slice(0, 5)
}
function goBack() {
backToMemberCenter()
}
function goMall() { navigateToPage(PAGE.POINTS_MALL) }
function goHistory() { navigateToPage(PAGE.POINTS_HISTORY) }
function goReferral() { navigateToPage(PAGE.REFERRAL) }
function checkIn() { uni.showToast({ title: '签到成功 +10 积分', icon: 'success' }) }
refresh()
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="积分明细" @back="onBack" />
@@ -41,40 +41,34 @@
</view>
</template>
<script>
<script setup>
import { ref, watch } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { filterPointsHistory } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return {
filter: 'all',
tabs: [
{ key: 'all', label: '全部' },
{ key: 'earn', label: '获取' },
{ key: 'spend', label: '消耗' }
],
list: []
}
},
watch: {
filter() { this.loadList() }
},
onShow() { this.loadList() },
methods: {
onBack() { goBackOrTab(PAGE.POINTS) },
loadList() {
const store = loadMemberStore()
this.list = filterPointsHistory(store, this.filter)
}
}
const filter = ref('all')
const tabs = ref([
{ key: 'all', label: '全部' },
{ key: 'earn', label: '获取' },
{ key: 'spend', label: '消耗' }
])
const list = ref([])
function onBack() { goBackOrTab(PAGE.POINTS) }
function loadList() {
const store = loadMemberStore()
list.value = filterPointsHistory(store, filter.value)
}
watch(filter, () => { loadList() })
loadList()
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="积分商城" @back="onBack" />
@@ -39,49 +39,50 @@
</view>
</template>
<script>
<script setup>
import { ref } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { PAGE, goBackOrTab } from '@/common/constants/routes.js'
import { loadMemberStore, persistMemberStore } from '@/common/memberInfo/store.js'
import { getPointsPageData, redeemPointsReward } from '@/common/memberInfo/moduleStore.js'
export default {
components: { MemberInfoSubNav },
data() {
return { balance: 0, rewards: [], redeemRecords: [] }
},
onShow() {
const data = getPointsPageData(loadMemberStore())
this.balance = data.balance
this.rewards = data.rewards
this.redeemRecords = data.redeemRecords
},
methods: {
onBack() { goBackOrTab(PAGE.POINTS) },
redeem(item) {
uni.showModal({
title: '确认兑换',
content: `使用 ${item.cost} 积分兑换「${item.name}」?`,
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
const result = redeemPointsReward(store, item.id)
uni.showToast({ title: result.message, icon: result.ok ? 'success' : 'none' })
if (result.ok) {
persistMemberStore(store)
const data = getPointsPageData(store)
this.balance = data.balance
this.rewards = data.rewards
this.redeemRecords = data.redeemRecords
}
}
})
}
}
const balance = ref(0)
const rewards = ref([])
const redeemRecords = ref([])
function loadData() {
const data = getPointsPageData(loadMemberStore())
balance.value = data.balance
rewards.value = data.rewards
redeemRecords.value = data.redeemRecords
}
function onBack() { goBackOrTab(PAGE.POINTS) }
function redeem(item) {
uni.showModal({
title: '确认兑换',
content: `使用 ${item.cost} 积分兑换「${item.name}」?`,
success: (res) => {
if (!res.confirm) return
const store = loadMemberStore()
const result = redeemPointsReward(store, item.id)
uni.showToast({ title: result.message, icon: result.ok ? 'success' : 'none' })
if (result.ok) {
persistMemberStore(store)
const data = getPointsPageData(store)
balance.value = data.balance
rewards.value = data.rewards
redeemRecords.value = data.redeemRecords
}
}
})
}
loadData()
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
+24 -36
View File
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="邀请好友" @back="goBack" />
@@ -104,45 +104,37 @@
</view>
</template>
<script>
<script setup>
import { ref } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { getReferralPageData } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
import { backToMemberCenter } from '@/common/constants/routes.js'
export default {
components: { MemberInfoSubNav },
mixins: [subPageMixin],
data() {
return {
data: { code: '', invited: 0, registered: 0, purchased: 0, records: [], rules: [] }
const data = ref(getReferralPageData(loadMemberStore()))
function goBack() {
backToMemberCenter()
}
function copyCode() {
uni.setClipboardData({
data: data.value.code,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
})
}
function shareInvite() {
uni.showActionSheet({
itemList: ['分享给微信好友', '生成分享海报'],
success: (res) => {
uni.showToast({ title: ['已唤起微信分享', '海报已生成'][res.tapIndex] || '分享成功', icon: 'none' })
}
},
onShow() {
const store = loadMemberStore()
this.data = getReferralPageData(store)
},
methods: {
copyCode() {
uni.setClipboardData({
data: this.data.code,
success: () => uni.showToast({ title: '邀请码已复制', icon: 'success' })
})
},
shareInvite() {
uni.showActionSheet({
itemList: ['分享给微信好友', '生成邀请海报', '复制分享链接'],
success: (res) => {
const msgs = ['已唤起分享', '海报已生成', '链接已复制']
uni.showToast({ title: msgs[res.tapIndex] || '分享成功', icon: 'none' })
}
})
}
}
})
}
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@@ -151,8 +143,4 @@ export default {
@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';
.mi-mod-referral-hero .bt-hero__actions {
margin-top: 16px;
}
</style>
@@ -1,4 +1,4 @@
<template>
<template>
<view class="scroll-container theme-light">
<view class="bt-page">
<MemberInfoSubNav title="训练报告" @back="goBack" />
@@ -69,64 +69,57 @@
</view>
</template>
<script>
<script setup>
import { ref, computed } from 'vue'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import BodyTestTrendChart from '@/components/memberInfo/BodyTestTrendChart.vue'
import { PAGE, navigateToPage } from '@/common/constants/routes.js'
import { PAGE, navigateToPage, backToMemberCenter } from '@/common/constants/routes.js'
import { getTrainingReportData, filterTrainingSessions } from '@/common/memberInfo/moduleStore.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
import { subPageMixin } from '@/common/memberInfo/mixins.js'
export default {
components: { MemberInfoSubNav, BodyTestTrendChart },
mixins: [subPageMixin],
data() {
return {
period: 'week',
periods: [
{ key: 'week', label: '本周' },
{ key: 'month', label: '本月' }
],
typeFilter: 'all',
typeFilters: [
{ key: 'all', label: '全部' },
{ key: 'group', label: '团课' },
{ key: 'private', label: '私教' },
{ key: 'free', label: '自由' }
],
report: { summary: {}, trendHours: [], trendCalories: [], sessions: [] },
sessions: [],
chartWidth: 300
}
},
onLoad() {
this.chartWidth = uni.getSystemInfoSync().windowWidth - 64
this.refresh()
},
onShow() { this.refresh() },
methods: {
refresh() {
const store = loadMemberStore()
this.report = getTrainingReportData(store, this.period)
this.sessions = filterTrainingSessions(store, { type: this.typeFilter })
},
switchPeriod(key) {
this.period = key
this.refresh()
},
goSession(item) {
navigateToPage(`${PAGE.TRAIN_SESSION}?id=${item.id}`)
}
},
watch: {
typeFilter() {
this.sessions = filterTrainingSessions(loadMemberStore(), { type: this.typeFilter })
}
}
const period = ref('week')
const periods = ref([
{ key: 'week', label: '本周' },
{ key: 'month', label: '本月' }
])
const typeFilter = ref('all')
const typeFilters = ref([
{ key: 'all', label: '全部' },
{ key: 'group', label: '团课' },
{ key: 'private', label: '私教' },
{ key: 'free', label: '自由' }
])
const report = ref({ summary: {}, trendHours: [], trendCalories: [], sessions: [] })
const chartWidth = ref(300)
const sessions = computed(() => {
return filterTrainingSessions(report.value.sessions, typeFilter.value)
})
function switchPeriod(p) {
period.value = p
refresh()
}
function refresh() {
const store = loadMemberStore()
report.value = getTrainingReportData(store, period.value)
}
function goBack() {
backToMemberCenter()
}
function goSession(item) {
navigateToPage(`${PAGE.TRAIN_SESSION_DETAIL}?id=${item.id}`)
}
// Initialize
chartWidth.value = uni.getSystemInfoSync().windowWidth - 64
refresh()
</script>
<style>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';

Some files were not shown because too many files have changed in this diff Show More