Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cc72ae0f7 | ||
|
|
13b99428de | ||
|
|
e85f39ab83 | ||
|
|
09e587cb65 | ||
|
|
74876e0923 | ||
|
|
0bebce3dc1 | ||
|
|
8d8c823616 | ||
|
|
1c547a717e | ||
|
|
d7961694f9 | ||
|
|
4e69185c48 | ||
|
|
96b8fd2534 | ||
|
|
b345ceeb42 | ||
|
|
0402a1e82d | ||
|
|
c2a09a5057 |
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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" : {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
/* 快应用特有相关 */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
@@ -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(() => {
|
||||
// 设置测试token和userId(实际项目中由登录接口返回)
|
||||
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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 将获取到的数据保存到store,供其他页面(如memberCenter)使用
|
||||
const store = loadMemberStore()
|
||||
store.stats = {
|
||||
...store.stats,
|
||||
checkInCount: stats.value.checkInCount ?? 0,
|
||||
courseCount: stats.value.courseCount ?? 0,
|
||||
pointsBalance: stats.value.pointsBalance ?? 0
|
||||
}
|
||||
saveMemberStore(store)
|
||||
|
||||
// 刷新store中的业务数据
|
||||
refreshFromStore()
|
||||
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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user