88 Commits
Author SHA1 Message Date
liwentao 6cc72ae0f7 移除前端中的会员卡和支付相关,新增微信一键登录 2026-07-14 18:50:35 +08:00
liwentao 13b99428de 修正多处问题,添加骨架屏 2026-06-29 16:41:36 +08:00
liwentao e85f39ab83 修正多处问题 2026-06-29 13:35:29 +08:00
future 09e587cb65 完成一键登录和支付功能 2026-06-28 14:49:21 +08:00
future 74876e0923 完成登录功能 2026-06-24 22:25:36 +08:00
liwentao 0bebce3dc1 修正布局,删改无用页面 2026-06-24 08:13:12 +08:00
future 8d8c823616 整合api请求、添加购买会员卡页面、登陆页面 2026-06-23 22:17:53 +08:00
liwentao 1c547a717e 修复跳转功能 2026-06-15 18:10:39 +08:00
liwentao d7961694f9 完善团课推荐页面 2026-06-15 18:03:42 +08:00
liwentao 4e69185c48 完善团课前后端交互 2026-06-15 15:49:21 +08:00
liwentao 96b8fd2534 完善团课相关页面交互,完成团课列表页基础后端交互。(后端连接至服务器,版本为DEV分支版本) 2026-06-13 17:11:36 +08:00
future b345ceeb42 加入加载东湖 2026-06-12 15:43:01 +08:00
future 0402a1e82d 签到、课程搜索页面调整为测试模式 2026-06-12 12:36:21 +08:00
future c2a09a5057 tabbar适配安卓端 2026-06-11 14:51:07 +08:00
liwentao c94be9bc38 Merge remote-tracking branch 'origin/feature/uni-app' into feature/uni-app
# Conflicts:
#	gym-manage-uniapp/pages.json
2026-06-11 14:33:39 +08:00
futureandliwentao bcf3bba3c7 优化会员信息模块及首页组件,清理冗余图片资源 2026-06-11 14:32:38 +08:00
futureandliwentao 268284cf32 新增首页骨架屏并优化页面体验 2026-06-11 14:32:19 +08:00
futureandliwentao 5176083139 新增搜索课程和加载组件页面,签到页面添加遮罩防重复扫码,添加 request 便捷方法(get/post/put/delete) 2026-06-11 14:32:03 +08:00
liwentao 9f184d4c2a 添加团课详细页 2026-06-11 14:31:40 +08:00
liwentao 77ed3b890f 更新团课列表页面,修复路径代理导致的错误识别 2026-06-11 14:31:22 +08:00
时舟年andliwentao 5e58d9a0dc 修复tabber 2026-06-11 14:31:01 +08:00
liwentao a19c9470c5 更新page.json,添加团课列表路由 2026-06-11 14:30:50 +08:00
futureandliwentao 261a90bdcd 添加扫码签到功能和相关配置 2026-06-11 14:30:47 +08:00
liwentao ac710952b2 修复丢失的跳转功能 2026-06-11 14:30:26 +08:00
liwentao 8be304417d 完成团课列表页面布局以及基础交互,使用测试数据 2026-06-11 14:29:45 +08:00
时舟年andliwentao b36c6133cb 会员个人中心页面初步完成 2026-06-11 14:28:37 +08:00
futureandliwentao b2b3782aa2 修改主题色 2026-06-11 14:26:50 +08:00
futureandliwentao 244b40025e 修复 SCSS 变量未定义错误和滚动监听问题 2026-06-11 14:26:49 +08:00
futureandliwentao 1912d62f21 优化多个页面样式及组件功能,优化课程、发现、训练、首页等核心页面样式 2026-06-11 14:26:49 +08:00
futureandliwentao abb806de5e 优化会员信息模块及首页组件,清理冗余图片资源 2026-06-11 14:26:49 +08:00
futureandliwentao c22595b33a 新增首页骨架屏并优化页面体验 2026-06-11 14:26:49 +08:00
futureandliwentao ec89d1459a 新增搜索课程和加载组件页面,签到页面添加遮罩防重复扫码,添加 request 便捷方法(get/post/put/delete) 2026-06-11 14:26:49 +08:00
liwentao c9aabf23f0 添加团课详细页 2026-06-11 14:26:49 +08:00
liwentao c8fa427379 更新团课列表页面,修复路径代理导致的错误识别 2026-06-11 14:26:49 +08:00
时舟年andliwentao fc5f094f8c 修复tabber 2026-06-11 14:26:49 +08:00
liwentao 8114809102 更新page.json,添加团课列表路由 2026-06-11 14:26:49 +08:00
futureandliwentao 26e185a804 添加扫码签到功能和相关配置 2026-06-11 14:26:49 +08:00
liwentao 68733acc72 补充遗失的文件 2026-06-11 14:26:49 +08:00
liwentao b454d5d940 完成团课列表页面布局以及基础交互,使用测试数据 2026-06-11 14:26:49 +08:00
时舟年andliwentao 11b03e8e62 个人中心页面初步实现 2026-06-11 14:26:49 +08:00
时舟年andliwentao a0026b1da5 会员个人中心页面初步完成 2026-06-11 14:26:49 +08:00
futureandliwentao 1fa2fbd3f3 完成首页布局 2026-06-11 14:26:48 +08:00
时舟年andliwentao 125492f6f1 微信小程序APPID配置 2026-06-11 14:26:48 +08:00
liwentao 8d56fc8e9e 初始化uni-app 2026-06-11 14:26:48 +08:00
liwentao 97a8511ea2 Merge remote-tracking branch 'origin/feature/uni-app' into feature/uni-app
# Conflicts:
#	gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/config/SecurityConfig.java
2026-06-11 08:39:52 +08:00
futureandliwentao 67c080efce 修改主题色 2026-06-11 08:39:26 +08:00
futureandliwentao b12d7cdf25 修复 SCSS 变量未定义错误和滚动监听问题 2026-06-11 08:39:26 +08:00
futureandliwentao d3b978938b 优化多个页面样式及组件功能,优化课程、发现、训练、首页等核心页面样式 2026-06-11 08:39:26 +08:00
futureandliwentao c909b023c7 优化会员信息模块及首页组件,清理冗余图片资源 2026-06-11 08:39:26 +08:00
futureandliwentao 5bc31f8936 新增首页骨架屏并优化页面体验 2026-06-11 08:39:23 +08:00
futureandliwentao dc7da19aee 新增搜索课程和加载组件页面,签到页面添加遮罩防重复扫码,添加 request 便捷方法(get/post/put/delete) 2026-06-11 08:38:34 +08:00
liwentao a7af34d22b 添加团课详细页 2026-06-11 08:37:51 +08:00
liwentao 0e8f19934e 更新团课列表页面,修复路径代理导致的错误识别 2026-06-11 08:37:48 +08:00
时舟年andliwentao 2a6409daa9 修复tabber 2026-06-11 08:37:26 +08:00
liwentao c658aacf0a 更新page.json,添加团课列表路由 2026-06-11 08:36:54 +08:00
futureandliwentao e9ef34bb7e 添加扫码签到功能和相关配置 2026-06-11 08:36:51 +08:00
liwentao d6e17b9944 补充遗失的文件 2026-06-11 08:35:51 +08:00
liwentao 4a714e8141 完成团课列表页面布局以及基础交互,使用测试数据 2026-06-11 08:35:51 +08:00
时舟年andliwentao 01e48eefe2 个人中心页面初步实现 2026-06-11 08:33:19 +08:00
时舟年andliwentao f30514c700 会员个人中心页面初步完成 2026-06-11 08:33:19 +08:00
futureandliwentao c19e0e0181 完成首页布局 2026-06-11 08:33:19 +08:00
时舟年andliwentao 959ee46c44 微信小程序APPID配置 2026-06-11 08:33:19 +08:00
liwentao 9192e82eee 初始化uni-app 2026-06-11 08:33:19 +08:00
future 95c2ded69e 修改主题色 2026-06-10 14:35:16 +08:00
future 1e093a0688 修复 SCSS 变量未定义错误和滚动监听问题 2026-06-08 15:45:54 +08:00
future 33d1140fbf 优化多个页面样式及组件功能,优化课程、发现、训练、首页等核心页面样式 2026-06-08 14:54:36 +08:00
future 51bdf15613 优化会员信息模块及首页组件,清理冗余图片资源 2026-06-07 22:41:55 +08:00
future be7eabdbb1 新增首页骨架屏并优化页面体验 2026-06-06 13:25:58 +08:00
future 823d626440 新增搜索课程和加载组件页面,签到页面添加遮罩防重复扫码,添加 request 便捷方法(get/post/put/delete) 2026-06-05 21:26:26 +08:00
liwentao 207a248b01 添加团课详细页 2026-06-05 15:35:05 +08:00
liwentao c4de871977 更新团课列表页面,修复路径代理导致的错误识别 2026-06-04 19:11:00 +08:00
时舟年 f0dda998a8 修复tabber 2026-06-04 16:47:31 +08:00
时舟年 8c96e2099c 初步完成页面 2026-06-04 14:30:22 +08:00
时舟年 1922d0fb1e 个人中心页面初步实现 2026-06-04 14:25:12 +08:00
时舟年 7350293d0e 会员个人中心页面初步完成 2026-06-04 14:18:53 +08:00
liwentao 8cf3c9ccee 更新page.json,添加团课列表路由 2026-06-04 13:19:36 +08:00
liwentao 1fc020ab00 Merge remote-tracking branch 'origin/feature/uni-app' into feature/uni-app
# Conflicts:
#	gym-manage-uniapp/pages.json
2026-06-04 13:17:59 +08:00
liwentao 4981a240fa 修复丢失的跳转功能 2026-06-04 13:16:09 +08:00
liwentao 349b7ae03b 补充遗失的文件 2026-06-04 13:08:23 +08:00
liwentao 2357dcfc67 完成团课列表页面布局以及基础交互,使用测试数据 2026-06-04 13:08:23 +08:00
future 14a0fe8d4f 添加扫码签到功能和相关配置 2026-06-03 16:26:44 +08:00
liwentao e304c1b724 Merge remote-tracking branch 'origin/feature/uni-app' into feature/uni-app 2026-06-03 14:02:00 +08:00
futureandliwentao f0d97e58d1 完成首页布局 2026-06-03 14:01:37 +08:00
时舟年andliwentao 2b58b672d5 微信小程序APPID配置 2026-06-03 14:01:37 +08:00
liwentao 8bedac5ab5 初始化uni-app 2026-06-03 14:01:36 +08:00
future 8e7c8f52f6 完成首页布局 2026-06-01 19:57:43 +08:00
时舟年 b4710b6397 微信小程序APPID配置 2026-05-31 17:33:40 +08:00
liwentao daff741c65 初始化uni-app 2026-05-31 15:07:44 +08:00
201 changed files with 42094 additions and 148 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
unpackage/
.hbuilderx/
.DS_Store
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="gym.manage.uniapp">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<application>
<activity
android:name="com.mobile.auth.gatewayauth.LoginAuthActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="false"
android:launchMode="singleTop"
android:screenOrientation="behind"
android:theme="@style/authsdk_activity_dialog" />
<activity
android:name="com.mobile.auth.gatewayauth.activity.AuthWebVeiwActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="false"
android:launchMode="singleTop"
android:screenOrientation="behind" />
<activity
android:name="com.mobile.auth.gatewayauth.PrivacyDialogActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="false"
android:launchMode="singleTop"
android:screenOrientation="behind"
android:theme="@style/authsdk_activity_dialog" />
</application>
</manifest>
+85
View File
@@ -0,0 +1,85 @@
<template>
<view>
<GlobalLoading />
</view>
</template>
<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">
/* 注意要写在第一行,同时给style标签加入lang="scss"属性 */
@import "@/uni.scss";
</style>
+132
View File
@@ -0,0 +1,132 @@
import request from "@/utils/request.js"
export function getGroupCourseList(params = {}, options = {}) {
return request.get('/groupCourse/list', params, options)
}
export function getGroupCoursePage(params = {}, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
const { page = 0, size = 10, sort = 'id', order = 'asc', keyword } = params
return request.post('/groupCourse/page', { page, size, sort, order, keyword }, options)
}
export function getGroupCourseById(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) {
return request.get(`/groupCourse/${id}`, {}, options)
}
export function getGroupCourseDetail(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) {
return request.get(`/groupCourse/${id}/detail`, {}, options)
}
export function createGroupCourse(params) {
return request.post('/groupCourse', params)
}
export function updateGroupCourse(id, params) {
return request.put(`/groupCourse/${id}`, params)
}
export function cancelGroupCourse(id) {
return request.post(`/groupCourse/${id}/cancel`)
}
export function deleteGroupCourse(id) {
return request.delete(`/groupCourse/${id}`)
}
export function getGroupCourseTypes(params = {}, options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
return request.get('/groupCourse/types', params, options)
}
export function getGroupCourseTypeById(id, options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
return request.get(`/groupCourse/types/${id}`, {}, options)
}
export function getTypeLabels(typeId, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
return request.get(`/groupCourse/types/${typeId}/labels`, {}, options)
}
export function searchGroupCourse(params = {}, options = {}) {
const {
courseName,
courseType,
startDate,
endDate,
timePeriod,
priceSort,
remainingMost,
isRecurring,
page = 0,
size = 10
} = params
const requestBody = { page, size }
if (courseName) requestBody.courseName = courseName
if (courseType) requestBody.courseType = courseType
if (startDate) requestBody.startDate = formatDateTime(startDate)
if (endDate) requestBody.endDate = formatDateTime(endDate, true)
if (timePeriod) requestBody.timePeriod = timePeriod
if (priceSort) requestBody.priceSort = priceSort
if (remainingMost !== undefined) requestBody.remainingMost = remainingMost
if (isRecurring !== undefined) requestBody.isRecurring = isRecurring
return request.post('/groupCourse/search', requestBody, options)
}
function formatDateTime(dateStr, isEnd = false) {
if (!dateStr) return dateStr
if (dateStr.includes('T')) return dateStr
return isEnd
? `${dateStr}T23:59:59`
: `${dateStr}T00:00:00`
}
export function bookGroupCourse(params) {
return request.post('/groupCourse/book', params)
}
export function cancelBooking(bookingId, params) {
return request.post(`/groupCourse/booking/${bookingId}/cancel`, params)
}
export function getMemberBookings(memberId, options = {}) {
return request.get(`/groupCourse/bookings/member/${memberId}`, {}, options)
}
export function getActiveRecommendCourses(options = { cache: false }) {
return request.get('/groupCourse/recommend/active', {}, options)
}
export function getGroupCourseRecommendList(params = {}, options = { cache: false }) {
return request.get('/groupCourse/recommend/list', params, options)
}
/**
* 扫码签到
* 扫描团课二维码后签到,将预约状态更新为已出席
* @param {number} courseId - 团课ID
* @param {number} memberId - 会员ID
*/
export function qrSignInGroupCourse(courseId, memberId) {
return request.post(`/groupCourse/signin/${memberId}`, { courseId })
}
export default {
getGroupCourseList,
getGroupCoursePage,
searchGroupCourse,
getGroupCourseById,
getGroupCourseDetail,
createGroupCourse,
updateGroupCourse,
cancelGroupCourse,
deleteGroupCourse,
getGroupCourseTypes,
getGroupCourseTypeById,
getTypeLabels,
bookGroupCourse,
cancelBooking,
getMemberBookings,
getActiveRecommendCourses,
getGroupCourseRecommendList
}
+294
View File
@@ -0,0 +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', 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,
bookGroupCourse,
cancelBooking,
signinGroupCourse,
getMemberBookings,
getCourseBookings,
getActiveBanners
}
@@ -0,0 +1,188 @@
// common/constants/routes.js
/** 与 pages.json 保持一致 */
export const PAGE = {
INDEX: '/pages/index/index',
COURSE: '/pages/course/index',
MEMBER: '/pages/memberInfo/memberInfo',
BOOKING: '/pages/memberInfo/booking',
USER_INFO: '/pages/memberInfo/userInfo',
BODY_TEST_HOME: '/pages/memberInfo/bodyTestHome',
BODY_TEST_CONNECT: '/pages/memberInfo/bodyTestConnect',
BODY_TEST_MEASURING: '/pages/memberInfo/bodyTestMeasuring',
BODY_TEST_REPORT: '/pages/memberInfo/bodyTestReport',
BODY_TEST_HISTORY: '/pages/memberInfo/bodyTestHistory',
BODY_TEST_COMPARE: '/pages/memberInfo/bodyTestCompare',
BODY_TEST_SETTINGS: '/pages/memberInfo/bodyTestSettings',
BODY_TEST_TREND: '/pages/memberInfo/bodyTestTrend',
COURSE_LIST: '/pages/groupCourse/list',
COURSE_DETAIL: '/pages/memberInfo/courseDetail',
COUPON_DETAIL: '/pages/memberInfo/couponDetail',
COUPON_CENTER: '/pages/memberInfo/couponCenter',
POINTS_MALL: '/pages/memberInfo/pointsMall',
POINTS_HISTORY: '/pages/memberInfo/pointsHistory',
ONLINE_COURSE: '/pages/memberInfo/onlineCourseDetail',
COURSE_EVALUATE: '/pages/memberInfo/courseEvaluate',
TRAIN_SESSION: '/pages/memberInfo/trainSessionDetail',
TRAIN_REPORT: '/pages/memberInfo/trainReport',
COUPONS: '/pages/memberInfo/coupons',
POINTS: '/pages/memberInfo/points',
REFERRAL: '/pages/memberInfo/referral',
MY_COURSES: '/pages/memberInfo/myCourses',
CHECK_IN_HISTORY: '/pages/memberInfo/checkInHistory'
}
/** 底部 TabBar 页面路径,顺序与 TabBar.vue 一致 */
export const TAB_ROUTES = [
PAGE.INDEX,
PAGE.COURSE,
PAGE.MEMBER
]
const TAB_PAGES = new Set(TAB_ROUTES)
/** 防止 Tab 连点触发并发路由 */
let tabNavigating = false
function normalizePath(url) {
if (!url) return ''
const path = url.split('?')[0]
return path.startsWith('/') ? path : `/${path}`
}
export function getTabIndexByRoute(route) {
const path = normalizePath(route)
const idx = TAB_ROUTES.indexOf(path)
return idx >= 0 ? idx : 0
}
export function getCurrentRoutePath() {
const pages = getCurrentPages()
if (!pages.length) return PAGE.INDEX
const page = pages[pages.length - 1]
const route = page.route || page.$page?.fullPath || ''
return normalizePath(route ? `/${route}` : PAGE.INDEX)
}
/**
* 跳转到普通页面(非 TabBar 页面)
* 使用 navigateTo,保留页面栈,可以正常返回
*/
export function navigateToPage(url) {
uni.showLoading({ title: '加载中...', mask: true })
const path = normalizePath(url)
// ✅ 如果目标是 TabBar 页面,不应该使用 navigateTo
// 这种情况应该使用 switchToTabPage(会清空页面栈)
if (TAB_PAGES.has(path)) {
console.warn('[navigateToPage] 不应该用 navigateTo 跳转 TabBar 页面,请使用 switchToTabPage')
uni.hideLoading()
switchToTabPage(path)
return
}
console.log('[navigateToPage] 跳转到:', url)
uni.navigateTo({
url,
fail: (err) => {
console.error('[navigateTo]', url, err)
uni.hideLoading()
// 页面栈满时降级使用 redirectTo
if (err.errMsg && err.errMsg.includes('limit')) {
uni.redirectTo({ url })
} else {
uni.showToast({ title: '页面跳转失败', icon: 'none' })
}
},
complete: () => {
// 页面已发起跳转,隐藏 loading
// 目标页面的 onLoad/onReady 也会调用 hideLoading 做兜底
uni.hideLoading()
}
})
}
/**
* 跳转到 TabBar 页面(清空页面栈)
* 用于从任何页面跳转到首页/课程/训练等 TabBar 页面
*/
export function switchToTabPage(url) {
const path = normalizePath(url)
if (!TAB_PAGES.has(path)) {
console.warn('[switchToTabPage] 目标不是 TabBar 页面:', path)
navigateToPage(url)
return
}
if (getCurrentRoutePath() === path || tabNavigating) return
console.log('[switchToTabPage] 跳转到 TabBar:', path)
tabNavigating = true
uni.switchTab({
url: path,
complete: () => {
uni.hideLoading()
setTimeout(() => {
tabNavigating = false
}, 320)
},
fail: (err) => {
console.error('[switchTab]', path, err)
uni.hideLoading()
uni.reLaunch({
url: path,
complete: () => {
uni.hideLoading()
setTimeout(() => {
tabNavigating = false
}, 320)
}
})
}
})
}
/**
* 重置到 TabBar 页面(清空所有历史)
* 用于退出登录、强制跳转等场景
*/
export function reLaunchToTabPage(url) {
const path = normalizePath(url)
console.log('[reLaunchToTabPage] 重置到:', path)
uni.reLaunch({
url: path,
fail: (err) => {
console.error('[reLaunch]', path, err)
uni.switchTab({ url: path })
}
})
}
/**
* 返回上一页,如果没有上一页则跳转到指定 TabBar 页面
*/
export function goBackOrTab(fallbackUrl = PAGE.MEMBER) {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack({ delta: 1 })
} else {
switchToTabPage(fallbackUrl)
}
}
/**
* 子页面返回个人中心
*/
export function backToMemberCenter() {
goBackOrTab(PAGE.MEMBER)
}
/**
* 子页面返回指定 TabBar 页面
*/
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))
}
@@ -0,0 +1,163 @@
const COLORS = {
primary: '#0B2B4B',
accent: '#FF6B35',
accentLight: 'rgba(255, 107, 53, 0.25)',
grid: '#E9EDF2',
text: '#5E6F8D',
fill: 'rgba(26, 74, 111, 0.35)',
line: '#1A4A6F'
}
function setupCanvas(canvas, width, height, dpr) {
canvas.width = width * dpr
canvas.height = height * dpr
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
return ctx
}
/** 绘制雷达图 */
export function drawRadarChart(canvas, opts = {}) {
if (!canvas) return
const {
width = 280,
height = 240,
labels = [],
values = [],
dpr = 1
} = opts
const ctx = setupCanvas(canvas, width, height, dpr)
ctx.clearRect(0, 0, width, height)
const cx = width / 2
const cy = height / 2 + 8
const radius = Math.min(width, height) * 0.32
const count = labels.length || 6
const angleStep = (Math.PI * 2) / count
for (let level = 1; level <= 4; level += 1) {
ctx.beginPath()
const r = (radius * level) / 4
for (let i = 0; i <= count; i += 1) {
const angle = -Math.PI / 2 + i * angleStep
const x = cx + r * Math.cos(angle)
const y = cy + r * Math.sin(angle)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.strokeStyle = COLORS.grid
ctx.lineWidth = 1
ctx.stroke()
}
for (let i = 0; i < count; i += 1) {
const angle = -Math.PI / 2 + i * angleStep
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.lineTo(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle))
ctx.strokeStyle = COLORS.grid
ctx.stroke()
}
ctx.beginPath()
values.forEach((val, i) => {
const ratio = Math.min(1, Math.max(0, val / 100))
const angle = -Math.PI / 2 + i * angleStep
const x = cx + radius * ratio * Math.cos(angle)
const y = cy + radius * ratio * Math.sin(angle)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
})
ctx.closePath()
ctx.fillStyle = COLORS.fill
ctx.fill()
ctx.strokeStyle = COLORS.accent
ctx.lineWidth = 2
ctx.stroke()
ctx.font = '11px sans-serif'
ctx.fillStyle = COLORS.text
ctx.textAlign = 'center'
labels.forEach((label, i) => {
const angle = -Math.PI / 2 + i * angleStep
const x = cx + (radius + 18) * Math.cos(angle)
const y = cy + (radius + 18) * Math.sin(angle) + 4
ctx.fillText(label, x, y)
})
}
/** 绘制折线趋势图 */
export function drawTrendChart(canvas, opts = {}) {
if (!canvas) return
const {
width = 300,
height = 160,
points = [],
dpr = 1,
unit = ''
} = opts
const ctx = setupCanvas(canvas, width, height, dpr)
ctx.clearRect(0, 0, width, height)
if (!points.length) {
ctx.fillStyle = COLORS.text
ctx.font = '13px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('暂无趋势数据', width / 2, height / 2)
return
}
const pad = { top: 16, right: 12, bottom: 28, left: 12 }
const chartW = width - pad.left - pad.right
const chartH = height - pad.top - pad.bottom
const values = points.map((p) => p.value)
const min = Math.min(...values) * 0.95
const max = Math.max(...values) * 1.05
const range = max - min || 1
ctx.strokeStyle = COLORS.grid
ctx.lineWidth = 1
for (let i = 0; i <= 3; i += 1) {
const y = pad.top + (chartH * i) / 3
ctx.beginPath()
ctx.moveTo(pad.left, y)
ctx.lineTo(width - pad.right, y)
ctx.stroke()
}
const coords = points.map((p, i) => ({
x: pad.left + (chartW * i) / Math.max(1, points.length - 1),
y: pad.top + chartH - ((p.value - min) / range) * chartH
}))
ctx.beginPath()
coords.forEach((pt, i) => {
if (i === 0) ctx.moveTo(pt.x, pt.y)
else ctx.lineTo(pt.x, pt.y)
})
ctx.strokeStyle = COLORS.line
ctx.lineWidth = 2
ctx.stroke()
coords.forEach((pt, i) => {
ctx.beginPath()
ctx.arc(pt.x, pt.y, 4, 0, Math.PI * 2)
ctx.fillStyle = COLORS.accent
ctx.fill()
ctx.strokeStyle = '#fff'
ctx.lineWidth = 1.5
ctx.stroke()
ctx.fillStyle = COLORS.text
ctx.font = '10px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(points[i].label, pt.x, height - 8)
})
if (unit) {
ctx.fillStyle = COLORS.text
ctx.font = '10px sans-serif'
ctx.textAlign = 'left'
ctx.fillText(unit, pad.left, pad.top - 2)
}
}
@@ -0,0 +1,286 @@
function formatRecordTime(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
return `${y}-${m}-${d} ${h}:${min}`
}
function formatIsoDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function formatTime(date) {
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
return `${h}:${min}`
}
export function getDefaultBodyTestState() {
return {
settings: {},
device: { connected: false, battery: 80 },
records: []
}
}
export function mergeBodyTestState(saved) {
const defaults = getDefaultBodyTestState()
if (!saved) return defaults
return {
settings: { ...defaults.settings, ...(saved.settings || {}) },
device: { ...defaults.device, ...(saved.device || {}) },
records: saved.records?.length ? saved.records : defaults.records
}
}
export function getLatestBodyTestRecord(store) {
const records = store.bodyTest?.records || []
return records.length ? { ...records[0] } : null
}
export function getBodyTestRecordById(store, id) {
const numId = Number(id)
const record = (store.bodyTest?.records || []).find((item) => item.id === numId)
return record ? { ...record } : null
}
export function getBodyTestHistory(store, year) {
let list = (store.bodyTest?.records || []).map((item) => ({ ...item }))
if (year && year !== 'all') {
list = list.filter((r) => r.date.startsWith(String(year)))
}
return list
}
export function getBodyTestChangeBadge(record, previous) {
if (!previous?.metrics || !record?.metrics) return null
const diff = Math.round((record.metrics.bodyFat - previous.metrics.bodyFat) * 10) / 10
if (diff === 0) return null
const sign = diff > 0 ? '+' : ''
return { key: 'bodyFat', text: `体脂率${sign}${diff}%`, good: diff < 0 }
}
export function getBodyTestYears(store) {
const years = new Set((store.bodyTest?.records || []).map((r) => r.date.slice(0, 4)))
return ['all', ...Array.from(years).sort().reverse()]
}
export function computeGrade(score) {
if (score >= 90) return { grade: 'A', gradeLabel: '优秀' }
if (score >= 80) return { grade: 'B+', gradeLabel: '良好' }
if (score >= 70) return { grade: 'B', gradeLabel: '中等' }
if (score >= 60) return { grade: 'C', gradeLabel: '一般' }
return { grade: 'D', gradeLabel: '需改善' }
}
export function computeScore(metrics) {
const bmi = metrics.bmi || 22
const bodyFat = metrics.bodyFat || 25
const muscle = metrics.muscleMass || 22
const bmiScore = bmi >= 18.5 && bmi <= 24 ? 90 : bmi >= 17 && bmi <= 27 ? 75 : 60
const fatScore = bodyFat <= 22 ? 92 : bodyFat <= 26 ? 80 : bodyFat <= 30 ? 68 : 55
const muscleScore = muscle >= 22 ? 88 : muscle >= 20 ? 76 : 62
return Math.round((bmiScore + fatScore + muscleScore) / 3)
}
export function computeChanges(current, previous) {
if (!previous?.metrics) return {}
const keys = ['weight', 'bmi', 'bodyFat', 'muscleMass', 'visceralFat', 'bmr', 'bodyWater', 'boneMass']
const changes = {}
keys.forEach((key) => {
const cur = Number(current.metrics[key])
const prev = Number(previous.metrics[key])
if (Number.isFinite(cur) && Number.isFinite(prev)) {
const diff = Math.round((cur - prev) * 10) / 10
changes[key] = diff
}
})
return changes
}
export function formatChangeValue(key, diff, unitSystem = 'metric') {
if (diff === undefined || diff === null) return '--'
const sign = diff > 0 ? '+' : ''
const units = {
weight: unitSystem === 'metric' ? 'kg' : 'lb',
bodyFat: '%',
muscleMass: 'kg',
bmi: '',
visceralFat: '级',
bmr: 'kcal',
bodyWater: '%',
boneMass: 'kg'
}
const unit = units[key] || ''
return `${sign}${diff}${unit}`
}
export function buildBodyReportSummary(record, previous) {
if (!record) {
return {
date: '--',
weight: '--',
bmi: '--',
bodyFat: '--',
bmr: '--',
status: '暂无数据',
change: '--'
}
}
const changes = computeChanges(record, previous)
const weightChange = changes.weight
let changeText = '--'
if (weightChange !== undefined) {
const sign = weightChange > 0 ? '+' : ''
changeText = `${sign}${weightChange}kg`
}
return {
date: record.date,
weight: String(record.metrics.weight),
bmi: String(record.metrics.bmi),
bodyFat: `${record.metrics.bodyFat}%`,
bmr: String(record.metrics.bmr),
status: record.status,
change: changeText,
recordId: record.id
}
}
export function getBodyTestTrendData(store, metricKey, limit = 6) {
const records = [...(store.bodyTest?.records || [])].reverse().slice(-limit)
return records.map((item) => ({
id: item.id,
date: item.date,
label: item.date.slice(5),
value: Number(item.metrics[metricKey]) || 0
}))
}
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: 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
}))
return { recordA: a, recordB: b, metrics }
}
export function getRecommendedCourses(record) {
return []
}
export function updateBodyTestSettings(store, patch) {
store.bodyTest.settings = { ...store.bodyTest.settings, ...patch }
return store
}
export function connectBodyTestDevice(store) {
store.bodyTest.device = {
...store.bodyTest.device,
connected: true,
battery: Math.min(100, (store.bodyTest.device.battery || 80) + Math.floor(Math.random() * 5)),
lastConnected: formatRecordTime(new Date())
}
return store
}
export function disconnectBodyTestDevice(store) {
store.bodyTest.device = { ...store.bodyTest.device, connected: false }
return store
}
function nextRecordId(records) {
return (records || []).reduce((max, item) => Math.max(max, item.id || 0), 0) + 1
}
/** 模拟一次完整体测并写入记录 */
export function saveSimulatedBodyTestRecord(store, finalMetrics) {
const now = new Date()
const previous = getLatestBodyTestRecord(store)
const metrics = { ...finalMetrics }
const heightCm = Number(store.profile?.height) || 165
const heightM = heightCm / 100
metrics.bmi = Math.round((metrics.weight / (heightM * heightM)) * 10) / 10
const score = computeScore(metrics)
const { grade, gradeLabel } = computeGrade(score)
const status = score >= 80 ? '比较健康' : score >= 70 ? '需关注' : '建议改善'
const radar = {
weight: Math.min(95, Math.round(score * 0.9 + Math.random() * 5)),
bodyFat: Math.min(95, Math.round(100 - metrics.bodyFat * 2.5)),
muscle: Math.min(95, Math.round(metrics.muscleMass * 3.2)),
bone: Math.min(95, Math.round(metrics.boneMass * 32)),
water: Math.min(95, Math.round(metrics.bodyWater * 1.4)),
bmr: Math.min(95, Math.round(metrics.bmr / 16))
}
const record = {
id: nextRecordId(store.bodyTest.records),
date: formatIsoDate(now),
time: formatTime(now),
score,
grade,
gradeLabel,
status,
metrics,
radar,
bodySegments: [],
advice: [],
recommendedCourseIds: []
}
if (previous) {
record.changes = computeChanges(record, previous)
}
store.bodyTest.records.unshift(record)
store.bodyReport = buildBodyReportSummary(record, previous)
return record
}
/** 测量过程实时数据插值 */
export function interpolateMeasuringMetrics(progress, profile) {
const baseWeight = Number(profile?.weight) || 64
const target = {
weight: baseWeight - 0.3 + Math.random() * 0.2,
bodyFat: 24.5 + Math.random() * 0.8,
muscleMass: 22.4 + Math.random() * 0.3,
visceralFat: 6,
bmr: 1380 + Math.floor(Math.random() * 20),
bodyWater: 52.5 + Math.random(),
boneMass: 2.4,
protein: 16.2
}
const ratio = Math.min(1, progress / 100)
return {
weight: Math.round((baseWeight + (target.weight - baseWeight) * ratio) * 10) / 10,
bodyFat: Math.round((26 + (target.bodyFat - 26) * ratio) * 10) / 10,
muscleMass: Math.round((21.5 + (target.muscleMass - 21.5) * ratio) * 10) / 10,
bmr: Math.round(1340 + (target.bmr - 1340) * ratio),
bodyWater: Math.round((51 + (target.bodyWater - 51) * ratio) * 10) / 10
}
}
@@ -0,0 +1,135 @@
import { courseCatalogMock } from './mockData.js'
function clone(value) {
return JSON.parse(JSON.stringify(value))
}
export function getDefaultCourseCatalog() {
return clone(courseCatalogMock.courses)
}
export function mergeCourseCatalog(saved) {
const defaults = getDefaultCourseCatalog()
if (!saved?.length) return defaults
return saved.map((item, i) => ({ ...defaults[i], ...item }))
}
function parseCourseStart(course) {
const str = `${course.date} ${course.startTime}`.replace(/-/g, '/')
return new Date(str)
}
function getPeriod(hour) {
if (hour < 12) return 'morning'
if (hour < 18) return 'afternoon'
return 'evening'
}
export function filterCourses(courses, filters = {}) {
const {
date = '',
weekDates = [],
type = 'all',
coach = '全部',
period = 'all'
} = filters
return courses.filter((c) => {
if (type !== 'all' && c.type !== type) return false
if (coach !== '全部' && c.coach !== coach) return false
if (period !== 'all' && c.period !== period) return false
if (date && c.date !== date) {
if (!weekDates.length || !weekDates.includes(c.date)) return false
}
return true
})
}
export function getCourseById(store, id) {
const course = (store.courseCatalog || []).find((c) => c.id === Number(id))
return course ? { ...course } : null
}
export function canCancelBooking(item) {
if (!item?.courseDate || !item?.startTime) return !!item?.canCancel
const start = new Date(`${item.courseDate} ${item.startTime}`.replace(/-/g, '/'))
const diff = start - Date.now()
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: '课程不存在' }
if (course.enrolled >= course.capacity) return { ok: false, message: '课程已约满' }
const exists = store.ongoingBookings.some((b) => b.courseId === course.id)
if (exists) return { ok: false, message: '您已预约该课程' }
course.enrolled += 1
const nextId = store.ongoingBookings.reduce((m, b) => Math.max(m, b.id || 0), 0) + 1
const parts = course.date.split('-')
const booking = {
id: nextId,
courseId: course.id,
title: course.title,
banner: course.banner,
status: 'booked',
statusLabel: '已预约',
schedule: `${parts[1]}${parts[2]}${course.startTime}-${course.endTime}`,
dateDay: parts[2],
dateMonth: `${parts[2]}`,
timeRange: `${course.startTime}-${course.endTime}`,
courseDate: course.date,
startTime: course.startTime,
coach: course.coach,
coachShort: course.coach.replace('教练', ''),
location: course.location,
footerText: `可取消(需提前2小时,截止 ${parts[1]}/${parts[2]} ${course.startTime} 前2小时)`,
canCancel: true,
type: course.type
}
store.ongoingBookings.unshift(booking)
return { ok: true, message: '预约成功', booking }
}
export function getWeekDates(baseDateStr) {
const base = baseDateStr ? new Date(baseDateStr.replace(/-/g, '/')) : new Date()
const day = base.getDay() || 7
const monday = new Date(base)
monday.setDate(base.getDate() - day + 1)
const dates = []
for (let i = 0; i < 7; i += 1) {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
dates.push(formatIso(d))
}
return dates
}
function formatIso(d) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export function enrichCourseForDisplay(course) {
const remaining = course.capacity - course.enrolled
const percent = Math.round((course.enrolled / course.capacity) * 100)
return {
...course,
remaining,
percent,
full: remaining <= 0,
scarcityLabel: remaining > 0 && remaining <= 5 ? `仅剩${remaining}` : ''
}
}
export { courseCatalogMock }
@@ -0,0 +1,37 @@
/** 手机号展示脱敏(中间四位 ****) */
export function maskPhone(phone) {
if (phone == null || phone === '') return ''
const str = String(phone).trim()
if (str.includes('****')) return str
const digits = str.replace(/\D/g, '')
if (digits.length === 11) {
return `${digits.slice(0, 3)}****${digits.slice(7)}`
}
if (digits.length > 4) {
const hideLen = Math.min(4, digits.length - 3)
const start = Math.floor((digits.length - hideLen) / 2)
return `${digits.slice(0, start)}${'*'.repeat(hideLen)}${digits.slice(start + hideLen)}`
}
return str
}
/** 个人中心头部:138****6789 已绑定微信 */
export function formatMemberCenterPhone(phone) {
const masked = maskPhone(phone)
return masked ? `${masked} 已绑定微信` : ''
}
/** 保存前规范化:尽量存 11 位数字;已是脱敏串则原样保留 */
export function normalizePhoneForStore(phone) {
const str = String(phone || '').trim()
if (!str) return ''
if (str.includes('****')) return str
const digits = str.replace(/\D/g, '')
if (digits.length >= 11) return digits.slice(0, 11)
return digits || str
}
@@ -0,0 +1,78 @@
export { statusBarTimeMixin, subPageMixin } from './mixins.js'
export {
loadMemberStore,
saveMemberStore,
persistMemberStore,
syncStats,
computeRemainingDays,
buildCardTip,
formatUpcomingAlert,
getBookingPreview,
getCenterPageData,
cancelOngoingBooking,
renewMemberCard,
parseLocalDate,
saveUserProfile,
getCurrentMemberId,
getLoginMemberInfo,
getToken
} from './store.js'
export {
getLatestBodyTestRecord,
getBodyTestRecordById,
getBodyTestHistory,
computeChanges,
formatChangeValue,
buildBodyReportSummary,
getBodyTestTrendData,
getCompareData,
getRecommendedCourses,
getBodyTestChangeBadge,
getBodyTestYears,
updateBodyTestSettings,
connectBodyTestDevice,
disconnectBodyTestDevice,
saveSimulatedBodyTestRecord,
interpolateMeasuringMetrics
} from './bodyTestStore.js'
export {
getTrainingReportData,
getTrainingSessionById,
filterTrainingSessions,
getCouponsByStatus,
getCouponById,
useCoupon,
deleteExpiredCoupon,
getCouponCenterList,
claimCouponFromCenter,
getPointsPageData,
redeemPointsReward,
filterPointsHistory,
getReferralPageData,
getMyCoursesData,
getMyCoursesByTab,
getOnlineCourseById,
updateOnlineProgress,
getCheckInHistory
} from './moduleStore.js'
export {
filterCourses,
getCourseById,
bookCourse,
canCancelBooking,
enrichCourseForDisplay,
getWeekDates
} from './bookingStore.js'
export { previewImage, persistChosenImage, isLocalFilePath } from './media.js'
export { maskPhone, formatMemberCenterPhone, normalizePhoneForStore } from './format.js'
export {
validateName,
validatePhone,
validatePhoneForRebind,
validateHeight,
validateWeight,
validateBirthday,
validateFitnessGoals,
validateUserProfile,
showValidationError
} from './validate.js'
@@ -0,0 +1,159 @@
/** 头像等媒体:真机选图后须 saveFile/static/ 须 getImageInfo */
function buildStaticPathCandidates(url) {
const list = [url]
if (url.startsWith('/')) {
list.push(url.slice(1))
} else {
list.push(`/${url}`)
}
return [...new Set(list.filter(Boolean))]
}
function isPackageStaticPath(url) {
return /^(\/)?static\//i.test(url)
}
/** chooseImage / saveFile 产生的本地路径(含真机 temp、usr、store */
export function isLocalFilePath(url) {
if (!url) return false
if (/^(wxfile:|file:|blob:|data:)/i.test(url)) return true
if (/^https?:\/\/(tmp|usr|store)\//i.test(url)) return true
if (/^https?:\/\//i.test(url) && !isPackageStaticPath(url)) return true
return false
}
function showPreviewFail() {
uni.showToast({ title: '无法预览头像', icon: 'none' })
}
function openPreview(path, onFail) {
if (!path) {
;(onFail || showPreviewFail)()
return
}
uni.previewImage({
urls: [path],
current: path,
fail: () => (onFail ? onFail() : showPreviewFail())
})
}
function previewLocalFile(url) {
openPreview(url, () => {
uni.getImageInfo({
src: url,
success: (res) => {
openPreview(res.path || url, showPreviewFail)
},
fail: showPreviewFail
})
})
}
function tryGetImageInfo(candidates, index, onSuccess, onFail) {
if (index >= candidates.length) {
onFail()
return
}
uni.getImageInfo({
src: candidates[index],
success: (res) => onSuccess(res.path || candidates[index]),
fail: () => tryGetImageInfo(candidates, index + 1, onSuccess, onFail)
})
}
function getMpUserDataPath() {
// #ifdef MP-WEIXIN
return wx.env.USER_DATA_PATH
// #endif
return ''
}
function tryCopyFile(candidates, index, onSuccess, onFail) {
// #ifdef MP-WEIXIN
const userPath = getMpUserDataPath()
if (!userPath) {
onFail()
return
}
const fs = uni.getFileSystemManager()
const extMatch = candidates[0]?.match(/\.(\w+)(?:\?|$)/)
const ext = extMatch ? extMatch[1] : 'png'
const dest = `${userPath}/avatar_preview_${Date.now()}.${ext}`
if (index >= candidates.length) {
onFail()
return
}
fs.copyFile({
srcPath: candidates[index],
destPath: dest,
success: () => onSuccess(dest),
fail: () => tryCopyFile(candidates, index + 1, onSuccess, onFail)
})
// #endif
// #ifndef MP-WEIXIN
onFail()
// #endif
}
function previewPackageStatic(url) {
const candidates = buildStaticPathCandidates(url)
tryGetImageInfo(
candidates,
0,
(path) => openPreview(path, showPreviewFail),
() => {
tryCopyFile(
candidates,
0,
(path) => openPreview(path, showPreviewFail),
showPreviewFail
)
}
)
}
/** 选图后将临时文件转为真机可预览、可持久化的本地路径 */
export function persistChosenImage(tempPath) {
return new Promise((resolve) => {
const path = String(tempPath || '').trim()
if (!path) {
resolve('')
return
}
// #ifdef MP-WEIXIN
uni.saveFile({
tempFilePath: path,
success: (res) => resolve(res.savedFilePath || path),
fail: () => resolve(path)
})
// #endif
// #ifndef MP-WEIXIN
resolve(path)
// #endif
})
}
export function previewImage(src, fallback = '') {
const url = String(src || fallback || '').trim()
if (!url) {
uni.showToast({ title: '暂无头像', icon: 'none' })
return
}
if (isLocalFilePath(url)) {
previewLocalFile(url)
return
}
if (isPackageStaticPath(url)) {
previewPackageStatic(url)
return
}
previewLocalFile(url)
}
@@ -0,0 +1,28 @@
import { backToMemberCenter } from '../constants/routes.js'
/** 状态栏时间(Pixso 顶栏占位) */
export const statusBarTimeMixin = {
data() {
return {
statusBarTime: '9:41'
}
},
onLoad() {
this.updateStatusBarTime()
},
methods: {
updateStatusBarTime() {
const now = new Date()
this.statusBarTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
}
}
}
/** 子页面返回个人中心 tab */
export const subPageMixin = {
methods: {
goBack() {
backToMemberCenter()
}
}
}
@@ -0,0 +1,887 @@
/** 个人中心模块 mock 数据(后续可替换为 API) */
// Mock 数据开关 - 设为 false 可关闭所有 mock 数据
export const MOCK_ENABLED = false
export const memberCenterMock = MOCK_ENABLED ? {
userInfo: {
name: '张小芳',
phone: '13812345678 已绑定微信',
memberLevel: '黄金会员',
avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AvatarEditWrap.png'
},
stats: {
checkInCount: 128,
trainingHours: 23,
pointsBalance: 1250
},
cardInfo: {
name: '健身时长卡',
detailTag: '详情',
expireDate: '有效期至 2025年12月31日',
remainingDays: 187,
tip: '距离下次到期还有187天,请及时续费'
},
checkIns: [
{
id: 1,
title: '今日签到 · 瑜伽初级班',
time: '2024-07-12 09:05',
tag: '团课',
tagTheme: 'group'
},
{
id: 2,
title: '自由训练 · 进馆记录',
time: '2024-07-11 18:30',
tag: '自由',
tagTheme: 'free'
},
{
id: 3,
title: '私教课 · 力量训练',
time: '2024-07-10 14:00',
tag: '私教',
tagTheme: 'private'
}
],
bodyReport: {
date: '2024-07-01',
weight: '63.5',
bmi: '22.1',
bodyFat: '24.8%',
bmr: '165',
status: '比较健康',
change: '-1.2kg'
},
couponPoints: {
amount: '¥50',
couponDesc: '满500可用 · 1张',
couponAction: '去使用',
points: 1250,
pointsLabel: '我的积分',
pointsAction: '去兑换'
},
referral: {
code: 'FIT-ZXF-2024',
invited: 5,
registered: 3,
purchased: 2
}
} : null
export const userInfoMock = {
name: '张小芳',
phone: '13812345678',
gender: 'female',
birthday: '1995年06月15日',
height: '165',
weight: '63.5',
fitnessGoals: ['减脂', '塑形'],
avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AvatarEditWrap.png'
}
export const fitnessGoalOptions = ['减脂', '塑形', '增肌', '提升耐力', '改善体态']
export const memberCardMock = {
card: {
name: '黄金健身时长卡',
status: '生效中',
validityStart: '2024年01月01日',
validity: '2024年01月01日 - 2025年12月31日',
validityEnd: '2025-12-31',
remainingDays: 187
},
recordTabs: [
{ key: 'all', label: '全部' },
{ key: 'consume', label: '消费' },
{ key: 'checkin', label: '签到' }
],
records: [
{
id: 1,
type: 'checkin',
title: '瑜伽初级班 · 团课签到',
time: '2024-07-12 09:05',
value: '-1次',
valueType: 'negative',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/dumbbell.png',
iconTheme: 'orange'
},
{
id: 2,
type: 'checkin',
title: '自由进馆',
time: '2024-07-11 18:30',
value: '-1天',
valueType: 'negative',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/mappin.png',
iconTheme: 'green'
},
{
id: 3,
type: 'consume',
title: '会员卡充值',
time: '2024-07-01 10:00',
value: '+90天',
valueType: 'positive',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/pluscircle.png',
iconTheme: 'orange'
}
],
rules: [
'时长卡有效期内不限入场次数,但需提前预约团课',
'卡到期后不退余额,请合理安排使用',
'一卡仅限本人使用,不可转让'
]
}
/** 智能体测模块 mock 数据 */
export const bodyTestMock = {
settings: {
autoSync: true,
bluetoothEnabled: true,
notifyOnComplete: true,
shareAnonymous: false,
unitSystem: 'metric'
},
device: {
connected: false,
name: 'InBody 270',
model: 'IB-270',
battery: 86,
signal: 'strong',
lastConnected: '2024-07-10 18:20'
},
connectSteps: [
{ step: 1, title: '开启体测仪', desc: '长按电源键 3 秒,等待蓝牙指示灯闪烁' },
{ step: 2, title: '靠近设备', desc: '将手机靠近体测仪 1 米范围内' },
{ step: 3, title: '确认连接', desc: '点击下方按钮搜索并配对设备' }
],
metricDefs: [
{ key: 'weight', label: '体重', unit: 'kg', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/target.png' },
{ key: 'bmi', label: 'BMI', unit: '', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/activity.png' },
{ key: 'bodyFat', label: '体脂率', unit: '%', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/trendingdown.png' },
{ key: 'muscleMass', label: '肌肉量', unit: 'kg', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/dumbbell.png' },
{ key: 'visceralFat', label: '内脏脂肪', unit: '级', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/alertcircle.png' },
{ key: 'bmr', label: '基础代谢', unit: 'kcal', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/clock.png' },
{ key: 'bodyWater', label: '体水分', unit: '%', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/shield.png' },
{ key: 'boneMass', label: '骨量', unit: 'kg', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user.png' }
],
radarLabels: [
{ key: 'weight', label: '体重控制' },
{ key: 'bodyFat', label: '体脂肪' },
{ key: 'muscle', label: '肌肉量' },
{ key: 'bone', label: '骨量' },
{ key: 'water', label: '体水分' },
{ key: 'bmr', label: '基础代谢' }
],
trendMetrics: [
{ key: 'weight', label: '体重' },
{ key: 'bodyFat', label: '体脂率' },
{ key: 'muscleMass', label: '肌肉量' },
{ key: 'bmi', label: 'BMI' }
],
recommendedCourses: [
{
id: 1,
title: '燃脂 HIIT 团课',
coach: '李明教练',
schedule: '每周二、四 19:00',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
tag: '减脂推荐'
},
{
id: 2,
title: '核心力量塑形',
coach: '王强教练',
schedule: '每周一、三 18:30',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
tag: '塑形推荐'
}
],
records: [
{
id: 4,
date: '2024-07-12',
time: '09:05',
score: 85,
grade: 'B+',
gradeLabel: '良好',
status: '比较健康',
bodyAge: 27,
realAge: 29,
metrics: {
weight: 63.5,
bmi: 22.1,
bodyFat: 24.8,
muscleMass: 22.6,
visceralFat: 6,
bmr: 1385,
bodyWater: 52.8,
boneMass: 2.42,
protein: 16.4
},
radar: { weight: 78, bodyFat: 72, muscle: 74, bone: 81, water: 79, bmr: 73 },
bodySegments: [
{ part: '左臂', level: 'normal', value: '2.1kg' },
{ part: '右臂', level: 'normal', value: '2.2kg' },
{ part: '躯干', level: 'high', value: '28.5kg' },
{ part: '左腿', level: 'normal', value: '8.6kg' },
{ part: '右腿', level: 'normal', value: '8.7kg' }
],
advice: [
'体脂率略高,建议增加有氧训练频率至每周 3-4 次',
'核心肌群表现良好,可尝试进阶力量课程',
'保持当前蛋白质摄入,有助于维持肌肉量'
],
recommendedCourseIds: [1, 2]
},
{
id: 3,
date: '2024-06-28',
time: '18:40',
score: 82,
grade: 'B+',
gradeLabel: '良好',
status: '比较健康',
bodyAge: 28,
realAge: 29,
metrics: {
weight: 64.7,
bmi: 22.5,
bodyFat: 25.3,
muscleMass: 22.2,
visceralFat: 7,
bmr: 1370,
bodyWater: 52.1,
boneMass: 2.4,
protein: 16.1
},
radar: { weight: 74, bodyFat: 68, muscle: 70, bone: 80, water: 76, bmr: 70 },
bodySegments: [
{ part: '左臂', level: 'normal', value: '2.0kg' },
{ part: '右臂', level: 'normal', value: '2.1kg' },
{ part: '躯干', level: 'high', value: '28.2kg' },
{ part: '左腿', level: 'normal', value: '8.5kg' },
{ part: '右腿', level: 'normal', value: '8.6kg' }
],
advice: [
'体重较上次下降 0.8kg,减脂方向正确',
'建议配合拉伸课程改善体态'
],
recommendedCourseIds: [1]
},
{
id: 2,
date: '2024-06-10',
time: '10:15',
score: 79,
grade: 'B',
gradeLabel: '中等',
status: '需关注',
bodyAge: 30,
realAge: 29,
metrics: {
weight: 65.5,
bmi: 22.8,
bodyFat: 26.1,
muscleMass: 21.8,
visceralFat: 8,
bmr: 1355,
bodyWater: 51.5,
boneMass: 2.38,
protein: 15.8
},
radar: { weight: 70, bodyFat: 62, muscle: 66, bone: 78, water: 72, bmr: 66 },
bodySegments: [
{ part: '左臂', level: 'low', value: '1.9kg' },
{ part: '右臂', level: 'normal', value: '2.0kg' },
{ part: '躯干', level: 'high', value: '28.0kg' },
{ part: '左腿', level: 'normal', value: '8.4kg' },
{ part: '右腿', level: 'normal', value: '8.5kg' }
],
advice: [
'内脏脂肪偏高,建议减少高糖饮食',
'增加抗阻训练提升肌肉量'
],
recommendedCourseIds: [2]
},
{
id: 1,
date: '2024-05-20',
time: '14:30',
score: 76,
grade: 'B',
gradeLabel: '中等',
status: '需关注',
bodyAge: 31,
realAge: 29,
metrics: {
weight: 66.2,
bmi: 23.1,
bodyFat: 26.8,
muscleMass: 21.5,
visceralFat: 9,
bmr: 1340,
bodyWater: 51.0,
boneMass: 2.35,
protein: 15.5
},
radar: { weight: 66, bodyFat: 58, muscle: 62, bone: 76, water: 68, bmr: 62 },
bodySegments: [
{ part: '左臂', level: 'low', value: '1.8kg' },
{ part: '右臂', level: 'low', value: '1.9kg' },
{ part: '躯干', level: 'high', value: '27.8kg' },
{ part: '左腿', level: 'normal', value: '8.3kg' },
{ part: '右腿', level: 'normal', value: '8.4kg' }
],
advice: [
'建议制定 8 周减脂计划并定期复测',
'每日饮水量建议达到 2000ml'
],
recommendedCourseIds: [1, 2]
}
]
}
export const bookingMock = {
upcomingAlert: '明天 09:00 有一堂瑜伽课,请提前 30 分钟到场',
tabs: [
{ key: 'ongoing', label: '进行中' },
{ key: 'history', label: '历史预约' }
],
ongoing: [
{
id: 1,
title: '瑜伽基础班',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
status: 'booked',
statusLabel: '已预约',
schedule: '07月15日 09:00-10:00',
dateDay: '07',
dateMonth: '月15日',
timeRange: '09:00-10:00',
coach: '李明教练',
coachShort: '李明',
location: '一楼 大厅',
footerText: '可取消(截止 07/15 07:00',
canCancel: true
},
{
id: 2,
title: '私教健身课',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
status: 'pending',
statusLabel: '待上课',
schedule: '07月18日 14:00-15:00',
dateDay: '07',
dateMonth: '月18日',
timeRange: '14:00-15:00',
coach: '王强教练',
coachShort: '王强',
location: 'B区私教室',
footerText: '地点:B区私教室',
canCancel: true
}
],
history: [
{
id: 3,
title: '动感单车',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
status: 'completed',
statusLabel: '已完成',
schedule: '07月10日 19:00-20:00',
coach: '赵敏教练',
footerText: '已签到',
canCancel: false
},
{
id: 4,
title: '普拉提进阶',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
status: 'cancelled',
statusLabel: '已取消',
schedule: '07月05日 10:00-11:00',
coach: '李明教练',
footerText: '用户主动取消',
canCancel: false
}
]
}
/** 可预约课程 catalog */
export const courseCatalogMock = {
coaches: ['全部', '李明教练', '王强教练', '赵敏教练'],
periodOptions: [
{ key: 'all', label: '全部时段' },
{ key: 'morning', label: '上午' },
{ key: 'afternoon', label: '下午' },
{ key: 'evening', label: '晚上' }
],
typeOptions: [
{ key: 'all', label: '全部' },
{ key: 'group', label: '团课' },
{ key: 'private', label: '私教' }
],
courses: [
{
id: 101,
title: '瑜伽基础班',
type: 'group',
coach: '李明教练',
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user0.png',
date: '2024-07-15',
startTime: '09:00',
endTime: '10:00',
location: '一楼大厅',
enrolled: 12,
capacity: 20,
price: '次卡扣 1 次',
payType: 'session',
period: 'morning',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
intro: '适合零基础学员,重点提升柔韧性与呼吸控制。',
suitable: '久坐办公族、初学者、想改善体态者',
coachBio: '国家一级瑜伽指导员,5年教学经验',
coachRating: 4.9,
reviews: [
{ user: '会员 A', score: 5, text: '教练讲解很细致,氛围很好' },
{ user: '会员 B', score: 5, text: '适合新手,推荐' }
],
cancelRule: '至少提前 2 小时取消,否则视为爽约'
},
{
id: 102,
title: 'HIIT 燃脂团课',
type: 'group',
coach: '赵敏教练',
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user1.png',
date: '2024-07-15',
startTime: '19:00',
endTime: '20:00',
location: '有氧区',
enrolled: 18,
capacity: 20,
price: '时长卡',
payType: 'duration',
period: 'evening',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
intro: '高强度间歇训练,快速燃脂提升心肺。',
suitable: '有一定运动基础、目标减脂者',
coachBio: 'ACE 认证教练,擅长 HIIT 与动感单车',
coachRating: 4.8,
reviews: [{ user: '会员 C', score: 5, text: '强度够,出汗很多' }],
cancelRule: '至少提前 2 小时取消'
},
{
id: 103,
title: '私教 · 力量训练',
type: 'private',
coach: '王强教练',
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user2.png',
date: '2024-07-16',
startTime: '14:00',
endTime: '15:00',
location: 'B区私教室',
enrolled: 1,
capacity: 1,
price: '私教课时卡',
payType: 'private',
period: 'afternoon',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
intro: '一对一力量训练,定制训练计划。',
suitable: '增肌塑形、康复训练',
coachBio: 'NSCA 认证私教,8年从业经验',
coachRating: 5.0,
reviews: [{ user: '会员 D', score: 5, text: '非常专业' }],
cancelRule: '至少提前 2 小时取消'
},
{
id: 104,
title: '普拉提进阶',
type: 'group',
coach: '李明教练',
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user0.png',
date: '2024-07-17',
startTime: '10:30',
endTime: '11:30',
location: '二楼瑜伽室',
enrolled: 8,
capacity: 15,
price: '次卡扣 1 次',
payType: 'session',
period: 'morning',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
intro: '核心稳定与体态矫正进阶课程。',
suitable: '有普拉提基础者',
coachBio: '国家一级瑜伽指导员',
coachRating: 4.9,
reviews: [],
cancelRule: '至少提前 2 小时取消'
},
{
id: 105,
title: '动感单车',
type: 'group',
coach: '赵敏教练',
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user1.png',
date: '2024-07-18',
startTime: '18:30',
endTime: '19:30',
location: '单车房',
enrolled: 20,
capacity: 20,
price: '储值卡 ¥39',
payType: 'stored',
period: 'evening',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
intro: '音乐骑行,团队氛围燃脂。',
suitable: '所有级别,可调节阻力',
coachBio: 'ACE 认证教练',
coachRating: 4.7,
reviews: [{ user: '会员 E', score: 4, text: '音乐很带感' }],
cancelRule: '至少提前 2 小时取消'
}
]
}
/** 个人中心其它模块 mock 数据 */
export const moduleMock = {
trainingReport: {
periodLabel: '本周训练',
summary: {
sessions: 4,
hours: 6.5,
calories: 2180,
streak: 3,
visits: 5
},
monthlyHours: [
{ label: '第1周', value: 4.2 },
{ label: '第2周', value: 5.8 },
{ label: '第3周', value: 6.5 },
{ label: '第4周', value: 5.0 }
],
monthlyCalories: [
{ label: '第1周', value: 1200 },
{ label: '第2周', value: 1680 },
{ label: '第3周', value: 2180 },
{ label: '第4周', value: 1850 }
],
weeklyHours: [
{ label: '一', value: 1.2 },
{ label: '二', value: 0 },
{ label: '三', value: 1.5 },
{ label: '四', value: 0.8 },
{ label: '五', value: 1.0 },
{ label: '六', value: 2.0 },
{ label: '日', value: 0 }
],
sessions: [
{
id: 1,
title: '瑜伽基础班',
coach: '李明教练',
date: '2024-07-12',
time: '09:00-10:00',
duration: '60分钟',
calories: 320,
type: 'group',
typeLabel: '团课'
},
{
id: 2,
title: '自由训练 · 力量',
coach: '自主训练',
date: '2024-07-11',
time: '18:30-19:45',
duration: '75分钟',
calories: 480,
type: 'free',
typeLabel: '自由'
},
{
id: 3,
title: '私教 · 核心塑形',
coach: '王强教练',
date: '2024-07-10',
time: '14:00-15:00',
duration: '60分钟',
calories: 410,
type: 'private',
typeLabel: '私教'
},
{
id: 4,
title: '动感单车',
coach: '赵敏教练',
date: '2024-07-08',
time: '19:00-20:00',
duration: '60分钟',
calories: 520,
type: 'group',
typeLabel: '团课'
}
]
},
couponTabs: [
{ key: 'available', label: '可用' },
{ key: 'used', label: '已使用' },
{ key: 'expired', label: '已过期' }
],
coupons: [
{
id: 1,
status: 'available',
amount: 50,
title: '满500减50',
desc: '全场团课/私教可用',
expire: '2024-12-31',
minSpend: 500,
tag: '通用券',
rules: '1. 满500元可用\n2. 适用于团课/私教\n3. 不可与其他优惠叠加\n4. 有效期至2024-12-31',
scope: '全门店 · 团课/私教',
flow: '选择课程 → 确认订单 → 选择优惠券 → 完成支付'
},
{
id: 2,
status: 'available',
amount: 30,
title: '新人专享',
desc: '首次购课立减',
expire: '2024-08-31',
minSpend: 200,
tag: '新人券',
rules: '1. 限新注册用户首次购课\n2. 满200可用',
scope: '全门店 · 首次购课',
flow: '首次预约课程时自动提示使用'
},
{
id: 3,
status: 'used',
amount: 20,
title: '签到奖励券',
desc: '连续签到7天获得',
expire: '2024-07-01',
minSpend: 100,
tag: '奖励券',
usedAt: '2024-06-28',
rules: '满100可用',
scope: '团课',
flow: '预约时使用'
},
{
id: 4,
status: 'expired',
amount: 100,
title: '周年庆特惠',
desc: '满1000可用',
expire: '2024-06-01',
minSpend: 1000,
tag: '活动券',
rules: '满1000可用,已过期',
scope: '全门店',
flow: '—'
}
],
couponCenter: [
{
id: 11,
amount: 20,
title: '周末团课券',
desc: '周末团课满200减20',
expireDays: 30,
minSpend: 200,
tag: '可领取',
claimed: false
},
{
id: 12,
amount: 50,
title: '私教体验券',
desc: '私教课满500减50',
expireDays: 15,
minSpend: 500,
tag: '限时',
claimed: false
},
{
id: 13,
amount: 10,
title: '签到加油券',
desc: '无门槛10元券',
expireDays: 7,
minSpend: 0,
tag: '每日',
claimed: true
}
],
pointsConfig: {
rate: '100积分 = 1元',
rule: '签到、训练、邀请好友、购课均可获得积分;积分可用于商城兑换。'
},
pointsRewards: [
{ id: 1, name: '团课体验券', cost: 500, stock: 12, icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/ticket.png' },
{ id: 2, name: '运动毛巾', cost: 800, stock: 5, icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/dumbbell.png' },
{ id: 3, name: '私教体验30分钟', cost: 2000, stock: 3, icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/usercheck.png' },
{ id: 4, name: '蛋白粉小样', cost: 350, stock: 20, icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/star.png' }
],
pointsHistory: [
{ id: 1, type: 'earn', title: '团课签到', amount: 50, time: '2024-07-12 09:10', balance: 1250 },
{ id: 2, type: 'earn', title: '邀请好友注册', amount: 200, time: '2024-07-08 15:30', balance: 1200 },
{ id: 3, type: 'spend', title: '兑换团课体验券', amount: -500, time: '2024-07-01 11:00', balance: 1000 },
{ id: 4, type: 'earn', title: '会员卡续费奖励', amount: 100, time: '2024-07-01 10:05', balance: 1500 },
{ id: 5, type: 'earn', title: '体测完成奖励', amount: 30, time: '2024-06-28 18:45', balance: 1400 }
],
referralRules: [
'好友通过您的邀请码注册,双方各得 100 积分',
'好友首次购课成功后,您额外获得 300 积分',
'每月邀请奖励上限 10 人,超出不再计奖',
'积分可用于兑换课程体验券及周边礼品'
],
referralRecords: [
{ id: 1, name: '李**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user0.png', status: 'purchased', statusLabel: '已购课', time: '2024-07-05', reward: '+300积分', rewardStatus: '已发放' },
{ id: 2, name: '王**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user1.png', status: 'registered', statusLabel: '已注册', time: '2024-06-20', reward: '+100积分', rewardStatus: '已发放' },
{ id: 3, name: '陈**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user2.png', status: 'invited', statusLabel: '已邀请', time: '2024-06-15', reward: '待注册', rewardStatus: '待发放' },
{ id: 4, name: '赵**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user3.png', status: 'purchased', statusLabel: '已购课', time: '2024-06-01', reward: '+300积分', rewardStatus: '已发放' },
{ id: 5, name: '刘**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user0.png', status: 'registered', statusLabel: '已注册', time: '2024-05-28', reward: '+100积分', rewardStatus: '已发放' }
],
referralRewardSummary: {
totalPoints: 800,
totalCoupons: 2,
pendingCount: 1
},
myCourseTabs: [
{ key: 'group', label: '团课' },
{ key: 'private', label: '私教' },
{ key: 'online', label: '线上课' },
{ key: 'package', label: '训练营' }
],
myCourses: {
group: {
ongoing: [
{
id: 1,
title: '瑜伽基础班',
coach: '李明教练',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
progress: 6,
total: 12,
schedule: '每周二、四 09:00',
location: '一楼大厅',
nextClass: '07月16日 09:00',
canCancel: true,
bookingId: 1
}
],
completed: [
{
id: 3,
title: '动感单车入门',
coach: '赵敏教练',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
progress: 8,
total: 8,
schedule: '已结课',
location: '单车房',
completedAt: '2024-06-30',
canEvaluate: true
}
]
},
private: {
remaining: 7,
coach: '王强教练',
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user2.png',
nextClass: '07月15日 14:00',
bookings: [
{ id: 2, title: '私教 · 力量训练', time: '07月18日 14:00', status: '已预约', location: 'B区私教室' }
],
completed: [
{ id: 5, title: '私教 · 核心塑形', time: '2024-07-10 14:00', coach: '王强教练' }
]
},
online: [
{
id: 201,
title: '居家核心训练',
cover: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
duration: '45分钟',
progress: 60,
chapters: 6,
watched: 4,
type: 'video'
},
{
id: 202,
title: '直播 · 晨间拉伸',
cover: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
duration: '30分钟',
progress: 0,
liveTime: '07月20日 07:00',
type: 'live'
}
],
package: [
{
id: 301,
title: '28天减脂训练营',
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
progress: 3,
total: 10,
coach: '李明教练',
schedule: '每周5练'
}
]
},
checkInTabs: [
{ key: 'all', label: '全部' },
{ key: 'group', label: '团课' },
{ key: 'private', label: '私教' },
{ key: 'free', label: '自由' }
],
checkInHistory: [
{
id: 1,
title: '今日签到 · 瑜伽初级班',
time: '2024-07-12 09:05',
tag: '团课',
tagTheme: 'group',
location: '一楼大厅'
},
{
id: 2,
title: '自由训练 · 进馆记录',
time: '2024-07-11 18:30',
tag: '自由',
tagTheme: 'free',
location: '器械区'
},
{
id: 3,
title: '私教课 · 力量训练',
time: '2024-07-10 14:00',
tag: '私教',
tagTheme: 'private',
location: 'B区私教室'
},
{
id: 4,
title: '团课签到 · 动感单车',
time: '2024-07-08 19:02',
tag: '团课',
tagTheme: 'group',
location: '单车房'
},
{
id: 5,
title: '自由训练 · 进馆记录',
time: '2024-07-06 17:45',
tag: '自由',
tagTheme: 'free',
location: '有氧区'
}
]
}
@@ -0,0 +1,270 @@
import { moduleMock } from './mockData.js'
function clone(value) {
return JSON.parse(JSON.stringify(value))
}
export function getDefaultModuleState() {
return {
trainingReport: clone(moduleMock.trainingReport),
coupons: clone(moduleMock.coupons),
couponCenter: clone(moduleMock.couponCenter),
pointsHistory: clone(moduleMock.pointsHistory),
pointsRewards: clone(moduleMock.pointsRewards),
redeemRecords: [],
referralRecords: clone(moduleMock.referralRecords),
myCourses: clone(moduleMock.myCourses),
checkInHistory: clone(moduleMock.checkInHistory)
}
}
export function mergeModuleState(saved) {
const defaults = getDefaultModuleState()
if (!saved) return defaults
return {
trainingReport: { ...defaults.trainingReport, ...(saved.trainingReport || {}) },
coupons: saved.coupons?.length ? saved.coupons : defaults.coupons,
couponCenter: saved.couponCenter?.length ? saved.couponCenter : defaults.couponCenter,
pointsHistory: saved.pointsHistory?.length ? saved.pointsHistory : defaults.pointsHistory,
pointsRewards: saved.pointsRewards?.length ? saved.pointsRewards : defaults.pointsRewards,
redeemRecords: saved.redeemRecords || defaults.redeemRecords,
referralRecords: saved.referralRecords?.length ? saved.referralRecords : defaults.referralRecords,
myCourses: saved.myCourses ? mergeMyCourses(defaults.myCourses, saved.myCourses) : defaults.myCourses,
checkInHistory: saved.checkInHistory?.length ? saved.checkInHistory : defaults.checkInHistory
}
}
function mergeMyCourses(defaults, saved) {
return {
group: saved.group || defaults.group,
private: saved.private || defaults.private,
online: saved.online?.length ? saved.online : defaults.online,
package: saved.package?.length ? saved.package : defaults.package
}
}
function syncCouponSummary(store) {
const available = store.modules.coupons.filter((c) => c.status === 'available')
const top = available[0]
store.couponPoints = {
...store.couponPoints,
amount: top ? `¥${top.amount}` : '暂无',
couponDesc: top
? `${top.minSpend}可用 · ${available.length}`
: '暂无可用优惠券',
couponAction: available.length ? '去使用' : '去领取',
points: store.stats.pointsBalance,
pointsLabel: '我的积分',
pointsAction: '去兑换'
}
return store
}
export function finalizeModules(store) {
syncCouponSummary(store)
store.checkIns = store.modules.checkInHistory.slice(0, 3).map((item) => ({ ...item }))
return store
}
export function getTrainingReportData(store, period = 'week') {
const report = store.modules.trainingReport
const trend = period === 'month' ? report.monthlyHours : report.weeklyHours
const calTrend = period === 'month' ? report.monthlyCalories : report.weeklyHours.map((w, i) => ({
label: w.label,
value: Math.round((report.summary.calories / 7) * (w.value || 0.5))
}))
return {
...report,
period,
summary: {
...report.summary,
hours: store.stats.trainingHours ?? report.summary.hours,
visits: report.summary.visits ?? store.stats.checkInCount ?? 5
},
trendHours: trend.map((t) => ({ ...t, id: t.label })),
trendCalories: calTrend.map((t) => ({ ...t, id: t.label })),
sessions: report.sessions.map((s) => ({ ...s }))
}
}
export function getTrainingSessionById(store, id) {
const session = store.modules.trainingReport.sessions.find((s) => s.id === Number(id))
if (!session) return null
return {
...session,
heartRate: '128 bpm',
comment: '动作标准,核心发力良好,下次可增加负重。',
checkInTime: `${session.date} ${session.time.split('-')[0]}`
}
}
export function filterTrainingSessions(store, filters = {}) {
let list = store.modules.trainingReport.sessions.map((s) => ({ ...s }))
if (filters.type && filters.type !== 'all') {
list = list.filter((s) => s.type === filters.type)
}
return list
}
export function getCouponsByStatus(store, status) {
return store.modules.coupons.filter((c) => c.status === status).map((c) => ({ ...c }))
}
export function getCouponById(store, id) {
const c = store.modules.coupons.find((item) => item.id === Number(id))
return c ? { ...c } : null
}
export function useCoupon(store, id) {
const coupon = store.modules.coupons.find((c) => c.id === id)
if (!coupon || coupon.status !== 'available') return null
coupon.status = 'used'
coupon.usedAt = new Date().toISOString().slice(0, 10)
syncCouponSummary(store)
return coupon
}
export function deleteExpiredCoupon(store, id) {
const idx = store.modules.coupons.findIndex((c) => c.id === id && c.status === 'expired')
if (idx >= 0) store.modules.coupons.splice(idx, 1)
syncCouponSummary(store)
return store
}
export function getCouponCenterList(store) {
return store.modules.couponCenter.map((c) => ({ ...c }))
@@ -0,0 +1,352 @@
import {
getDefaultBodyTestState,
mergeBodyTestState,
getLatestBodyTestRecord,
buildBodyReportSummary
} from './bodyTestStore.js'
import {
getDefaultModuleState,
mergeModuleState,
finalizeModules
} from './moduleStore.js'
import { getDefaultCourseCatalog,
mergeCourseCatalog,
canCancelBooking
} from './bookingStore.js'
import { getMemberId as requestGetMemberId } from '@/utils/request.js'
const STORAGE_KEY = 'gym_member_info_v1'
/**
* 获取当前登录会员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) {
return `距离下次到期还有${remainingDays}天,请及时续费`
}
function applyCardInfo(store) {
const days = computeRemainingDays(store.card.validityEnd)
store.card.remainingDays = days
store.cardInfo.remainingDays = days
store.cardInfo.tip = buildCardTip(days)
return store
}
export function syncStats(store) {
store.stats = {
...store.stats,
checkInCount: store.stats.checkInCount ?? 0,
courseCount: store.stats.courseCount ?? 0,
pointsBalance: store.stats.pointsBalance ?? 0
}
return store
}
function finalizeStore(store) {
syncStats(store)
applyCardInfo(store)
const latestBodyTest = getLatestBodyTestRecord(store)
if (latestBodyTest) {
const previous = store.bodyTest.records[1]
store.bodyReport = buildBodyReportSummary(latestBodyTest, previous)
}
if (store.modules) {
finalizeModules(store)
}
return store
}
function getDefaultStore() {
return finalizeStore({
stats: {},
cardInfo: {},
card: {},
cards: [],
records: [],
ongoingBookings: [],
historyBookings: [],
checkIns: [],
bodyReport: {},
bodyTest: getDefaultBodyTestState(),
modules: getDefaultModuleState(),
courseCatalog: getDefaultCourseCatalog(),
couponPoints: {},
referral: {},
storedCard: {
balance: 0,
zeroVerified: false
}
})
}
export { getDefaultStore }
function mergeDefaults(saved) {
const defaults = getDefaultStore()
return finalizeStore({
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,
checkIns: saved.checkIns?.length ? saved.checkIns : defaults.checkIns,
bodyReport: { ...defaults.bodyReport, ...(saved.bodyReport || {}) },
bodyTest: mergeBodyTestState(saved.bodyTest),
modules: mergeModuleState(saved.modules),
courseCatalog: mergeCourseCatalog(saved.courseCatalog),
couponPoints: { ...defaults.couponPoints, ...(saved.couponPoints || {}) },
referral: { ...defaults.referral, ...(saved.referral || {}) },
storedCard: { ...defaults.storedCard, ...(saved.storedCard || {}) }
})
}
export function loadMemberStore() {
try {
const saved = uni.getStorageSync(STORAGE_KEY)
if (saved && typeof saved === 'object') {
return mergeDefaults(saved)
}
} catch (e) {
console.warn('[memberStore] load failed', e)
}
return getDefaultStore()
}
export function saveMemberStore(store) {
uni.setStorageSync(STORAGE_KEY, store)
}
/** 解析为本地 0 点,避免 ISO 字符串时区偏差 */
export function parseLocalDate(dateStr) {
if (!dateStr) return null
const str = String(dateStr).trim()
const iso = str.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (iso) {
return new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]))
}
const cn = str.match(/(\d{4})年(\d{2})月(\d{2})日/)
if (cn) {
return new Date(Number(cn[1]), Number(cn[2]) - 1, Number(cn[3]))
}
const parsed = new Date(str.replace(/-/g, '/'))
return Number.isNaN(parsed.getTime()) ? null : parsed
}
function formatIsoDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function formatChineseDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${y}${m}${day}`
}
function formatRecordTime(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
return `${y}-${m}-${d} ${h}:${min}`
}
function nextRecordId(records) {
return (records || []).reduce((max, item) => Math.max(max, item.id || 0), 0) + 1
}
export function computeRemainingDays(endDateStr) {
const end = parseLocalDate(endDateStr)
if (!end) return 0
const now = new Date()
now.setHours(0, 0, 0, 0)
end.setHours(0, 0, 0, 0)
const diff = Math.ceil((end - now) / 86400000)
return Math.max(0, diff)
}
export function formatUpcomingAlert(booking) {
if (!booking) return ''
const timePart = booking.timeRange || booking.schedule?.split(' ')[1] || ''
return `明天 ${timePart} 有一堂${booking.title},请提前 30 分钟到场`
}
export function toBookingPreviewItem(item) {
return {
id: item.id,
dateDay: item.dateDay,
dateMonth: item.dateMonth,
desc: `${item.title} · ${item.timeRange}`,
coach: item.coachShort || item.coach.replace('教练', ''),
location: item.location ? `地点:${item.location}` : '',
status: item.status,
statusLabel: item.statusLabel
}
}
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 {
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 },
couponPoints: {
...store.couponPoints,
points: store.stats.pointsBalance
},
referral: { ...store.referral }
}
}
export function cancelOngoingBooking(store, id) {
const index = store.ongoingBookings.findIndex((b) => b.id === id)
if (index < 0) return { ok: false, message: '预约不存在' }
const item = store.ongoingBookings[index]
if (!canCancelBooking(item)) {
return { ok: false, message: '距开课不足2小时,无法取消' }
}
const [removed] = store.ongoingBookings.splice(index, 1)
if (removed.courseId) {
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',
statusLabel: '已取消',
footerText: '用户主动取消',
canCancel: false
})
finalizeStore(store)
saveMemberStore(store)
return { ok: true, message: '已取消' }
}
export function renewMemberCard(store, addDays = 90) {
const now = new Date()
now.setHours(0, 0, 0, 0)
let base = parseLocalDate(store.card.validityEnd) || new Date(now)
base.setHours(0, 0, 0, 0)
// 已过期:从今天起续费;未过期:从当前到期日起顺延
if (base < now) {
base = new Date(now)
}
const end = new Date(base)
end.setDate(end.getDate() + addDays)
const validityEnd = formatIsoDate(end)
const validityEndCn = formatChineseDate(end)
store.card.validityEnd = validityEnd
store.card.validity = store.card.validityStart
? `${store.card.validityStart} - ${validityEndCn}`
: `2024年01月01日 - ${validityEndCn}`
store.cardInfo.expireDate = `有效期至 ${validityEndCn}`
store.records.unshift({
id: nextRecordId(store.records),
type: 'consume',
title: '会员卡续费',
time: formatRecordTime(new Date()),
value: `+${addDays}`,
valueType: 'positive',
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/pluscircle.png',
iconTheme: 'orange'
})
finalizeStore(store)
saveMemberStore(store)
return store
}
export function saveUserProfile(store, profile) {
// 不再保存profile到store,用户信息从loginMemberInfo获取
if (profile.weight) {
store.bodyReport.weight = profile.weight
}
finalizeStore(store)
saveMemberStore(store)
return store
}
export function persistMemberStore(store) {
finalizeStore(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] 余额不使用缓存,无需清除')
}
@@ -0,0 +1,202 @@
/** 个人信息页前端校验(与后端手机号规则对齐:^1[3-9]\\d{9}$ */
const PHONE_REG = /^1[3-9]\d{9}$/
const MIN_NAME_LEN = 2
const MAX_NAME_LEN = 8
const NAME_REG = new RegExp(
`^[\\u4e00-\\u9fa5a-zA-Z·\\s]{${MIN_NAME_LEN},${MAX_NAME_LEN}}$`
)
const MEASURE_REG = /^\d+(\.\d)?$/
const MIN_HEIGHT = 50
const MAX_HEIGHT = 250
const MIN_WEIGHT = 20
const MAX_WEIGHT = 300
const MIN_BIRTH_YEAR = 1900
const MIN_AGE = 14
const MAX_FITNESS_GOALS = 5
export function isMaskedPhone(phone) {
return String(phone || '').includes('****')
}
export function validateName(name) {
const value = String(name ?? '').trim()
if (!value) {
return { ok: false, message: '请输入姓名' }
}
if (!NAME_REG.test(value)) {
return { ok: false, message: `姓名为${MIN_NAME_LEN}-${MAX_NAME_LEN}个汉字或字母` }
}
return { ok: true, value }
}
/** 保存时使用:允许保留已脱敏的旧手机号 */
export function validatePhone(phone, options = {}) {
const { allowMasked = true } = options
const raw = String(phone ?? '').trim()
if (!raw) {
return { ok: false, message: '请绑定手机号' }
}
if (allowMasked && isMaskedPhone(raw)) {
const digits = raw.replace(/\D/g, '')
if (digits.length >= 7) {
return { ok: true, value: raw }
}
return { ok: false, message: '手机号格式不正确' }
}
const digits = raw.replace(/\D/g, '')
if (!PHONE_REG.test(digits)) {
return { ok: false, message: '请输入11位有效手机号' }
}
return { ok: true, value: digits }
}
/** 换绑时必须输入完整新号 */
export function validatePhoneForRebind(phone) {
return validatePhone(phone, { allowMasked: false })
}
function parseMeasure(value) {
const str = String(value ?? '').trim()
if (!str || !MEASURE_REG.test(str)) {
return null
}
const num = Number(str)
return Number.isFinite(num) ? num : null
}
function formatMeasure(num) {
return Number.isInteger(num) ? String(num) : String(Number(num.toFixed(1)))
}
export function validateHeight(height) {
const num = parseMeasure(height)
if (num == null) {
return { ok: false, message: '请输入有效身高(单位 cm' }
}
if (num < MIN_HEIGHT || num > MAX_HEIGHT) {
return { ok: false, message: `身高请在 ${MIN_HEIGHT}-${MAX_HEIGHT} cm 之间` }
}
return { ok: true, value: formatMeasure(num) }
}
export function validateWeight(weight) {
const num = parseMeasure(weight)
if (num == null) {
return { ok: false, message: '请输入有效体重(单位 kg' }
}
if (num < MIN_WEIGHT || num > MAX_WEIGHT) {
return { ok: false, message: `体重请在 ${MIN_WEIGHT}-${MAX_WEIGHT} kg 之间` }
}
return { ok: true, value: formatMeasure(num) }
}
export function parseBirthdayChinese(birthday) {
const match = String(birthday ?? '').match(/(\d{4})年(\d{2})月(\d{2})日/)
if (!match) return null
return {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3])
}
}
export function validateBirthday(birthday) {
const parts = parseBirthdayChinese(birthday)
if (!parts) {
return { ok: false, message: '请选择生日' }
}
const { year, month, day } = parts
if (year < MIN_BIRTH_YEAR) {
return { ok: false, message: '生日年份不合理' }
}
const date = new Date(year, month - 1, day)
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return { ok: false, message: '生日日期无效' }
}
const today = new Date()
today.setHours(0, 0, 0, 0)
if (date > today) {
return { ok: false, message: '生日不能晚于今天' }
}
const minBirth = new Date(
today.getFullYear() - MIN_AGE,
today.getMonth(),
today.getDate()
)
if (date > minBirth) {
return { ok: false, message: `需年满 ${MIN_AGE} 周岁` }
}
return { ok: true, value: `${year}${String(month).padStart(2, '0')}${String(day).padStart(2, '0')}` }
}
export function validateGender(gender) {
if (gender === 'male' || gender === 'female') {
return { ok: true, value: gender }
}
return { ok: false, message: '请选择性别' }
}
export function validateFitnessGoals(goals, options = []) {
const list = Array.isArray(goals) ? goals : []
const allowed = new Set(options)
const invalid = list.filter((g) => !allowed.has(g))
if (invalid.length) {
return { ok: false, message: '健身目标选项无效' }
}
if (list.length > MAX_FITNESS_GOALS) {
return { ok: false, message: `最多选择 ${MAX_FITNESS_GOALS} 个健身目标` }
}
return { ok: true, value: [...list] }
}
export function validateUserProfile(profile, goalOptions = []) {
const nameResult = validateName(profile.name)
if (!nameResult.ok) return nameResult
const phoneResult = validatePhone(profile.phone)
if (!phoneResult.ok) return phoneResult
const genderResult = validateGender(profile.gender)
if (!genderResult.ok) return genderResult
const birthdayResult = validateBirthday(profile.birthday)
if (!birthdayResult.ok) return birthdayResult
const heightResult = validateHeight(profile.height)
if (!heightResult.ok) return heightResult
const weightResult = validateWeight(profile.weight)
if (!weightResult.ok) return weightResult
const goalsResult = validateFitnessGoals(profile.fitnessGoals, goalOptions)
if (!goalsResult.ok) return goalsResult
return {
ok: true,
value: {
...profile,
name: nameResult.value,
phone: phoneResult.value,
gender: genderResult.value,
birthday: birthdayResult.value,
height: heightResult.value,
weight: weightResult.value,
fitnessGoals: goalsResult.value
}
}
}
export function showValidationError(message) {
uni.showToast({ title: message, icon: 'none' })
}
+302
View File
@@ -0,0 +1,302 @@
/**
* 路由权限拦截器
* 自动拦截配置好的路径,未登录时自动弹出登录弹窗
*
* 使用方式:在 App.vue 中 import 并调用 init() 即可
* 无需在每个页面单独配置
*/
import { getToken } from '@/utils/request.js'
/**
* 白名单 - 不需要登录即可访问的页面
*/
const WHITE_LIST = [
'/pages/index/index',
'/pages/course/course',
'/pages/memberInfo/login',
'/pages/memberInfo/register',
'/pages/memberInfo/forgetPassword',
'/pages/about/about',
'/pages/help/help',
'/pages/notice/notice',
'/pages/product/product',
'/pages/store/store',
'/pages/article/article',
'/pages/search/search'
]
/**
* 需要登录的页面路径前缀
* 匹配以下前缀的页面都需要登录
*/
const LOGIN_REQUIRED_PREFIXES = [
'/pages/memberInfo/checkIn',
'/pages/memberInfo/booking',
'/pages/memberInfo/coupons',
'/pages/memberInfo/points',
'/pages/memberInfo/userInfo',
'/pages/memberInfo/order',
'/pages/memberInfo/settings',
'/pages/memberInfo/feedback',
'/pages/memberInfo/invite',
'/pages/memberInfo/recharge'
]
/**
* 需要登录的具体页面路径
*/
const LOGIN_REQUIRED_PATHS = [
'/pages/memberInfo/memberInfo'
]
/**
* 检查当前路径是否需要登录
* @param {String} url - 页面路径(可能包含query参数)
* @returns {Boolean}
*/
function isNeedLogin(url) {
// 去掉query参数
const path = url.split('?')[0]
// 检查白名单
if (WHITE_LIST.includes(path)) {
return false
}
// 检查具体路径
if (LOGIN_REQUIRED_PATHS.includes(path)) {
return true
}
// 检查路径前缀
if (LOGIN_REQUIRED_PREFIXES.some(prefix => path.startsWith(prefix))) {
return true
}
return false
}
/**
* 检查用户是否已登录
* @returns {Boolean}
*/
function isLoggedIn() {
const token = getToken()
const isLoginStorage = uni.getStorageSync('isLogin')
return !!(token || isLoginStorage)
}
/**
* 登录弹窗组件引用(需要在App.vue中注册)
*/
let loginModalRef = null
/**
* 注册登录弹窗组件
* @param {Object} modalRef - LoginModal组件的ref
*/
export function registerLoginModal(modalRef) {
loginModalRef = modalRef
}
/**
* 显示登录弹窗
*/
function showLoginModal() {
if (loginModalRef) {
loginModalRef.show()
} else {
// 如果没有注册弹窗,跳转到登录页
uni.navigateTo({
url: '/pages/memberInfo/login'
})
}
}
/**
* 记录用户原本要访问的页面
* @param {String} url - 页面路径
*/
function saveRedirectUrl(url) {
uni.setStorageSync('redirectUrl', url)
}
/**
* 获取用户原本要访问的页面
* @returns {String|null}
*/
export function getRedirectUrl() {
const url = uni.getStorageSync('redirectUrl')
uni.removeStorageSync('redirectUrl')
return url
}
/**
* 跳转到用户原本要访问的页面
*/
export function redirectToSavedUrl() {
const url = getRedirectUrl()
if (url) {
uni.navigateTo({
url: url,
fail: () => {
uni.switchTab({
url: '/pages/index/index'
})
}
})
}
}
/**
* 初始化路由拦截器
*/
export function initPermissionInterceptor() {
console.log('[PermissionInterceptor] 初始化路由拦截器')
// 拦截 navigateTo
uni.addInterceptor('navigateTo', {
invoke(args) {
const url = args.url
if (isNeedLogin(url) && !isLoggedIn()) {
console.log('[PermissionInterceptor] 拦截 navigateTo:', url)
saveRedirectUrl(url)
showLoginModal()
return false
}
return true
},
success() {
// 跳转成功
},
fail(err) {
console.error('[PermissionInterceptor] navigateTo 失败:', err)
}
})
// 拦截 redirectTo
uni.addInterceptor('redirectTo', {
invoke(args) {
const url = args.url
if (isNeedLogin(url) && !isLoggedIn()) {
console.log('[PermissionInterceptor] 拦截 redirectTo:', url)
saveRedirectUrl(url)
showLoginModal()
return false
}
return true
},
success() {},
fail(err) {
console.error('[PermissionInterceptor] redirectTo 失败:', err)
}
})
// 拦截 reLaunch
uni.addInterceptor('reLaunch', {
invoke(args) {
const url = args.url
if (isNeedLogin(url) && !isLoggedIn()) {
console.log('[PermissionInterceptor] 拦截 reLaunch:', url)
saveRedirectUrl(url)
showLoginModal()
return false
}
return true
},
success() {},
fail(err) {
console.error('[PermissionInterceptor] reLaunch 失败:', err)
}
})
// 拦截 switchTab
uni.addInterceptor('switchTab', {
invoke(args) {
const url = args.url
if (isNeedLogin(url) && !isLoggedIn()) {
console.log('[PermissionInterceptor] 拦截 switchTab:', url)
saveRedirectUrl(url)
showLoginModal()
return false
}
return true
},
success() {},
fail(err) {
console.error('[PermissionInterceptor] switchTab 失败:', err)
}
})
// 拦截 navigateBack
uni.addInterceptor('navigateBack', {
invoke() {
return true
},
success() {},
fail() {}
})
console.log('[PermissionInterceptor] 路由拦截器初始化完成')
}
/**
* 添加需要登录的页面路径
* @param {String|Array} paths - 页面路径或路径数组
*/
export function addLoginRequiredPaths(paths) {
if (Array.isArray(paths)) {
LOGIN_REQUIRED_PATHS.push(...paths)
} else {
LOGIN_REQUIRED_PATHS.push(paths)
}
}
/**
* 添加需要登录的页面路径前缀
* @param {String|Array} prefixes - 路径前缀或前缀数组
*/
export function addLoginRequiredPrefixes(prefixes) {
if (Array.isArray(prefixes)) {
LOGIN_REQUIRED_PREFIXES.push(...prefixes)
} else {
LOGIN_REQUIRED_PREFIXES.push(prefixes)
}
}
/**
* 添加到白名单
* @param {String|Array} paths - 页面路径或路径数组
*/
export function addWhiteList(paths) {
if (Array.isArray(paths)) {
WHITE_LIST.push(...paths)
} else {
WHITE_LIST.push(paths)
}
}
/**
* 获取当前配置的白名单
* @returns {Array}
*/
export function getWhiteList() {
return [...WHITE_LIST]
}
/**
* 获取当前配置的需要登录的路径
* @returns {Array}
*/
export function getLoginRequiredPaths() {
return [...LOGIN_REQUIRED_PATHS]
}
/**
* 获取当前配置的需要登录的路径前缀
* @returns {Array}
*/
export function getLoginRequiredPrefixes() {
return [...LOGIN_REQUIRED_PREFIXES]
}
+231
View File
@@ -0,0 +1,231 @@
/**
* ============================================
* 健身房管理系统小程序 - 全局配色变量
* 主题:清新健康运动风格
* 主色调:浅蓝渐变 + 活力橙点缀
* 兼容暗色/浅色模式基础,保证可访问性
* ============================================
*/
:root {
/* ========== 主品牌色(首页主题蓝绿色系)========== */
--primary-dark: #2D4A5A; /* 深蓝绿主色 - 用于重要文字、品牌标识,体现专业信赖感 */
--primary-deep: #7AB5CC; /* 蓝绿主色 - 用于按钮、图标、标签背景,首页核心主题色 */
--primary-light: #9CCFDF; /* 浅蓝绿 - 用于hover状态、渐变辅助,增加层次感 */
/* 主页主题蓝绿渐变色系 */
--primary-sky-100: #D6EEF8; /* 最浅蓝 - 渐变起始色,清新自然 */
--primary-sky-200: #E4F2FA; /* 浅蓝 - 渐变第二层 */
--primary-sky-300: #EEF6FB; /* 淡蓝 - 渐变第三层 */
--primary-sky-400: #F5FAFD; /* 微蓝 - 渐变第四层 */
--primary-sky-500: #FAFCFE; /* 极浅蓝 - 渐变第五层,接近白色 */
/* 光晕效果色 */
--glow-blue-1: rgba(160, 210, 235, 0.35); /* 蓝绿色光晕 */
--glow-blue-2: rgba(180, 220, 240, 0.3); /* 浅蓝色光晕 */
--glow-blue-3: rgba(170, 215, 238, 0.25); /* 浅蓝绿色光晕 */
/* ========== 强调/行动色(活力绿)========== */
--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%); /* 主页主渐变背景 */
--bg-light: #F5FAFD; /* 全局浅蓝背景 - 柔和且提升蓝色/橙色的视觉舒适度 */
--bg-white: #FFFFFF; /* 纯白卡片背景 - 用于内容卡片、表单区域,提高可读性与层次感 */
--bg-gray: #F2F5F9; /* 浅灰辅助背景 - 分割区域或禁用态背景 */
/* ========== 文本色系 ========== */
--text-dark: #1E2A3A; /* 主要文字 - 标题、正文,保证高对比度 */
--text-muted: #5E6F8D; /* 辅助文字 - 次要信息、占位符,保持易读性 */
--text-light: #8A99B4; /* 更浅文字 - 提示语、时间戳,但需注意与背景对比 */
--text-inverse: #FFFFFF; /* 反白文字 - 深色/橙色背景上的文字 */
/* ========== 边框/分割线 ========== */
--border-light: #E9EDF2; /* 浅边框 - 卡片分割、列表边界,细腻柔和 */
--border-focus: #FF6B35; /* 聚焦边框 - 输入框选中或强调区域,使用橙色点缀 */
/* ========== 状态颜色(功能性) ========== */
--success-green: #2ECC71; /* 成功绿 - 已完成课程、健康打卡 */
--warning-amber: #F39C12; /* 警示橙黄 - 提醒、到期提示 */
--error-red: #E74C3C; /* 错误红 - 异常情况或取消预约 */
--info-blue: #3498DB; /* 信息蓝 - 提示气泡、帮助文字 */
/* ========== 渐变色 (提升活力感) ========== */
--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 配色(清新蓝绿风格)========== */
/* 引用位置: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: #7AB5CC; /* 选中图标颜色 - 蓝绿色(首页主题色) */
--tabbar-text-inactive: #8AABBB; /* 未选中文字颜色 - 浅灰蓝 */
--tabbar-text-active: #7AB5CC; /* 选中文字颜色 - 蓝绿色(与图标一致,首页主题色) */
/* ========== 通用蓝色系阴影(用于卡片、按钮等)========== */
/* 引用位置:components/index/RecommendCourses.vue, QuickEntry.vue, TodayRecommend.vue */
--shadow-blue-light: rgba(120, 185, 215, 0.18); /* 浅蓝色阴影 - 卡片悬浮效果 */
/* ========== 阴影层级 ========== */
--shadow-sm: 0 8px 20px rgba(0, 0, 0, 0.03), 0 2px 6px rgba(0, 0, 0, 0.05); /* 卡片小阴影 轻量浮起 */
--shadow-md: 0 12px 28px rgba(0, 0, 0, 0.08); /* 中等阴影 - 弹窗或下拉菜单 */
--shadow-lg: 0 20px 35px rgba(0, 0, 0, 0.12); /* 大阴影 - 模态框、悬浮元素 */
--shadow-orange-glow: 0 4px 12px rgba(255, 107, 53, 0.25); /* 橙色光晕 - 增强CTA吸引力 */
--shadow-sky-glow: 0 4px 12px rgba(160, 210, 235, 0.2); /* 蓝色光晕 - 主页效果增强 */
/* ========== 圆角规范 (柔和运动风) ========== */
--radius-sm: 12px; /* 小组件、标签圆角 */
--radius-md: 20px; /* 标准卡片圆角 */
--radius-lg: 28px; /* 大容器、头部卡片圆角 */
--radius-full: 999px; /* 胶囊按钮、头像完全圆角 */
/* ========== 布局与间距 ========== */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* ========== 字体 (移动端优先) ========== */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-size-xs: 0.7rem; /* 辅助标注 */
--font-size-sm: 0.8rem; /* 次要文字 */
--font-size-base: 0.9rem; /* 正文基准 */
--font-size-md: 1rem; /* 小标题 */
--font-size-lg: 1.2rem; /* 卡片标题 */
--font-size-xl: 1.4rem; /* 大数字/欢迎语 */
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
}
/* ========== 暗色模式适配(可选,保持品牌一致性) ========== */
@media (prefers-color-scheme: dark) {
:root {
/* 暗色模式下微调背景与文字,保留品牌色核心 */
--bg-light: #121826;
--bg-white: #1E2636;
--bg-gray: #0F141F;
--text-dark: #EDF2F7;
--text-muted: #9AA9C1;
--border-light: #2A3346;
--shadow-sm: 0 8px 20px rgba(0, 0, 0, 0.4);
/* 保留主色深蓝与橙色不变,但可适当提高对比 */
--primary-dark: #123A5E; /* 亮一点保证深色背景可见度 */
--accent-orange: #FF7846; /* 稍微提亮橙色 */
}
}
/* ========== 辅助类 (方便开发直接复用) ========== */
/* 背景色类 */
.bg-primary {
background-color: var(--primary-dark);
}
.bg-accent {
background-color: var(--accent-orange);
}
.bg-gradient-sky {
background: var(--gradient-sky);
}
.bg-gradient-primary {
background: var(--bg-gradient-primary);
}
.bg-sky-100 {
background-color: var(--primary-sky-100);
}
.bg-sky-200 {
background-color: var(--primary-sky-200);
}
.bg-sky-300 {
background-color: var(--primary-sky-300);
}
.bg-sky-400 {
background-color: var(--primary-sky-400);
}
.bg-sky-500 {
background-color: var(--primary-sky-500);
}
/* 文字色类 */
.text-primary {
color: var(--primary-dark);
}
.text-accent {
color: var(--accent-orange);
}
.text-sky-100 {
color: var(--primary-sky-100);
}
.text-sky-200 {
color: var(--primary-sky-200);
}
/* 光晕效果类 */
.glow-blue-1 {
background: radial-gradient(circle, var(--glow-blue-1) 0%, transparent 70%);
}
.glow-blue-2 {
background: radial-gradient(circle, var(--glow-blue-2) 0%, transparent 70%);
}
.glow-blue-3 {
background: radial-gradient(circle, var(--glow-blue-3) 0%, transparent 70%);
}
/* 按钮类 */
.btn-orange {
background: var(--gradient-orange);
color: white;
border: none;
border-radius: var(--radius-full);
padding: 10px 20px;
font-weight: var(--font-weight-bold);
box-shadow: var(--shadow-orange-glow);
transition: all 0.2s ease;
}
.btn-orange:active {
transform: scale(0.97);
background: var(--accent-orange-dark);
}
/* 卡片类 */
.card-default {
background: var(--bg-white);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
padding: var(--spacing-md);
}
.card-sky {
background: var(--gradient-sky);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
/* 阴影类 */
.shadow-sky {
box-shadow: var(--shadow-sky-glow);
}
/* 通用页面容器 */
.page-container-sky {
min-height: 100vh;
background: var(--gradient-sky);
}
/* 滚动容器 */
.scroll-container-sky {
height: 100vh;
width: 100%;
background: var(--gradient-sky);
}
@@ -0,0 +1,25 @@
@font-face {
font-family: "iconfont_courseCard"; /* Project id */
src: url('./font/iconfont_courseCard.ttf?t=1780537357472') format('truetype');
}
.iconfont_courseCard {
font-family: "iconfont_courseCard" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-didian:before {
content: "\e61a";
}
.icon-renwu-ren:before {
content: "\e749";
}
.icon-shijian:before {
content: "\e61d";
}
@@ -0,0 +1,29 @@
@font-face {
font-family: "iconfont_time_select"; /* Project id */
src: url('./font/iconfont_time_select.ttf?t=1780535096813') format('truetype');
}
.iconfont_time_select {
font-family: "iconfont_time_select" !important;
font-size: 25px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-zaochen:before {
content: "\e784";
}
.icon-gengduo:before {
content: "\e6df";
}
.icon-xiawucha:before {
content: "\100ff";
}
.icon-yewan:before {
content: "\e67e";
}
@@ -0,0 +1,12 @@
@import './member-info-page.css';
@import './member-info-status-bar.css';
@import './member-info-header.css';
@import './member-info-member-card.css';
@import './member-info-quick-actions.css';
@import './member-info-booking-list.css';
@import './member-info-check-in-list.css';
@import './member-info-body-report.css';
@import './member-info-coupon-points.css';
@import './member-info-referral.css';
@import './member-info-settings.css';
@import './member-info-logout.css';
@@ -0,0 +1,245 @@
.body-report-section {
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
}
.body-report-section__inner {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.body-report-section__header {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.body-report-section__header-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.body-report-section__title {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark);
}
.body-report-section__card {
width: 100%;
display: flex;
flex-direction: column;
border-radius: 14px;
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
background-color: var(--bg-white, #ffffff);
}
.body-report-section__card-inner {
display: flex;
flex-direction: column;
gap: 12px;
padding: var(--spacing-md, 16px);
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.body-report-section__card-head {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.body-report-section__card-head-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.body-report-section__desc {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
}
.body-report-section__view-btn {
display: flex;
flex-direction: row;
gap: 4px;
align-items: center;
}
.body-report-section__view-icon {width: 14px;
height: 14px;
display: block;
}
.body-report-section__view-report {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--accent-orange);
}
.body-report-section__metrics {
width: 100%;
display: flex;
flex-direction: row;
align-items: flex-start;
}
.body-report-section__metrics-inner {
display: flex;
flex-direction: row;
align-items: flex-start;
width: 100%;
}
.body-report-section__metric {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
.body-report-section__metric-inner {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
width: 100%;
}
.body-report-section__text {
font-size: var(--font-size-xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--accent-orange);
}
.body-report-section__text-2 {
font-size: var(--font-size-xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--success-green);
}
.body-report-section__text-4 {
font-size: var(--font-size-xl);
font-family: var(--font-family);
font-weight: 700;
color: rgba(243, 156, 18, 1);
}
.body-report-section__num {
font-size: var(--font-size-xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--primary-deep);
}
.body-report-section__metric-value,
.body-report-section__text-3,
.body-report-section__metric-label,
.body-report-section__text-5 {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
}
.body-report-section__metric-divider {
width: 1px;
height: 30px;
flex-shrink: 0;
background-color: var(--border-light, #e9edf2);
}
.body-report-section__summary {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
padding: 10px 12px;
border-radius: 10px;
background-color: var(--bg-light, #f9fafe);
box-sizing: border-box;
overflow: hidden;
}
.body-report-section__summary-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
}
.body-report-section__goal {
flex: 1;
min-width: 0;
max-width: 58%;
display: flex;
flex-direction: row;
gap: var(--spacing-xs);
align-items: center;
padding: 5px 10px;
border-radius: 100px;
background-color: rgba(240, 250, 245, 1);
}
.body-report-section__goal-icon {width: 12px;
height: 12px;
flex-shrink: 0;
display: block;
}
.body-report-section__goal-text {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 500;
color: var(--success-green);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body-report-section__change {
flex-shrink: 0;
display: flex;
flex-direction: row;
gap: var(--spacing-xs);
align-items: center;
}
.body-report-section__change-icon {width: 14px;
height: 14px;
flex-shrink: 0;
display: block;
}
.body-report-section__metric-value-2 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--success-green);
white-space: nowrap;
}
@@ -0,0 +1,215 @@
.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%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.booking-section__header-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.booking-section__title {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark);
flex-shrink: 0;
white-space: nowrap;
}
.booking-section__link {
flex-shrink: 0;
}
.booking-section__item {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
border-radius: 14px;
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
background-color: var(--bg-white, #ffffff);
}
.booking-section__item-inner {
display: flex;
flex-direction: row;
gap: 12px;
align-items: center;
padding: 14px;
width: 100%;
position: relative;
}
.booking-section__date {
width: 48px;
height: 56px;
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border-radius: 10px;
background-color: rgba(255, 243, 238, 1);
}
.booking-section__date-inner {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.booking-section__num {
font-size: var(--font-size-2xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--accent-orange);
line-height: 1;
}
.booking-section__date-sub {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: var(--font-weight-regular);
color: var(--accent-orange-light);
}
.booking-section__content {
width: 100%;
height: auto;
flex-shrink: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
}
.booking-section__content-inner {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
align-items: flex-start;
width: 100%;
}
.booking-section__desc {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: var(--font-weight-medium);
color: var(--text-dark);
}
.booking-section__meta {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
}
.booking-section__meta-inner {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
width: 100%;
}
.booking-section__icon-coach {width: 12px;
height: 12px;
flex-shrink: 0;
display: block;
}
.booking-section__coach {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: var(--font-weight-regular);
color: var(--text-muted);
}
.booking-section__icon-location {width: 12px;
height: 12px;
flex-shrink: 0;
display: block;
}
.booking-section__text {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: var(--font-weight-regular);
color: var(--text-muted);
}
.booking-section__status-wrap {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.booking-section__status-badge {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 3px 8px;
border-radius: 6px;
}
.booking-section__status-badge--booked {
background-color: var(--success-green);
}
.booking-section__status-badge--pending {
background-color: rgba(255, 243, 238, 1);
border: 1px solid rgba(212, 166, 74, 1);
}
.booking-section__status-text {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: var(--font-weight-medium);
color: var(--text-inverse);
line-height: 1.2;
}
.booking-section__status-text--pending {
color: var(--accent-orange);
}
@@ -0,0 +1,186 @@
.checkin-section {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.checkin-section__inner {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
width: 100%;
position: relative;
}
.checkin-section__header {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.checkin-section__header-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.checkin-section__title {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: var(--font-weight-bold);
color: var(--text-dark);
}
.checkin-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;
}
.checkin-section__list-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.checkin-section__row {
width: 100%;
display: flex;
flex-direction: column;
}
.checkin-section__item {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
}
.checkin-section__item-inner {
display: flex;
flex-direction: row;
gap: 12px;
align-items: center;
padding: 14px var(--spacing-md);
width: 100%;
box-sizing: border-box;
}
.checkin-section__dot {
width: 8px;
height: 8px;
flex-shrink: 0;
border-radius: var(--radius-full);
background-color: var(--success-green);
}
.checkin-section__dot--group {
background-color: var(--success-green);
}
.checkin-section__dot--free {
background-color: var(--accent-orange);
}
.checkin-section__dot--private {
background-color: var(--primary-deep);
}
.checkin-section__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.checkin-section__content-inner {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
align-items: flex-start;
width: 100%;
}
.checkin-section__desc {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: var(--font-weight-medium);
color: var(--text-dark);
line-height: 1.3;
}
.checkin-section__text {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: var(--font-weight-regular);
color: var(--text-light);
line-height: 1.3;
}
.checkin-section__tag-badge {
flex-shrink: 0;
margin-left: var(--spacing-sm);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 3px var(--spacing-sm);
border-radius: 6px;
background-color: rgba(240, 250, 245, 1);
}
.checkin-section__tag-badge--group {
background-color: rgba(240, 250, 245, 1);
}
.checkin-section__tag-badge--free {
background-color: rgba(255, 243, 238, 1);
}
.checkin-section__tag-badge--private {
background-color: rgba(235, 243, 250, 1);
}
.checkin-section__tag-text {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: var(--font-weight-medium);
color: var(--success-green);
line-height: 1.2;
}
.checkin-section__tag-text--group {
color: var(--success-green);
}
.checkin-section__tag-text--free {
color: var(--accent-orange);
}
.checkin-section__tag-text--private {
color: var(--primary-deep);
}
.checkin-section__divider {
height: 1px;
background-color: var(--border-light);
margin-left: calc(var(--spacing-md) + 8px + 12px);
margin-right: var(--spacing-md);
}
@@ -0,0 +1,99 @@
/* 组件根节点:锁定浅色变量 + box-sizing(小程序组件内继承 page 的 theme-light 不稳定) */
.status-bar,
.profile-header,
.member-card-section,
.quick-actions,
.booking-section,
.checkin-section,
.body-report-section,
.coupon-section,
.referral-section,
.settings-section,
.logout-btn__border-wrap,
.logout-section {
box-sizing: border-box;
--primary-dark: #0B2B4B;
--primary-deep: #1A4A6F;
--primary-light: #2C6288;
--accent-orange: #FF6B35;
--accent-orange-light: #FF8C5A;
--accent-orange-dark: #E55A2B;
--bg-light: #F9FAFE;
--bg-white: #FFFFFF;
--bg-gray: #F2F5F9;
--text-dark: #1E2A3A;
--text-muted: #5E6F8D;
--text-light: #8A99B4;
--text-inverse: #FFFFFF;
--border-light: #E9EDF2;
--border-focus: #FF6B35;
--success-green: #2ECC71;
--warning-amber: #F39C12;
--error-red: #E74C3C;
--info-blue: #3498DB;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--radius-sm: 12px;
--radius-md: 20px;
--radius-lg: 28px;
--radius-full: 999px;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-size-xs: 11px;
--font-size-sm: 12px;
--font-size-base: 14px;
--font-size-md: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 22px;
--font-size-3xl: 24px;
--font-size-4xl: 28px;
--font-size-5xl: 32px;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-bold: 700;
--shadow-sm: 0 8px 20px rgba(0, 0, 0, 0.03), 0 2px 6px rgba(0, 0, 0, 0.05);
}
/* 区块标题右侧操作链接:查看全部 / 全部记录 / 历史数据 / 推荐记录 */
.member-card-section__link-text,
.booking-section__view-all,
.checkin-section__view-all,
.coupon-section__view-all,
.body-report-section__history-link,
.referral-section__records-link {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 400;
color: var(--accent-orange);
white-space: nowrap;
flex-shrink: 0;
line-height: 1.4;
}
.member-card-section__link,
.booking-section__link,
.checkin-section__link,
.coupon-section__link,
.body-report-section__link,
.referral-section__link {
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.member-card-section__link-arrow,
.booking-section__link-arrow,
.checkin-section__link-arrow,
.coupon-section__link-arrow,
.body-report-section__link-arrow,
.referral-section__link-arrow {
width: 14px;
height: 14px;
flex-shrink: 0;
display: block;
}
@@ -0,0 +1,243 @@
@import '@/common/style/memberInfo/member-info-gradient-cards.css';
.coupon-section {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.coupon-section__inner {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
width: 100%;
position: relative;
}
.coupon-section__header {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.coupon-section__header-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.coupon-section__title {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.coupon-section__link {
width: auto;
height: auto;
position: relative;
}
.coupon-section__view-all {
width: auto;
height: auto;
position: relative;
flex-grow: 0;
}
.coupon-section__link-arrow {
position: relative;
}
.coupon-section__cards {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
}
.coupon-section__cards-inner {
display: flex;
flex-direction: row;
gap: 10px;
align-items: flex-start;
width: 100%;
position: relative;
}
.coupon-section__coupon {
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
border-radius: var(--radius-sm);
}
.coupon-section__coupon-inner {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
align-items: flex-start;
justify-content: center;
padding: 14px 14px 14px 14px;
width: 100%;
position: relative;
}
.coupon-section__amount {
font-size: var(--font-size-3xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.coupon-section__desc {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.coupon-section__coupon-status {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 2px 6px 2px 6px;
border-radius: 6px 6px 6px 6px;
background-color: rgba(255, 255, 255, 0.1882352977991104);
}
.coupon-section__status {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.coupon-section__points {
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
border-radius: var(--radius-sm);
}
.coupon-section__points-inner {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
align-items: flex-start;
justify-content: center;
padding: 14px 14px 14px 14px;
width: 100%;
position: relative;
}
.coupon-section__num {
font-size: var(--font-size-3xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.coupon-section__points-label {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.coupon-section__points-action {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 2px 6px 2px 6px;
border-radius: 6px 6px 6px 6px;
background-color: rgba(255, 255, 255, 0.1882352977991104);
}
.coupon-section__text {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
@@ -0,0 +1,31 @@
/**
* 与个人中心首页 coupon-section 一致的渐变卡片背景
* 使用 SVG 背景,兼容微信小程序(CSS 变量渐变在部分端不生效)
*/
.mi-gradient-blue,
.coupon-section__points,
.mi-mod-points-hero,
.bt-hero,
.bt-score-card,
.mi-mod-referral-hero {
background-color: #0B2B4B;
background-position: center;
background-size: cover;
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20174%20106'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(11%2C43%2C75%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(26%2C74%2C111%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
box-shadow: 0px 4px 8px 0px rgba(11, 43, 75, 0.25);
}
.mi-gradient-orange,
.coupon-section__coupon,
.mi-mod-coupon__left,
.mi-mod-coupon__use,
.mi-center-coupon__btn:not(.mi-center-coupon__btn--done),
.bt-page__action-link--primary,
.bt-btn--primary {
background-color: #FF6B35;
background-position: center;
background-size: cover;
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20174%20106'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(255%2C107%2C53%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(255%2C140%2C90%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
box-shadow: 0px 4px 8px 0px rgba(255, 107, 53, 0.25);
}
@@ -0,0 +1,331 @@
.profile-header {
width: 100%;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
/* 顶栏固定:仅白底导航栏吸顶,下方用户信息可滚动 */
.profile-header__toolbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
width: 100%;
background-color: var(--bg-white, #ffffff);
box-sizing: border-box;
}
.profile-header__toolbar-spacer {
width: 100%;
flex-shrink: 0;
}
.profile-header__nav {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
height: 44px;
padding-left: 16px;
box-sizing: border-box;
}
.profile-header__nav-left {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
min-width: 56px;
flex-shrink: 0;
position: relative;
z-index: 2;
}
.profile-header__nav-right {
min-width: 72px;
flex-shrink: 0;
position: relative;
z-index: 2;
margin-left: auto;
}
.profile-header__title {
position: absolute;
left: 50%;
transform: translateX(-50%);
max-width: 42%;
text-align: center;
font-size: var(--font-size-md, 16px);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
z-index: 1;
}
.profile-header__icon-bell,
.profile-header__icon-settings {
width: 22px;
height: 22px;
flex-shrink: 0;
display: block;
}
/* settings.png 为白色线稿,白底顶栏需着色后才可见 */
.profile-header__icon-settings {
filter: brightness(0) saturate(100%) invert(52%) sepia(98%) saturate(1800%) hue-rotate(346deg) brightness(102%) contrast(101%);
}
/* 用户信息渐变区 */
.profile-header__hero {
width: 100%;
background-position: center;
background-size: 100% 100%;
background-repeat: no-repeat;
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20390%20239'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.0000%2C%201.0000%2C%20-1.0000%2C%20-0.0000%2C%201.0000%2C%200.0000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(11%2C43%2C75%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(26%2C74%2C111%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
}
.profile-header__inner {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 16px 20px 28px;
width: 100%;
box-sizing: border-box;
}
.profile-header__user {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
}
.profile-header__user-inner {
display: flex;
flex-direction: row;
gap: 14px;
align-items: center;
width: 100%;
}
.profile-header__avatar-wrap {
position: relative;
width: 72px;
height: 72px;
flex-shrink: 0;
overflow: visible;
}
.profile-header__avatar-ring {
width: 72px;
height: 72px;
border-radius: 50%;
border: 3px solid #ffffff;
overflow: hidden;
box-sizing: border-box;
background-color: rgba(255, 255, 255, 0.12);
}
.profile-header__avatar {
width: 100%;
height: 100%;
display: block;
}
.profile-header__avatar-badge {
position: absolute;
right: -1px;
bottom: -1px;
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid #ffffff;
background-color: #2ecc71;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
box-sizing: border-box;
}
.profile-header__avatar-badge-icon {
width: 12px;
height: 12px;
display: block;
flex-shrink: 0;
filter: brightness(0) invert(1);
}
.profile-header__user-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.profile-header__user-meta-inner {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 4px);
align-items: flex-start;
width: 100%;
}
.profile-header__name {
font-size: var(--font-size-xl, 20px);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
white-space: nowrap;
}
.profile-header__phone {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
white-space: nowrap;
}
.profile-header__badge {
display: flex;
flex-direction: row;
gap: var(--spacing-xs, 4px);
align-items: center;
padding: 3px 10px;
border-radius: 10px;
background-color: var(--accent-orange);
}
.profile-header__badge-icon {
width: 12px;
height: 12px;
flex-shrink: 0;
display: block;
}
.profile-header__level {
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-inverse);
white-space: nowrap;
}
.profile-header__stats {
width: 100%;
}
.profile-header__stats-inner {
display: flex;
flex-direction: row;
align-items: flex-start;
padding-top: 16px;
width: 100%;
}
.profile-header__stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
.profile-header__stat-inner {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
width: 100%;
}
.profile-header__stat-value {
font-size: var(--font-size-2xl, 22px);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
white-space: nowrap;
}
.profile-header__stat-label {
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
white-space: nowrap;
}
.profile-header__stat-divider {
width: 1px;
height: 32px;
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;
}
@@ -0,0 +1,40 @@
.logout-section {
width: 100%;
box-sizing: border-box;
}
.logout-section__btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 50px;
padding: 14px 16px;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
background-color: #ffffff;
box-sizing: border-box;
}
.logout-section__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
display: block;
}
.logout-section__text {
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-weight: 500;
color: #e74c3c;
line-height: 1;
white-space: nowrap;
}
.mi-tap-btn--hover {
opacity: 0.85;
transform: scale(0.98);
}
@@ -0,0 +1,443 @@
.member-card-section {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.member-card-section__inner {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
width: 100%;
position: relative;
}
.member-card-section__head {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.member-card-section__head-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.member-card-section__title {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.member-card-section__link {
width: auto;
height: auto;
position: relative;
}
.member-card-section__link-text {
white-space: nowrap;
}
.member-card-section__link-arrow {
position: relative;
}
.member-card-preview {
width: 100%;
min-height: 140px;
height: auto;
overflow: visible;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: stretch;
border-radius: 16px;
box-shadow: 0px 8px 16px 0px rgba(255, 107, 53, 0.25);
background-position: center;
background-size: 100% 100%;
box-sizing: border-box;
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20358%20140'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(255%2C107%2C53%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(255%2C140%2C90%2C1)'%20offset%3D'0.6000000238418579'%2F%3E%3Cstop%20stop-color%3D'rgba(229%2C90%2C43%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
}
.member-card-preview__inner {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
padding: 20px 16px 16px;
width: 100%;
box-sizing: border-box;
position: relative;
}
.member-card-preview__head {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.member-card-preview__head-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
position: relative;
}
.member-card-preview__type-row {
flex: 1;
min-width: 0;
display: flex;
flex-direction: row;
gap: 6px;
align-items: center;
}
.member-card-preview__icon-wrap {
width: 18px;
height: 18px;
position: relative;
flex-shrink: 0;
}
.member-card-preview__icon-bg {
width: 100%;
height: 100%;
position: relative;
border-radius: 1.5px 1.5px 1.5px 1.5px;
}
.member-card-preview__icon-border {
position: absolute;
left: 8.33%;
top: 20.83%;
right: 8.33%;
bottom: 20.83%;
width: 83.34%;
height: 58.34%;
}
.member-card-preview__icon-stroke {
position: absolute;
inset: -0.75px -0.75px -0.75px -0.75px;
border-radius: 1.5px 1.5px 1.5px 1.5px;
pointer-events: none;
border-width: 1.5px 1.5px 1.5px 1.5px;
border-style: solid;
box-sizing: border-box;
border-color: var(--text-inverse);
}
.member-card-preview__icon-line {width: 83.34%;
height: 8.33%;
position: absolute;
left: 8.33%;
right: 8.33%;
top: 41.67%;
bottom: 50%;
display: block;
}
.member-card-preview__name {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-card-preview__tag {
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
padding: 3px 10px;
border-radius: 8px;
background-color: rgba(255, 255, 255, 0.19);
}
.member-card-preview__tag-text {
font-size: var(--font-size-xs);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.member-card-preview__expire {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.member-card-preview__footer {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background-color: transparent;
}
.member-card-preview__footer-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
position: relative;
}
.member-card-preview__days {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
gap: var(--spacing-xs);
align-items: flex-start;
}
.member-card-preview__days-num {
font-size: var(--font-size-4xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.member-card-preview__days-unit {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.member-card-preview__renew {
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
padding: 6px 14px;
border-radius: 14px;
background-color: var(--bg-white);
}
.member-card-preview__renew-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;
}
.member-card-tip__inner {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
border-radius: 10px 10px 10px 10px;
background-color: rgba(255, 243, 238, 1);
}
.member-card-tip__content {
display: flex;
flex-direction: row;
gap: 6px;
align-items: center;
padding: 8px 12px 8px 12px;
width: 100%;
position: relative;
}
.member-card-tip {
position: relative;
width: 100%;
height: auto;
display: flex;
flex-shrink: 0;
}
.member-card-tip__border {
position: absolute;
inset: 0px;
border-radius: 10px 10px 10px 10px;
pointer-events: none;
border-width: 1px 1px 1px 1px;
border-style: solid;
box-sizing: border-box;
border-color: rgba(255, 204, 170, 1);
}
.member-card-tip__icon {width: 14px;
height: 14px;
position: relative;
flex-shrink: 0;
display: block;
}
.member-card-tip__text {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--accent-orange);
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
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;
}
@@ -0,0 +1,88 @@
.scroll-container {
height: 100%;
width: 100%;
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
background: var(--gradient-sky);
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-size-xs: 11px;
--font-size-sm: 12px;
--font-size-base: 14px;
--font-size-md: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 22px;
--font-size-3xl: 24px;
--font-size-4xl: 28px;
--font-size-5xl: 32px;
}
.scroll-container > view {
width: 100%;
}
.member-page {
width: 100%;
min-height: 100%;
height: auto;
overflow: visible;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: stretch;
background: var(--gradient-sky);
box-sizing: border-box;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
}
.member-page__body {
width: 100%;
height: auto;
flex: 1;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: stretch;
}
.member-page__sections {
display: flex;
flex-direction: column;
gap: 12px;
align-items: stretch;
padding: var(--spacing-md, 16px);
padding-bottom: calc(var(--spacing-md, 16px) + env(safe-area-inset-bottom));
width: 100%;
box-sizing: border-box;
position: relative;
font-family: var(--font-family);
}
.member-page__sections text {
font-family: var(--font-family);
}
/* ????????? */
.status-bar,
.profile-header,
.member-card-section,
.quick-actions,
.booking-section,
.checkin-section,
.body-report-section,
.coupon-section,
.referral-section,
.settings-section,
.logout-btn__border-wrap,
.logout-section {
width: 100%;
box-sizing: border-box;
}
@@ -0,0 +1,226 @@
.quick-actions {
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
border-radius: 16px;
box-shadow: 0px 2px 12px 0px rgba(26, 25, 24, 0.03137254901960784);
background-color: var(--bg-white, #ffffff);
overflow: hidden;
}
.quick-actions__inner {
display: flex;
flex-direction: column;
width: 100%;
}
.quick-actions__grid {
width: 100%;
display: flex;
flex-direction: row;
}
.quick-actions__grid-inner {
display: flex;
flex-direction: row;
width: 100%;
}
.quick-actions__item {
flex: 1;
height: 80px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-width: 0;
}
.quick-actions__item-inner {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.quick-actions__icon-wrap {
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
border-radius: var(--radius-sm);
}
.quick-actions__icon-wrap-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.quick-actions__icon {
width: 20px;
height: 20px;
position: relative;
}
.quick-actions__icon-part {
position: absolute;
display: block;
}
.quick-actions__icon-part:nth-child(1) {
width: 7.69%;
height: 16.67%;
left: 30.77%;
top: 8.33%;
}
.quick-actions__icon-part:nth-child(2) {
width: 7.69%;
height: 16.67%;
left: 61.54%;
top: 8.33%;
}
.quick-actions__icon-part:nth-child(4) {
width: 7.69%;
height: 16.67%;
left: 30.77%;
top: 58.33%;
}
.quick-actions__icon-part:nth-child(5) {
width: 7.69%;
height: 16.67%;
left: 61.54%;
top: 58.33%;
}
.quick-actions__border-wrap {
position: absolute;
left: 12.5%;
top: 25%;
width: 75%;
height: 50%;
}
.quick-actions__rect {
width: 100%;
height: 100%;
border-radius: 2px;
}
.quick-actions__border {
position: absolute;
inset: -1px;
border-radius: 2px;
border: 1px solid var(--accent-orange);
pointer-events: none;
}
.quick-actions__icon-img {width: 20px;
height: 20px;
display: block;
}
.quick-actions__title,
.quick-actions__title-2,
.quick-actions__title-3,
.quick-actions__title-4,
.quick-actions__coach,
.quick-actions__text,
.quick-actions__text-2,
.quick-actions__points-desc {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-dark);
text-align: center;
}
.quick-actions__divider {
width: 100%;
height: 1px;
background-color: var(--border-light, #e9edf2);
}
/* 第?????*/
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(1),
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(1) .quick-actions__icon-wrap {
background-color: rgba(255, 243, 238, 1);
}
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(2),
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(2) .quick-actions__icon-wrap {
background-color: rgba(240, 250, 245, 1);
}
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(2) .quick-actions__icon-img {
}
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(3),
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(3) .quick-actions__icon-wrap {
background-color: rgba(235, 243, 250, 1);
}
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(3) .quick-actions__icon-img {
}
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(4),
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(4) .quick-actions__icon-wrap {
background-color: rgba(255, 243, 238, 1);
}
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(4) .quick-actions__icon-img {
}
/* 第?????*/
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(1),
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(1) .quick-actions__icon-wrap {
background-color: rgba(255, 236, 236, 1);
}
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(1) .quick-actions__icon-img {
}
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(2),
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(2) .quick-actions__icon-wrap {
background-color: rgba(255, 243, 238, 1);
}
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(2) .quick-actions__icon-img {
}
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(3),
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(3) .quick-actions__icon-wrap {
background-color: rgba(240, 250, 245, 1);
}
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(3) .quick-actions__icon-img {
}
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(4),
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(4) .quick-actions__icon-wrap {
background-color: rgba(235, 243, 250, 1);
}
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(4) .quick-actions__icon-img {
}
@@ -0,0 +1,211 @@
.referral-section {
width: 100%;
max-width: 100%;
box-sizing: border-box;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
background-color: var(--bg-white, #ffffff);
overflow: hidden;
}
.referral-section__inner {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
width: 100%;
box-sizing: border-box;
}
.referral-section__header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.referral-section__title {
font-size: var(--font-size-md, 16px);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark);
flex-shrink: 0;
}
.referral-section__link {
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.referral-section__records-link {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 400;
color: var(--accent-orange);
line-height: 1.4;
white-space: nowrap;
}
.referral-section__link-arrow {
width: 14px;
height: 14px;
display: block;
flex-shrink: 0;
}
/* 推荐码行:grid 避免小程序 flex 宽度计算异常 */
.referral-section__code-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 10px;
width: 100%;
box-sizing: border-box;
}
.referral-section__code-box {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 10px 12px;
border-radius: 8px;
background-color: #f2f5f9;
box-sizing: border-box;
overflow: hidden;
}
.referral-section__code-label {
display: block;
font-size: var(--font-size-xs, 11px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
line-height: 1.4;
}
.referral-section__code-value {
display: block;
width: 100%;
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 700;
color: var(--primary-dark);
line-height: 1.3;
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.referral-section__copy-btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
height: 100%;
min-height: 52px;
padding: 0 14px;
border-radius: 10px;
background-color: var(--accent-orange);
box-sizing: border-box;
}
/* 例图:双矩形复制图标 */
.referral-section__copy-icon {
position: relative;
width: 14px;
height: 14px;
flex-shrink: 0;
}
.referral-section__copy-sheet {
position: absolute;
width: 9px;
height: 9px;
border: 1.5px solid #ffffff;
border-radius: 2px;
box-sizing: border-box;
}
.referral-section__copy-sheet--back {
right: 0;
bottom: 0;
opacity: 0.85;
}
.referral-section__copy-sheet--front {
left: 0;
top: 0;
background-color: rgba(255, 255, 255, 0.2);
}
.referral-section__copy-text {
display: block;
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-inverse);
line-height: 1.4;
white-space: nowrap;
}
.referral-section__stats {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
box-sizing: border-box;
}
.referral-section__stat {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.referral-section__stat-num {
display: block;
font-size: var(--font-size-lg, 18px);
font-family: var(--font-family);
font-weight: 700;
line-height: 1.2;
}
.referral-section__stat-num--orange {
color: var(--accent-orange);
}
.referral-section__stat-num--green {
color: var(--success-green);
}
.referral-section__stat-num--amber {
color: #f39c12;
}
.referral-section__stat-label {
display: block;
font-size: var(--font-size-xs, 11px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
line-height: 1.4;
white-space: nowrap;
}
.referral-section__stat-divider {
width: 1px;
height: 28px;
flex-shrink: 0;
background-color: var(--border-light);
}
@@ -0,0 +1,166 @@
.settings-section {
width: 100%;
box-sizing: border-box;
--bg-white: #ffffff;
--text-dark: #1e2a3a;
--text-light: #8a99b4;
--error-red: #e74c3c;
--success-green: #2ecc71;
--border-light: #e9edf2;
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-size-xs: 11px;
--font-size-base: 14px;
--font-size-md: 16px;
--font-weight-medium: 500;
--font-weight-bold: 700;
}
.settings-section__inner {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.settings-section__title {
display: block;
font-size: 16px;
font-family: var(--font-family);
font-weight: 700;
color: #1e2a3a;
line-height: 1.4;
}
.settings-section__list {
width: 100%;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
background-color: #ffffff;
overflow: hidden;
}
.settings-section__list-inner {
display: flex;
flex-direction: column;
width: 100%;
}
.settings-section__item {
width: 100%;
min-height: 52px;
display: flex;
flex-direction: row;
align-items: center;
background-color: #ffffff;
}
.settings-section__item--tall {
min-height: 60px;
}
.settings-section__item-inner {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
padding: 12px 16px;
width: 100%;
box-sizing: border-box;
}
.settings-section__item-icon-wrap {
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background-color: rgba(255, 243, 238, 1);
}
.settings-section__item-icon-wrap--blue {
background-color: rgba(235, 243, 250, 1);
}
.settings-section__item-icon-wrap--green {
background-color: rgba(240, 250, 245, 1);
}
.settings-section__item-icon-wrap--red {
background-color: rgba(255, 236, 236, 1);
}
.settings-section__item-icon-wrap-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.settings-section__item-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
display: block;
}
.settings-section__item-label {
flex: 1;
min-width: 0;
display: block;
font-size: 14px;
font-family: var(--font-family);
font-weight: 500;
color: #1e2a3a;
line-height: 1.4;
}
.settings-section__item-label--danger {
color: #e74c3c;
}
.settings-section__item-texts {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.settings-section__item-title {
display: block;
font-size: 14px;
font-family: var(--font-family);
font-weight: 500;
color: #1e2a3a;
line-height: 1.4;
}
.settings-section__item-desc {
display: block;
font-size: 11px;
font-family: var(--font-family);
font-weight: 400;
color: #2ecc71;
line-height: 1.3;
}
.settings-section__item-arrow {
width: 16px;
height: 16px;
flex-shrink: 0;
display: block;
}
.settings-section__item-divider {
width: 100%;
height: 1px;
background-color: #e9edf2;
}
.mi-tap-row--hover {
opacity: 0.72;
}
@@ -0,0 +1,48 @@
.status-bar {
width: 100%;
height: 62px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background-color: var(--primary-dark);
}
.status-bar__inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0px 20px 0px 20px;
width: 100%;
height: 100%;
position: relative;
}
.status-bar__time {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.status-bar__icons {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
@@ -0,0 +1,123 @@
.sub-nav {
width: 100%;
display: flex;
flex-direction: column;
flex-shrink: 0;
box-sizing: border-box;
}
.sub-nav__toolbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
width: 100%;
background-color: #ffffff;
box-sizing: border-box;
border-bottom: 1px solid #e9edf2;
}
.sub-nav__spacer {
width: 100%;
flex-shrink: 0;
}
.sub-nav__nav {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
height: 44px;
padding-left: 12px;
padding-right: 12px;
box-sizing: border-box;
width: 100%;
}
.sub-nav__back {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
min-width: 32px;
border-radius: 8px;
background-color: #f9fafe;
flex-shrink: 0;
position: relative;
z-index: 2;
}
.sub-nav__back-icon {
width: 20px;
height: 20px;
max-width: 20px;
max-height: 20px;
display: block;
flex-shrink: 0;
}
.sub-nav__title {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
max-width: 50%;
text-align: center;
font-size: 16px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-weight: 600;
color: #1e2a3a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
z-index: 1;
line-height: 1.2;
}
.sub-nav__right {
display: flex;
flex-direction: row;
align-items: center;
margin-left: auto;
flex-shrink: 0;
position: relative;
z-index: 2;
}
.sub-nav__action {
flex-shrink: 0;
margin-right: 4px;
}
.sub-nav__action-text {
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-weight: 500;
color: #ff6b35;
white-space: nowrap;
}
.sub-nav__action--button {
padding: 6px 14px;
border-radius: 8px;
background-color: #ff6b35;
}
.sub-nav__action--button .sub-nav__action-text {
color: #ffffff;
font-weight: 600;
}
.sub-nav__capsule {
flex-shrink: 0;
min-width: 0;
}
.sub-nav__capsule--h5 {
width: 0 !important;
min-width: 0 !important;
overflow: hidden;
}
@@ -0,0 +1,36 @@
/* 小程序点击反馈(配合 hover-class 使用) */
.mi-tap--hover,
.mi-tap--scale,
.mi-tap-card--hover,
.mi-tap-btn--hover,
.mi-tap-tab--hover,
.mi-tap-save--hover {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.mi-tap--scale {
transform: scale(0.97);
}
.mi-tap-card--hover {
opacity: 0.92;
transform: scale(0.995);
}
.mi-tap-btn--hover {
opacity: 0.85;
transform: scale(0.98);
}
.mi-tap-tab--hover {
opacity: 0.75;
}
.mi-tap-row--hover {
background-color: rgba(249, 250, 254, 0.95);
}
.mi-tap-save--hover {
opacity: 0.88;
transform: scale(0.99);
}
@@ -0,0 +1,860 @@
/* 智能体测模块 - 公共样式(基于 base.css 变量) */
@import '@/common/style/memberInfo/member-info-gradient-cards.css';
.bt-page {
width: 100%;
min-height: 100%;
box-sizing: border-box;
overflow-x: hidden;
background-color: var(--bg-light, #F9FAFE);
}
.bt-page__body {
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 12px);
padding: var(--spacing-md, 16px) var(--spacing-md, 16px) 40px;
box-sizing: border-box;
}
.bt-card {
width: 100%;
padding: 16px;
border-radius: var(--radius-md, 20px);
background-color: var(--bg-white, #fff);
box-shadow: var(--shadow-sm, 0 8px 20px rgba(0, 0, 0, 0.03));
box-sizing: border-box;
}
.bt-card__title {
font-size: var(--font-size-md, 16px);
font-weight: 700;
color: var(--text-dark, #1E2A3A);
margin-bottom: 12px;
display: block;
}
.bt-card__desc {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
line-height: 1.5;
display: block;
}
.bt-hero {
padding: 20px;
border-radius: var(--radius-sm, 12px);
display: flex;
flex-direction: column;
gap: 12px;
}
.bt-hero__top {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
}
.bt-hero__label {
font-size: var(--font-size-sm, 12px);
color: rgba(255, 255, 255, 0.75);
}
.bt-hero__badge {
padding: 4px 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.18);
}
.bt-hero__badge-text {
font-size: var(--font-size-sm, 12px);
color: var(--text-inverse, #fff);
}
.bt-hero__score-row {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 8px;
}
.bt-hero__score {
font-size: 48px;
font-weight: 800;
color: var(--text-inverse, #fff);
line-height: 1;
}
.bt-hero__grade {
font-size: var(--font-size-xl, 20px);
font-weight: 700;
color: var(--accent-orange-light, #FF8C5A);
}
.bt-hero__meta {
font-size: var(--font-size-sm, 12px);
color: rgba(255, 212, 184, 1);
}
.bt-hero__actions {
display: flex;
flex-direction: row;
gap: 10px;
margin-top: 4px;
}
.bt-btn {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 16px;
border-radius: var(--radius-full, 999px);
box-sizing: border-box;
}
.bt-btn--primary {
box-shadow: var(--shadow-orange-glow, 0 4px 12px rgba(255, 107, 53, 0.25));
}
.bt-btn--ghost {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.bt-btn--outline {
background: var(--bg-white, #fff);
border: 1px solid var(--border-light, #E9EDF2);
}
.bt-btn__text {
font-size: var(--font-size-base, 14px);
font-weight: 600;
}
.bt-btn--primary .bt-btn__text,
.bt-btn--ghost .bt-btn__text {
color: var(--text-inverse, #fff);
}
.bt-btn--outline .bt-btn__text {
color: var(--primary-dark, #0B2B4B);
}
.bt-btn__icon {
width: 16px;
height: 16px;
}
.bt-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.bt-grid__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 4px;
border-radius: var(--radius-sm, 12px);
background: var(--bg-gray, #F2F5F9);
}
.bt-grid__icon {
width: 22px;
height: 22px;
}
.bt-grid__label {
font-size: 11px;
color: var(--text-muted, #5E6F8D);
text-align: center;
}
.bt-device {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
}
.bt-device__icon-wrap {
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--bg-gray, #F2F5F9);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.bt-device__icon {
width: 22px;
height: 22px;
}
.bt-device__info {
flex: 1;
min-width: 0;
}
.bt-device__name {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.bt-device__status {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
display: block;
margin-top: 2px;
}
.bt-device__status--on {
color: var(--success-green, #2ECC71);
}
.bt-device__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-light, #8A99B4);
flex-shrink: 0;
}
.bt-device__dot--on {
background: var(--success-green, #2ECC71);
}
.bt-metrics {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.bt-metric {
padding: 14px;
border-radius: var(--radius-sm, 12px);
background: var(--bg-gray, #F2F5F9);
display: flex;
flex-direction: column;
gap: 4px;
}
.bt-metric__value {
font-size: var(--font-size-xl, 20px);
font-weight: 700;
color: var(--text-dark, #1E2A3A);
}
.bt-metric__label {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
}
.bt-metric__change {
font-size: 11px;
font-weight: 600;
}
.bt-metric__change--down {
color: var(--success-green, #2ECC71);
}
.bt-metric__change--up {
color: var(--warning-amber, #F39C12);
}
.bt-steps {
display: flex;
flex-direction: column;
gap: 0;
}
.bt-step {
display: flex;
flex-direction: row;
gap: 12px;
padding: 12px 0;
}
.bt-step__num {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--primary-dark, #0B2B4B);
color: var(--text-inverse, #fff);
font-size: var(--font-size-sm, 12px);
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.bt-step__content {
flex: 1;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.bt-step:last-child .bt-step__content {
border-bottom: none;
padding-bottom: 0;
}
.bt-step__title {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.bt-step__desc {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
margin-top: 4px;
display: block;
line-height: 1.5;
}
.bt-measure {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 16px;
gap: 16px;
}
.bt-measure__ring-wrap {
position: relative;
width: 180px;
height: 180px;
}
.bt-measure__ring-bg {
width: 180px;
height: 180px;
border-radius: 50%;
border: 10px solid var(--border-light, #E9EDF2);
box-sizing: border-box;
}
.bt-measure__ring-fill {
position: absolute;
top: 0;
left: 0;
width: 180px;
height: 180px;
border-radius: 50%;
border: 10px solid transparent;
border-top-color: var(--accent-orange, #FF6B35);
border-right-color: var(--accent-orange, #FF6B35);
box-sizing: border-box;
transform: rotate(-90deg);
transition: transform 0.3s ease;
}
.bt-measure__center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.bt-measure__percent {
font-size: 36px;
font-weight: 800;
color: var(--primary-dark, #0B2B4B);
display: block;
}
.bt-measure__hint {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
}
.bt-measure__live {
width: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.bt-measure__live-item {
padding: 12px;
border-radius: var(--radius-sm, 12px);
background: var(--bg-gray, #F2F5F9);
text-align: center;
}
.bt-measure__live-value {
font-size: var(--font-size-lg, 18px);
font-weight: 700;
color: var(--text-dark, #1E2A3A);
display: block;
}
.bt-measure__live-label {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
margin-top: 2px;
display: block;
}
.bt-score-card {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: var(--radius-sm, 12px);
}
.bt-score-card__circle {
width: 72px;
height: 72px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.bt-score-card__num {
font-size: 28px;
font-weight: 800;
color: var(--text-inverse, #fff);
line-height: 1;
}
.bt-score-card__grade {
font-size: 11px;
color: var(--accent-orange-light, #FF8C5A);
font-weight: 600;
}
.bt-score-card__info {
flex: 1;
}
.bt-score-card__title {
font-size: var(--font-size-md, 16px);
font-weight: 700;
color: var(--text-inverse, #fff);
display: block;
}
.bt-score-card__date {
font-size: var(--font-size-sm, 12px);
color: rgba(255, 255, 255, 0.7);
margin-top: 4px;
display: block;
}
.bt-body-map {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 8px 0;
}
.bt-body-map__figure {
width: 120px;
height: 200px;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.bt-body-map__head {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--primary-light, #2C6288);
opacity: 0.6;
}
.bt-body-map__torso {
width: 56px;
height: 70px;
border-radius: 12px;
background: var(--primary-deep, #1A4A6F);
margin-top: 4px;
}
.bt-body-map__limbs {
display: flex;
flex-direction: row;
gap: 48px;
margin-top: -60px;
}
.bt-body-map__arm {
width: 16px;
height: 50px;
border-radius: 8px;
background: var(--primary-light, #2C6288);
opacity: 0.7;
}
.bt-body-map__legs {
display: flex;
flex-direction: row;
gap: 12px;
margin-top: 4px;
}
.bt-body-map__leg {
width: 22px;
height: 60px;
border-radius: 8px;
background: var(--primary-light, #2C6288);
opacity: 0.7;
}
.bt-body-map__segments {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
.bt-body-map__seg {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 8px;
background: var(--bg-gray, #F2F5F9);
}
.bt-body-map__seg--high {
border-left: 3px solid var(--accent-orange, #FF6B35);
}
.bt-body-map__seg--low {
border-left: 3px solid var(--info-blue, #3498DB);
}
.bt-body-map__seg-name {
font-size: var(--font-size-sm, 12px);
color: var(--text-dark, #1E2A3A);
}
.bt-body-map__seg-val {
font-size: var(--font-size-sm, 12px);
font-weight: 600;
color: var(--text-muted, #5E6F8D);
}
.bt-advice-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.bt-advice-item {
display: flex;
flex-direction: row;
gap: 8px;
align-items: flex-start;
}
.bt-advice-item__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-orange, #FF6B35);
margin-top: 6px;
flex-shrink: 0;
}
.bt-advice-item__text {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
line-height: 1.5;
flex: 1;
}
.bt-course {
display: flex;
flex-direction: row;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.bt-course:last-child {
border-bottom: none;
padding-bottom: 0;
}
.bt-course__banner {
width: 72px;
height: 72px;
border-radius: 12px;
flex-shrink: 0;
}
.bt-course__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.bt-course__tag {
font-size: 10px;
color: var(--accent-orange, #FF6B35);
font-weight: 600;
}
.bt-course__title {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.bt-course__meta {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
}
.bt-history-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.bt-history-item:last-child {
border-bottom: none;
}
.bt-history-item__date {
width: 52px;
text-align: center;
flex-shrink: 0;
}
.bt-history-item__day {
font-size: var(--font-size-xl, 20px);
font-weight: 700;
color: var(--primary-dark, #0B2B4B);
display: block;
}
.bt-history-item__month {
font-size: 10px;
color: var(--text-muted, #5E6F8D);
}
.bt-history-item__info {
flex: 1;
min-width: 0;
}
.bt-history-item__score-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
.bt-history-item__grade {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.bt-history-item__status {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
}
.bt-history-item__metrics {
font-size: 11px;
color: var(--text-light, #8A99B4);
margin-top: 2px;
}
.bt-history-item__arrow {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.bt-compare-header {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.bt-compare-picker {
padding: 12px;
border-radius: var(--radius-sm, 12px);
background: var(--bg-gray, #F2F5F9);
text-align: center;
}
.bt-compare-picker__label {
font-size: 10px;
color: var(--text-light, #8A99B4);
display: block;
}
.bt-compare-picker__date {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
margin-top: 4px;
display: block;
}
.bt-compare-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.bt-compare-row:last-child {
border-bottom: none;
}
.bt-compare-row__label {
flex: 1;
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
}
.bt-compare-row__val {
width: 60px;
text-align: center;
font-size: var(--font-size-sm, 12px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.bt-compare-row__diff {
width: 56px;
text-align: right;
font-size: 11px;
font-weight: 600;
}
.bt-setting {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.bt-setting:last-child {
border-bottom: none;
}
.bt-setting__label {
font-size: var(--font-size-base, 14px);
color: var(--text-dark, #1E2A3A);
}
.bt-setting__desc {
font-size: var(--font-size-sm, 12px);
color: var(--text-light, #8A99B4);
margin-top: 2px;
}
.bt-footer-actions {
display: flex;
flex-direction: row;
gap: 10px;
padding: 16px;
}
.bt-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 16px;
}
.bt-empty__text {
font-size: var(--font-size-base, 14px);
color: var(--text-light, #8A99B4);
}
.bt-tabs {
display: flex;
flex-direction: row;
gap: var(--spacing-sm, 8px);
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
overflow-x: auto;
box-sizing: border-box;
}
.bt-tab {
padding: 8px 14px;
border-radius: var(--radius-full, 999px);
background: var(--bg-white, #fff);
border: 1px solid var(--border-light, #E9EDF2);
flex-shrink: 0;
}
.bt-tab--active {
background: var(--primary-dark, #0B2B4B);
border-color: var(--primary-dark, #0B2B4B);
}
.bt-tab__text {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
white-space: nowrap;
}
.bt-tab--active .bt-tab__text {
color: var(--text-inverse, #fff);
font-weight: 600;
}
.bt-trend-link {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px 0 0;
margin-top: 8px;
border-top: 1px solid var(--border-light, #E9EDF2);
}
.bt-trend-link__text {
font-size: var(--font-size-sm, 12px);
color: var(--primary-deep, #1A4A6F);
font-weight: 600;
}
.bt-trend-link__arrow {
width: 14px;
height: 14px;
}
@@ -0,0 +1,295 @@
.booking-page {
width: 100%;
min-height: 100%;
box-sizing: border-box;
overflow-x: hidden;
background-color: var(--bg-light, #F9FAFE);
}
/* Tab 栏 */
.booking-page__tabs {
display: flex;
flex-direction: row;
align-items: stretch;
width: 100%;
height: 44px;
background-color: var(--bg-white, #ffffff);
border-bottom: 1px solid var(--border-light, #E9EDF2);
box-sizing: border-box;
}
.booking-page__tab {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 44px;
padding: 0 16px;
box-sizing: border-box;
}
.booking-page__tab-text {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light, #8A99B4);
white-space: nowrap;
}
.booking-page__tab-text--active {
font-weight: 700;
color: var(--accent-orange, #FF6B35);
}
.booking-page__tab-indicator {
position: absolute;
left: 16px;
right: 16px;
bottom: 0;
height: 2px;
border-radius: 2px;
background-color: var(--accent-orange, #FF6B35);
}
/* 内容区 */
.booking-page__body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 16px 40px;
box-sizing: border-box;
}
/* 提醒横幅 */
.booking-page__alert {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 204, 170, 1);
background-color: rgba(255, 243, 238, 1);
box-sizing: border-box;
}
.booking-page__alert-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
display: block;
}
.booking-page__alert-text {
flex: 1;
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 500;
color: var(--accent-orange, #FF6B35);
line-height: 1.4;
}
/* 预约卡片 */
.bk-card {
width: 100%;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
background-color: var(--bg-white, #ffffff);
overflow: hidden;
box-sizing: border-box;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.bk-card__banner {
width: 100%;
height: 80px;
display: block;
border-radius: 14px 14px 0 0;
}
.bk-card__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
box-sizing: border-box;
}
.bk-card__header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
}
.bk-card__title {
flex: 1;
min-width: 0;
font-size: var(--font-size-md, 16px);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark, #1E2A3A);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bk-card__status {
flex-shrink: 0;
padding: 4px 10px;
border-radius: 6px;
box-sizing: border-box;
}
.bk-card__status--booked {
background-color: var(--success-green, #2ECC71);
}
.bk-card__status--pending {
background-color: rgba(255, 243, 238, 1);
border: 1px solid rgba(212, 166, 74, 1);
}
.bk-card__status--completed {
background-color: var(--bg-light, #F9FAFE);
border: 1px solid var(--border-light, #E9EDF2);
}
.bk-card__status--cancelled {
background-color: var(--bg-light, #F9FAFE);
border: 1px solid var(--border-light, #E9EDF2);
}
.bk-card__status-text {
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 500;
white-space: nowrap;
}
.bk-card__status-text--booked {
color: var(--text-inverse, #ffffff);
}
.bk-card__status-text--pending {
color: var(--accent-orange, #FF6B35);
}
.bk-card__status-text--completed {
color: var(--text-muted, #5E6F8D);
}
.bk-card__status-text--cancelled {
color: var(--text-light, #8A99B4);
}
/* 时间与教练信息(分两行) */
.bk-card__meta {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.bk-card__meta-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
.bk-card__meta-icon {
width: 13px;
height: 13px;
flex-shrink: 0;
display: block;
}
.bk-card__meta-text {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted, #5E6F8D);
white-space: nowrap;
}
/* 底部操作行 */
.bk-card__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
}
.bk-card__footer-info {
flex: 1;
min-width: 0;
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light, #8A99B4);
white-space: nowrap;
overflow: hidden;
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;
border-radius: 8px;
border: 1px solid var(--border-light, #E9EDF2);
background-color: var(--bg-white, #ffffff);
box-sizing: border-box;
}
.bk-card__cancel-text {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 500;
color: var(--error-red, #E74C3C);
white-space: nowrap;
}
/* 空状态 */
.booking-page__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 16px;
}
.booking-page__empty-text {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light, #8A99B4);
}
@@ -0,0 +1,774 @@
.Pixso-frame-2_965 {
width: 100%;
height: 62px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background-color: var(--bg-white);
}
.frame-content-2_965 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0px 20px 0px 20px;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_966 {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_967 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_968 {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--bg-white);
}
.frame-content-2_968 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 16px 0px 16px;
width: 100%;
height: 100%;
position: relative;
}
.stroke-wrapper-2_968 {
position: relative;
width: 100%;
height: 52px;
display: flex;
flex-shrink: 0;
}
.stroke-2_968 {
position: absolute;
inset: 0px;
border-radius: 0px 0px 0px 0px;
pointer-events: none;
border-width: 0px 0px 1px 0px;
border-style: solid;
box-sizing: border-box;
border-color: var(--border-light);
}
.Pixso-frame-2_969 {
width: 32px;
height: 32px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
border-radius: 8px 8px 8px 8px;
background-color: var(--bg-light);
}
.frame-content-2_969 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-vector-2_970 {width: 20px;
height: 20px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_972 {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
text-align: center;
color: var(--primary-dark);
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
flex-grow: 1;
flex-basis: 0;
}
.Pixso-frame-2_973 {
width: 32px;
height: 32px;
position: relative;
flex-shrink: 0;
background-color: rgba(249, 250, 254, 0);
}
.Pixso-frame-2_974 {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--bg-white);
}
.frame-content-2_974 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 16px 0px 16px;
width: 100%;
height: 100%;
position: relative;
}
.stroke-wrapper-2_974 {
position: relative;
width: 100%;
height: 44px;
display: flex;
flex-shrink: 0;
}
.stroke-2_974 {
position: absolute;
inset: 0px;
border-radius: 0px 0px 0px 0px;
pointer-events: none;
border-width: 0px 0px 1px 0px;
border-style: solid;
box-sizing: border-box;
border-color: var(--border-light);
}
.Pixso-frame-2_975 {
width: 74px;
height: 44px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
}
.frame-content-2_975 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 16px 0px 16px;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_976 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 700;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_977 {
width: 74px;
height: 2px;
position: absolute;
left: 0px;
top: 42px;
border-radius: 2px 2px 2px 2px;
background-color: var(--accent-orange);
}
.Pixso-frame-2_978 {
width: auto;
height: 44px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
}
.frame-content-2_978 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 16px 0px 16px;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_979 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_980 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.frame-content-2_980 {
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
padding: 16px 16px 40px 16px;
width: 100%;
position: relative;
}
.Pixso-frame-2_981 {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
border-radius: 10px 10px 10px 10px;
background-color: rgba(255, 243, 238, 1);
}
.frame-content-2_981 {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
padding: 10px 12px 10px 12px;
width: 100%;
position: relative;
}
.stroke-wrapper-2_981 {
position: relative;
width: 100%;
height: auto;
display: flex;
flex-shrink: 0;
}
.stroke-2_981 {
position: absolute;
inset: 0px;
border-radius: 10px 10px 10px 10px;
pointer-events: none;
border-width: 1px 1px 1px 1px;
border-style: solid;
box-sizing: border-box;
border-color: rgba(255, 204, 170, 1);
}
.Pixso-vector-2_982 {width: 14px;
height: 14px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_985 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--accent-orange);
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
flex-grow: 1;
flex-basis: 0;
}
.Pixso-frame-2_986 {
width: 100%;
height: 195px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 14px 14px 14px 14px;
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
background-color: var(--bg-white);
}
.frame-content-2_986 {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-vector-2_987 {width: 100%;
height: 80px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-frame-2_988 {
width: 358px;
height: 80px;
position: absolute;
left: 0px;
top: 0px;
border-radius: 14px 14px 0px 0px;
background-color: rgba(0, 0, 0, 0.1882352977991104);
}
.Pixso-frame-2_989 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.frame-content-2_989 {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
padding: 12px 14px 12px 14px;
width: 100%;
position: relative;
}
.Pixso-frame-2_990 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.frame-content-2_990 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_991 {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_992 {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 4px 10px 4px 10px;
border-radius: 6px 6px 6px 6px;
background-color: var(--success-green);
}
.Pixso-paragraph-2_993 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_994 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.frame-content-2_994 {
display: flex;
flex-direction: row;
gap: 14px;
align-items: center;
width: 100%;
position: relative;
}
.Pixso-vector-2_995 {width: 13px;
height: 13px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_998 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-vector-2_999 {width: 13px;
height: 13px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_1002 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_1003 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.frame-content-2_1003 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_1004 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_1005 {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 6px 14px 6px 14px;
border-radius: 8px 8px 8px 8px;
background-color: var(--bg-white);
}
.stroke-wrapper-2_1005 {
position: relative;
width: auto;
height: auto;
display: flex;
flex-shrink: 0;
}
.stroke-2_1005 {
position: absolute;
inset: -1px -1px -1px -1px;
border-radius: 9px 9px 9px 9px;
pointer-events: none;
border-width: 1px 1px 1px 1px;
border-style: solid;
box-sizing: border-box;
border-color: var(--border-light);
}
.Pixso-paragraph-2_1006 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--error-red);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_1007 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 14px 14px 14px 14px;
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
background-color: var(--bg-white);
}
.frame-content-2_1007 {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-vector-2_1008 {width: 100%;
height: 80px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-frame-2_1009 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.frame-content-2_1009 {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
padding: 12px 14px 12px 14px;
width: 100%;
position: relative;
}
.Pixso-frame-2_1010 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.frame-content-2_1010 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_1011 {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_1012 {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 4px 10px 4px 10px;
border-radius: 6px 6px 6px 6px;
background-color: rgba(255, 243, 238, 1);
}
.stroke-wrapper-2_1012 {
position: relative;
width: auto;
height: auto;
display: flex;
flex-shrink: 0;
}
.stroke-2_1012 {
position: absolute;
inset: 0px;
border-radius: 6px 6px 6px 6px;
pointer-events: none;
border-width: 1px 1px 1px 1px;
border-style: solid;
box-sizing: border-box;
border-color: rgba(212, 166, 74, 1);
}
.Pixso-paragraph-2_1013 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 500;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_1014 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.frame-content-2_1014 {
display: flex;
flex-direction: row;
gap: 14px;
align-items: center;
width: 100%;
position: relative;
}
.Pixso-vector-2_1015 {width: 13px;
height: 13px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_1018 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-vector-2_1019 {width: 13px;
height: 13px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_1022 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_1023 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.frame-content-2_1023 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_1024 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_1025 {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 6px 14px 6px 14px;
border-radius: 8px 8px 8px 8px;
background-color: var(--bg-white);
}
.stroke-wrapper-2_1025 {
position: relative;
width: auto;
height: auto;
display: flex;
flex-shrink: 0;
}
.stroke-2_1025 {
position: absolute;
inset: -1px -1px -1px -1px;
border-radius: 9px 9px 9px 9px;
pointer-events: none;
border-width: 1px 1px 1px 1px;
border-style: solid;
box-sizing: border-box;
border-color: var(--border-light);
}
.Pixso-paragraph-2_1026 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--error-red);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
@@ -0,0 +1,264 @@
.mi-course-list__filters {
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 10px);
box-sizing: border-box;
}
.mi-course-list__date-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.mi-course-list__mode {
padding: 8px 12px;
border-radius: 999px;
background: var(--primary-dark, #0B2B4B);
flex-shrink: 0;
}
.mi-course-list__mode-text {
font-size: 12px;
color: #fff;
font-weight: 600;
}
.mi-course-list__dates {
flex: 1;
white-space: nowrap;
}
.mi-course-list__date {
display: inline-flex;
flex-direction: column;
align-items: center;
padding: 8px 12px;
margin-right: 6px;
border-radius: 12px;
background: var(--bg-white, #fff);
border: 1px solid var(--border-light, #E9EDF2);
}
.mi-course-list__date--active {
background: rgba(255, 107, 53, 0.12);
border-color: var(--accent-orange, #FF6B35);
}
.mi-course-list__date-week {
font-size: 10px;
color: var(--text-muted, #5E6F8D);
}
.mi-course-list__date-day {
font-size: 13px;
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.mi-course-list__chips {
white-space: nowrap;
}
.mi-course-list__row {
display: flex;
flex-direction: row;
gap: 8px;
}
.mi-course-list__picker {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 12px;
background: var(--bg-white, #fff);
border: 1px solid var(--border-light, #E9EDF2);
font-size: 13px;
color: var(--text-dark, #1E2A3A);
}
.mi-course-list__arrow {
width: 14px;
height: 14px;
transform: rotate(90deg);
}
.mi-course-card {
display: flex;
flex-direction: row;
gap: 12px;
padding: 12px;
border-radius: 18px;
background: var(--bg-white, #fff);
box-shadow: var(--shadow-sm);
margin-bottom: 12px;
}
.mi-course-card__banner {
width: 96px;
height: 120px;
border-radius: 12px;
flex-shrink: 0;
}
.mi-course-card__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.mi-course-card__head {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 6px;
}
.mi-course-card__title {
font-size: 15px;
font-weight: 700;
color: var(--text-dark, #1E2A3A);
flex: 1;
}
.mi-course-card__type {
padding: 2px 8px;
border-radius: 6px;
flex-shrink: 0;
}
.mi-course-card__type--group {
background: rgba(255, 107, 53, 0.12);
}
.mi-course-card__type--private {
background: rgba(11, 43, 75, 0.1);
}
.mi-course-card__type text {
font-size: 10px;
font-weight: 600;
color: var(--text-muted, #5E6F8D);
}
.mi-course-card__coach {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted, #5E6F8D);
}
.mi-course-card__avatar {
width: 20px;
height: 20px;
border-radius: 50%;
}
.mi-course-card__meta {
font-size: 11px;
color: var(--text-light, #8A99B4);
}
.mi-course-card__capacity {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
.mi-course-card__bar {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--bg-gray, #F2F5F9);
overflow: hidden;
}
.mi-course-card__bar-fill {
height: 100%;
background: var(--gradient-orange);
border-radius: 3px;
}
.mi-course-card__cap-text {
font-size: 10px;
color: var(--text-muted, #5E6F8D);
flex-shrink: 0;
}
.mi-course-card__scarcity {
font-size: 10px;
color: var(--accent-orange, #FF6B35);
font-weight: 600;
flex-shrink: 0;
}
.mi-course-card__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 4px;
}
.mi-course-card__price {
font-size: 12px;
font-weight: 600;
color: var(--primary-deep, #1A4A6F);
}
.mi-course-card__btn {
padding: 6px 16px;
border-radius: 999px;
background: var(--gradient-orange);
}
.mi-course-card__btn text {
font-size: 12px;
font-weight: 600;
color: #fff;
}
.mi-course-card__btn--disabled {
background: var(--bg-gray, #F2F5F9);
}
.mi-course-card__btn--disabled text {
color: var(--text-light, #8A99B4);
}
.mi-course-list__fab {
position: fixed;
right: 16px;
bottom: 32px;
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
padding: 12px 16px;
border-radius: 999px;
background: var(--primary-dark, #0B2B4B);
box-shadow: var(--shadow-md);
z-index: 10;
}
.mi-course-list__fab-icon {
width: 18px;
height: 18px;
}
.mi-course-list__fab-text {
font-size: 13px;
font-weight: 600;
color: #fff;
}
@@ -0,0 +1,340 @@
.member-card-page {
width: 100%;
min-height: 100%;
box-sizing: border-box;
overflow-x: hidden;
background-color: var(--bg-light, #F9FAFE);
}
.member-card-page__body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 16px 40px;
box-sizing: border-box;
}
/* 会员卡 */
.mc-hero {
width: 100%;
min-height: 160px;
padding: 20px 20px 16px;
border-radius: 18px;
box-shadow: 0 10px 20px rgba(11, 43, 75, 0.31);
background: linear-gradient(135deg, #0B2B4B 0%, #1A4A6F 100%);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
}
.mc-hero__top {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
width: 100%;
gap: 8px;
}
.mc-hero__title-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.mc-hero__crown {
width: 18px;
height: 18px;
flex-shrink: 0;
display: block;
}
.mc-hero__name {
font-size: var(--font-size-md, 16px);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse, #ffffff);
white-space: nowrap;
}
.mc-hero__badge {
flex-shrink: 0;
padding: 4px 10px;
border-radius: 10px;
background-color: rgba(255, 255, 255, 0.19);
}
.mc-hero__badge-text {
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-inverse, #ffffff);
white-space: nowrap;
}
.mc-hero__validity {
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
display: block;
}
.mc-hero__bottom {
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
width: 100%;
margin-top: 4px;
}
.mc-hero__days {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 4px;
}
.mc-hero__days-num {
font-size: var(--font-size-5xl, 32px);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse, #ffffff);
line-height: 1;
}
.mc-hero__days-unit {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
padding-top: 8px;
}
.mc-hero__renew {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
padding: 8px 18px;
border-radius: 16px;
background-color: var(--bg-white, #ffffff);
flex-shrink: 0;
}
.mc-hero__renew-icon {
width: 13px;
height: 13px;
flex-shrink: 0;
display: block;
}
.mc-hero__renew-text {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 700;
color: var(--accent-orange, #FF6B35);
white-space: nowrap;
}
/* 使用记录 */
.mc-records {
width: 100%;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
background-color: var(--bg-white, #ffffff);
overflow: hidden;
box-sizing: border-box;
}
.mc-records__header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 48px;
box-sizing: border-box;
}
.mc-records__title {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
white-space: nowrap;
flex-shrink: 0;
}
.mc-records__tabs {
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: 8px;
background-color: var(--bg-light, #F9FAFE);
flex-shrink: 0;
}
.mc-records__tab {
display: flex;
align-items: center;
justify-content: center;
height: 24px;
padding: 0 10px;
border-radius: 6px;
box-sizing: border-box;
}
.mc-records__tab--active {
background-color: var(--bg-white, #ffffff);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.mc-records__tab-text {
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light, #8A99B4);
white-space: nowrap;
}
.mc-records__tab-text--active {
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.mc-records__divider {
width: 100%;
height: 1px;
background-color: var(--bg-light, #F9FAFE);
}
.mc-records__item-inner {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 14px 16px;
box-sizing: border-box;
}
.mc-records__icon-wrap {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mc-records__icon-wrap--orange {
background-color: rgba(255, 243, 238, 1);
}
.mc-records__icon-wrap--green {
background-color: rgba(240, 250, 245, 1);
}
.mc-records__icon {
width: 18px;
height: 18px;
display: block;
flex-shrink: 0;
}
.mc-records__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.mc-records__item-title {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-dark, #1E2A3A);
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mc-records__item-time {
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light, #8A99B4);
display: block;
}
.mc-records__value {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 700;
white-space: nowrap;
flex-shrink: 0;
}
.mc-records__value--negative {
color: var(--error-red, #E74C3C);
}
.mc-records__value--positive {
color: var(--success-green, #2ECC71);
}
/* 使用规则 */
.mc-rules {
width: 100%;
padding: 16px;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
background-color: var(--bg-white, #ffffff);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 10px;
}
.mc-rules__title {
font-size: var(--font-size-base, 14px);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.mc-rules__item {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
}
.mc-rules__bullet {
width: 6px;
height: 6px;
margin-top: 6px;
border-radius: 1px;
background-color: var(--accent-orange, #FF6B35);
flex-shrink: 0;
}
.mc-rules__text {
flex: 1;
font-size: var(--font-size-sm, 12px);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted, #5E6F8D);
line-height: 1.5;
}
@@ -0,0 +1,982 @@
.Pixso-frame-2_878 {
width: 100%;
height: 62px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background-color: var(--bg-white);
}
.frame-content-2_878 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0px 20px 0px 20px;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_879 {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_880 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_881 {
width: 100%;
height: 100%;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--bg-white);
}
.frame-content-2_881 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 16px 0px 16px;
width: 100%;
height: 100%;
position: relative;
}
.stroke-wrapper-2_881 {
position: relative;
width: 100%;
height: 52px;
display: flex;
flex-shrink: 0;
}
.stroke-2_881 {
position: absolute;
inset: 0px;
border-radius: 0px 0px 0px 0px;
pointer-events: none;
border-width: 0px 0px 1px 0px;
border-style: solid;
box-sizing: border-box;
border-color: var(--border-light);
}
.Pixso-frame-2_882 {
width: 32px;
height: 32px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
border-radius: 8px 8px 8px 8px;
background-color: var(--bg-light);
}
.frame-content-2_882 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-vector-2_883 {width: 20px;
height: 20px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_885 {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
text-align: center;
color: var(--primary-dark);
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
flex-grow: 1;
flex-basis: 0;
}
.Pixso-frame-2_886 {
width: 32px;
height: 32px;
position: relative;
flex-shrink: 0;
background-color: rgba(249, 250, 254, 0);
}
.Pixso-frame-2_887 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.frame-content-2_887 {
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
padding: 16px 16px 40px 16px;
width: 100%;
position: relative;
}
.Pixso-frame-2_888 {
width: 100%;
height: 160px;
overflow: hidden;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 18px 18px 18px 18px;
box-shadow: 0px 10px 20px 0px rgba(11, 43, 75, 0.3137254901960784);
background-position: center;
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20358%20160'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(11%2C43%2C75%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(26%2C74%2C111%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
}
.frame-content-2_888 {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 20px 20px 16px 20px;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-frame-2_889 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.frame-content-2_889 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.Pixso-frame-2_890 {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
gap: 6px;
align-items: center;
}
.Pixso-vector-2_891 {width: 18px;
height: 18px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_894 {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_895 {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 4px 10px 4px 10px;
border-radius: 10px 10px 10px 10px;
background-color: rgba(255, 255, 255, 0.1882352977991104);
}
.Pixso-paragraph-2_896 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_897 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_898 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.frame-content-2_898 {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
width: 100%;
position: relative;
}
.Pixso-frame-2_899 {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
gap: 4px;
align-items: flex-start;
}
.Pixso-paragraph-2_900 {
font-size: var(--font-size-5xl);
font-family: var(--font-family);
font-weight: 700;
color: var(--text-inverse);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_901 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: rgba(255, 212, 184, 1);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_902 {
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
gap: 4px;
align-items: center;
padding: 8px 18px 8px 18px;
border-radius: 16px 16px 16px 16px;
background-color: var(--bg-white);
}
.Pixso-vector-2_903 {width: 13px;
height: 13px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-paragraph-2_908 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 700;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_909 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 14px 14px 14px 14px;
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
background-color: var(--bg-white);
}
.frame-content-2_909 {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-frame-2_910 {
width: 100%;
height: 48px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.frame-content-2_910 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0px 16px 0px 16px;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_911 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_912 {
width: auto;
height: 28px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
border-radius: 8px 8px 8px 8px;
background-color: var(--bg-light);
}
.frame-content-2_912 {
display: flex;
flex-direction: row;
gap: 2px;
align-items: center;
padding: 2px 2px 2px 2px;
height: 100%;
position: relative;
}
.Pixso-frame-2_913 {
width: auto;
height: 24px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
border-radius: 6px 6px 6px 6px;
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.06274509803921569);
background-color: var(--bg-white);
}
.frame-content-2_913 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 10px 0px 10px;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_914 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_915 {
width: auto;
height: 24px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
border-radius: 6px 6px 6px 6px;
background-color: rgba(249, 250, 254, 0);
}
.frame-content-2_915 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 10px 0px 10px;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_916 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_917 {
width: auto;
height: 24px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
border-radius: 6px 6px 6px 6px;
background-color: rgba(249, 250, 254, 0);
}
.frame-content-2_917 {
display: flex;
flex-direction: row;
align-items: center;
padding: 0px 10px 0px 10px;
height: 100%;
position: relative;
}
.Pixso-paragraph-2_918 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_919 {
width: 100%;
height: 1px;
position: relative;
flex-shrink: 0;
background-color: var(--bg-light);
}
.Pixso-frame-2_920 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.frame-content-2_920 {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
padding: 14px 16px 14px 16px;
width: 100%;
position: relative;
}
.Pixso-frame-2_921 {
width: 36px;
height: 36px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
border-radius: 10px 10px 10px 10px;
background-color: rgba(255, 243, 238, 1);
}
.frame-content-2_921 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-vector-2_922 {width: 18px;
height: 18px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-frame-2_928 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
}
.frame-content-2_928 {
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_929 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_930 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
white-space: pre-wrap;
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_931 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 700;
color: var(--error-red);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_932 {
width: 100%;
height: 1px;
position: relative;
flex-shrink: 0;
background-color: var(--bg-light);
}
.Pixso-frame-2_933 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.frame-content-2_933 {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
padding: 14px 16px 14px 16px;
width: 100%;
position: relative;
}
.Pixso-frame-2_934 {
width: 36px;
height: 36px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
border-radius: 10px 10px 10px 10px;
background-color: rgba(240, 250, 245, 1);
}
.frame-content-2_934 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-vector-2_935 {width: 18px;
height: 18px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-frame-2_938 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
}
.frame-content-2_938 {
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_939 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_940 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
white-space: pre-wrap;
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_941 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 700;
color: var(--error-red);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_942 {
width: 100%;
height: 1px;
position: relative;
flex-shrink: 0;
background-color: var(--bg-light);
}
.Pixso-frame-2_943 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.frame-content-2_943 {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
padding: 14px 16px 14px 16px;
width: 100%;
position: relative;
}
.Pixso-frame-2_944 {
width: 36px;
height: 36px;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
border-radius: 10px 10px 10px 10px;
background-color: rgba(255, 243, 238, 1);
}
.frame-content-2_944 {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
}
.Pixso-vector-2_945 {width: 18px;
height: 18px;
position: relative;
flex-shrink: 0;
display: block;
}
.Pixso-frame-2_949 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
}
.frame-content-2_949 {
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_950 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_951 {
font-size: var(--font-size-sm);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-light);
white-space: pre-wrap;
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_952 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 700;
color: var(--success-green);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_953 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 14px 14px 14px 14px;
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
background-color: var(--bg-white);
}
.frame-content-2_953 {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
padding: 16px 16px 16px 16px;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_954 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-dark);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-frame-2_955 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
}
.frame-content-2_955 {
display: flex;
flex-direction: row;
gap: 8px;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_956 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_957 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
flex-grow: 1;
flex-basis: 0;
}
.Pixso-frame-2_958 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
}
.frame-content-2_958 {
display: flex;
flex-direction: row;
gap: 8px;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_959 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_960 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
flex-grow: 1;
flex-basis: 0;
}
.Pixso-frame-2_961 {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
}
.frame-content-2_961 {
display: flex;
flex-direction: row;
gap: 8px;
align-items: flex-start;
width: 100%;
position: relative;
}
.Pixso-paragraph-2_962 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--accent-orange);
width: auto;
height: auto;
position: relative;
flex-shrink: 0;
white-space: nowrap;
flex-grow: 0;
}
.Pixso-paragraph-2_963 {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 400;
color: var(--text-muted);
width: 100%;
height: auto;
position: relative;
flex-shrink: 1;
flex-grow: 1;
flex-basis: 0;
}
@@ -0,0 +1,584 @@
/* 个人中心其它模块页面样式 */
@import '@/common/style/memberInfo/member-info-gradient-cards.css';
.mi-mod-tabs {
display: flex;
flex-direction: row;
gap: var(--spacing-sm, 8px);
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
overflow-x: auto;
box-sizing: border-box;
}
/* 训练明细 */
.mi-mod-session {
padding: 14px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.mi-mod-session:last-child {
border-bottom: none;
padding-bottom: 0;
}
.mi-mod-session__head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mi-mod-session__title {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
flex: 1;
}
.mi-mod-session__tag {
padding: 2px 8px;
border-radius: 6px;
flex-shrink: 0;
}
.mi-mod-session__tag--group {
background: rgba(255, 107, 53, 0.12);
}
.mi-mod-session__tag--private {
background: rgba(11, 43, 75, 0.1);
}
.mi-mod-session__tag--free {
background: rgba(46, 204, 113, 0.12);
}
.mi-mod-session__tag-text {
font-size: 10px;
font-weight: 600;
color: var(--text-muted, #5E6F8D);
}
.mi-mod-session__meta {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
margin-top: 4px;
display: block;
}
.mi-mod-session__footer {
display: flex;
flex-direction: row;
gap: 16px;
margin-top: 8px;
}
.mi-mod-session__stat {
font-size: var(--font-size-sm, 12px);
font-weight: 600;
color: var(--primary-deep, #1A4A6F);
}
/* 优惠券 */
.mi-mod-coupon {
display: flex;
flex-direction: row;
border-radius: var(--radius-md, 20px);
overflow: hidden;
margin-bottom: 12px;
box-shadow: var(--shadow-sm);
}
.mi-mod-coupon--expired,
.mi-mod-coupon--used {
opacity: 0.65;
}
.mi-mod-coupon__left {
width: 100px;
padding: 16px 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mi-mod-coupon--used .mi-mod-coupon__left,
.mi-mod-coupon--expired .mi-mod-coupon__left {
background: var(--bg-gray, #F2F5F9);
}
.mi-mod-coupon__amount {
font-size: 28px;
font-weight: 800;
color: var(--text-inverse, #fff);
line-height: 1;
}
.mi-mod-coupon--used .mi-mod-coupon__amount,
.mi-mod-coupon--expired .mi-mod-coupon__amount {
color: var(--text-muted, #5E6F8D);
}
.mi-mod-coupon__min {
font-size: 10px;
color: rgba(255, 255, 255, 0.85);
margin-top: 4px;
}
.mi-mod-coupon--used .mi-mod-coupon__min,
.mi-mod-coupon--expired .mi-mod-coupon__min {
color: var(--text-light, #8A99B4);
}
.mi-mod-coupon__right {
flex: 1;
padding: 14px 16px;
background: var(--bg-white, #fff);
position: relative;
}
.mi-mod-coupon__title {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.mi-mod-coupon__desc {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
margin-top: 4px;
display: block;
}
.mi-mod-coupon__expire {
font-size: 11px;
color: var(--text-light, #8A99B4);
margin-top: 8px;
display: block;
}
.mi-mod-coupon__tag {
position: absolute;
top: 14px;
right: 14px;
padding: 2px 8px;
border-radius: 6px;
background: var(--bg-gray, #F2F5F9);
}
.mi-mod-coupon__tag-text {
font-size: 10px;
color: var(--accent-orange, #FF6B35);
font-weight: 600;
}
/* 积分 */
.mi-mod-points-hero {
padding: 24px 20px;
border-radius: var(--radius-sm, 12px);
text-align: center;
}
.mi-mod-points-hero__label {
font-size: var(--font-size-xs, 12px);
color: rgba(255, 212, 184, 1);
display: block;
}
.mi-mod-points-hero__value {
font-size: var(--font-size-3xl, 2rem);
font-weight: 700;
color: var(--text-inverse, #fff);
line-height: 1.2;
display: block;
margin: 8px 0;
}
.mi-mod-points-hero__tip {
font-size: var(--font-size-xs, 12px);
color: rgba(255, 212, 184, 1);
line-height: 1.5;
}
.mi-mod-rewards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.mi-mod-reward {
padding: 14px;
border-radius: var(--radius-sm, 12px);
background: var(--bg-gray, #F2F5F9);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
}
.mi-mod-reward__icon {
width: 28px;
height: 28px;
}
.mi-mod-reward__name {
font-size: var(--font-size-sm, 12px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.mi-mod-reward__cost {
font-size: 11px;
color: var(--accent-orange, #FF6B35);
font-weight: 600;
}
.mi-mod-reward__stock {
font-size: 10px;
color: var(--text-light, #8A99B4);
}
.mi-mod-points-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.mi-mod-points-row:last-child {
border-bottom: none;
}
.mi-mod-points-row__info {
flex: 1;
}
.mi-mod-points-row__title {
font-size: var(--font-size-base, 14px);
color: var(--text-dark, #1E2A3A);
display: block;
}
.mi-mod-points-row__time {
font-size: var(--font-size-sm, 12px);
color: var(--text-light, #8A99B4);
margin-top: 2px;
display: block;
}
.mi-mod-points-row__right {
text-align: right;
}
.mi-mod-points-row__amount {
font-size: var(--font-size-md, 16px);
font-weight: 700;
display: block;
}
.mi-mod-points-row__amount--earn {
color: var(--success-green, #2ECC71);
}
.mi-mod-points-row__amount--spend {
color: var(--warning-amber, #F39C12);
}
.mi-mod-points-row__balance {
font-size: 10px;
color: var(--text-light, #8A99B4);
}
/* 邀请 */
.mi-mod-referral-hero {
padding: 20px;
border-radius: var(--radius-sm, 12px);
}
.mi-mod-referral-hero__title {
font-size: var(--font-size-lg, 18px);
font-weight: 700;
color: var(--text-inverse, #fff);
display: block;
}
.mi-mod-referral-hero__desc {
font-size: var(--font-size-sm, 12px);
color: rgba(255, 212, 184, 1);
margin-top: 6px;
display: block;
}
.mi-mod-referral-code {
margin-top: 16px;
padding: 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.15);
text-align: center;
}
.mi-mod-referral-code__label {
font-size: var(--font-size-sm, 12px);
color: rgba(255, 255, 255, 0.75);
display: block;
}
.mi-mod-referral-code__value {
font-size: 24px;
font-weight: 800;
color: var(--text-inverse, #fff);
letter-spacing: 2px;
margin-top: 6px;
display: block;
}
.mi-mod-referral-stats {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-around;
}
.mi-mod-referral-stat {
text-align: center;
}
.mi-mod-referral-stat__num {
font-size: var(--font-size-xl, 20px);
font-weight: 800;
display: block;
}
.mi-mod-referral-stat__num--orange {
color: var(--accent-orange, #FF6B35);
}
.mi-mod-referral-stat__num--green {
color: var(--success-green, #2ECC71);
}
.mi-mod-referral-stat__num--amber {
color: var(--warning-amber, #F39C12);
}
.mi-mod-referral-stat__label {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
margin-top: 4px;
display: block;
}
.mi-mod-referral-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.mi-mod-referral-row:last-child {
border-bottom: none;
}
.mi-mod-referral-row__info {
flex: 1;
}
.mi-mod-referral-row__name {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.mi-mod-referral-row__time {
font-size: var(--font-size-sm, 12px);
color: var(--text-light, #8A99B4);
margin-top: 2px;
display: block;
}
.mi-mod-referral-row__right {
text-align: right;
}
.mi-mod-referral-row__status {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
display: block;
}
.mi-mod-referral-row__reward {
font-size: 11px;
font-weight: 600;
color: var(--accent-orange, #FF6B35);
margin-top: 2px;
display: block;
}
/* 我的课程 */
.mi-mod-course-card {
display: flex;
flex-direction: row;
gap: 12px;
padding: 14px;
border-radius: var(--radius-md, 20px);
background: var(--bg-white, #fff);
box-shadow: var(--shadow-sm);
margin-bottom: 12px;
}
.mi-mod-course-card__banner {
width: 88px;
height: 88px;
border-radius: 12px;
flex-shrink: 0;
}
.mi-mod-course-card__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.mi-mod-course-card__title {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
}
.mi-mod-course-card__coach {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
}
.mi-mod-course-card__progress {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.mi-mod-course-card__progress-bar {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--bg-gray, #F2F5F9);
overflow: hidden;
}
.mi-mod-course-card__progress-fill {
height: 100%;
border-radius: 3px;
background: var(--gradient-orange);
}
.mi-mod-course-card__progress-text {
font-size: 10px;
color: var(--text-muted, #5E6F8D);
flex-shrink: 0;
}
.mi-mod-course-card__meta {
font-size: 11px;
color: var(--text-light, #8A99B4);
}
.mi-mod-course-card__next {
font-size: 11px;
font-weight: 600;
color: var(--primary-deep, #1A4A6F);
}
/* 签到记录 */
.mi-mod-checkin-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border-light, #E9EDF2);
}
.mi-mod-checkin-row:last-child {
border-bottom: none;
}
.mi-mod-checkin-row__icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mi-mod-checkin-row__icon--group {
background: rgba(255, 107, 53, 0.12);
}
.mi-mod-checkin-row__icon--private {
background: rgba(11, 43, 75, 0.1);
}
.mi-mod-checkin-row__icon--free {
background: rgba(46, 204, 113, 0.12);
}
.mi-mod-checkin-row__icon-img {
width: 20px;
height: 20px;
}
.mi-mod-checkin-row__info {
flex: 1;
min-width: 0;
}
.mi-mod-checkin-row__title {
font-size: var(--font-size-base, 14px);
font-weight: 600;
color: var(--text-dark, #1E2A3A);
display: block;
}
.mi-mod-checkin-row__time {
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
margin-top: 2px;
display: block;
}
.mi-mod-checkin-row__tag {
padding: 4px 10px;
border-radius: 8px;
flex-shrink: 0;
}
.mi-mod-checkin-row__tag--group {
background: rgba(255, 107, 53, 0.12);
}
.mi-mod-checkin-row__tag--private {
background: rgba(11, 43, 75, 0.1);
}
.mi-mod-checkin-row__tag--free {
background: rgba(46, 204, 113, 0.12);
}
.mi-mod-checkin-row__tag-text {
font-size: 10px;
font-weight: 600;
color: var(--text-muted, #5E6F8D);
}
@@ -0,0 +1,32 @@
/* 子页面根容器:锁定变量,与 H5 theme-light 一致 */
.scroll-container {
box-sizing: border-box;
--primary-dark: #0B2B4B;
--primary-deep: #1A4A6F;
--accent-orange: #FF6B35;
--accent-orange-light: #FF8C5A;
--bg-light: #F9FAFE;
--bg-white: #FFFFFF;
--text-dark: #1E2A3A;
--text-muted: #5E6F8D;
--text-light: #8A99B4;
--text-inverse: #FFFFFF;
--border-light: #E9EDF2;
--success-green: #2ECC71;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-size-xs: 11px;
--font-size-sm: 12px;
--font-size-base: 14px;
--font-size-md: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 22px;
--font-size-3xl: 24px;
--font-size-4xl: 28px;
--font-size-5xl: 32px;
}
@@ -0,0 +1,83 @@
.scroll-container {
height: 100%;
width: 100%;
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
}
.scroll-container > view {
width: 100%;
}
/* ========== 子页面统一布局(参考 base.css 间距变量) ========== */
.bt-page,
.booking-page {
width: 100%;
min-height: 100%;
box-sizing: border-box;
overflow-x: hidden;
background-color: var(--bg-light, #F9FAFE);
}
/* 导航栏下方首个区块:与固定顶栏留出间距 */
.bt-page > .sub-nav + .mi-mod-tabs,
.bt-page > .sub-nav + .mi-course-list__filters,
.bt-page > .sub-nav + .bt-page__action-bar,
.bt-page > .sub-nav + .bt-page__body,
.booking-page > .sub-nav + .booking-page__tabs {
margin-top: var(--spacing-md, 16px);
}
/* tabs / 筛选栏下方内容区:避免重复过大顶边距 */
.bt-page > .sub-nav + .mi-mod-tabs + .bt-page__action-bar + .bt-page__body,
.bt-page > .sub-nav + .mi-mod-tabs + .bt-page__body,
.bt-page > .sub-nav + .mi-course-list__filters + .bt-page__body,
.booking-page > .sub-nav + .booking-page__tabs + .bt-page__action-bar + .booking-page__body,
.booking-page > .sub-nav + .booking-page__tabs + .booking-page__body {
padding-top: var(--spacing-sm, 8px);
}
/* 导航栏下直接跟操作栏(无 tabs) */
.bt-page > .sub-nav + .bt-page__action-bar + .bt-page__body {
padding-top: var(--spacing-sm, 8px);
}
/* 页面内次级操作栏(原导航栏右侧按钮下移至此) */
.bt-page__action-bar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm, 8px);
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
box-sizing: border-box;
}
.bt-page__action-bar--end {
justify-content: flex-end;
}
.bt-page__action-bar-text {
flex: 1;
font-size: var(--font-size-sm, 12px);
color: var(--text-muted, #5E6F8D);
line-height: 1.4;
}
.bt-page__action-link {
flex-shrink: 0;
font-size: var(--font-size-sm, 12px);
font-weight: 600;
color: var(--primary-deep, #1A4A6F);
padding: 6px 12px;
border-radius: var(--radius-full, 999px);
background-color: var(--bg-white, #FFFFFF);
border: 1px solid var(--border-light, #E9EDF2);
}
.bt-page__action-link--primary {
color: var(--text-inverse, #FFFFFF);
border-color: transparent;
}
@@ -0,0 +1,192 @@
.Pixso-frame-2_791 {
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: visible;
background-color: var(--bg-light, #F9FAFE);
}
.frame-content-2_802 {
box-sizing: border-box;
padding-bottom: calc(88px + env(safe-area-inset-bottom)) !important;
}
.user-info-save-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background-color: var(--bg-white, #ffffff);
box-shadow: 0 -2px 12px rgba(26, 25, 24, 0.06);
box-sizing: border-box;
}
.user-info-save-bar__btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 48px;
border-radius: 12px;
background-color: var(--accent-orange, #FF6B35);
}
.user-info-save-bar__text {
font-size: var(--font-size-md, 16px);
font-family: var(--font-family);
font-weight: 600;
color: var(--text-inverse, #ffffff);
line-height: 1;
}
.avatar-block {
width: 100%;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
background-color: var(--bg-white);
flex-shrink: 0;
overflow: hidden;
box-sizing: border-box;
}
.avatar-block__inner {
position: relative;
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
background-color: var(--bg-light, #f9fafe);
}
.avatar-block__photo {
width: 100%;
height: 100%;
display: block;
}
.avatar-block__change {
position: absolute;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 28px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
background-color: rgba(0, 0, 0, 0.55);
z-index: 2;
box-sizing: border-box;
}
.avatar-block__icon {
width: 12px;
height: 12px;
flex-shrink: 0;
display: block;
filter: brightness(0) invert(1);
}
.avatar-block__text {
font-size: var(--font-size-xs, 11px);
line-height: 1;
color: #ffffff;
white-space: nowrap;
}
.frame-content-2_811,
.frame-content-2_817,
.frame-content-2_826,
.frame-content-2_842,
.frame-content-2_848,
.frame-content-2_859 {
min-width: 0;
}
.Pixso-paragraph-2_813,
.Pixso-paragraph-2_844,
.Pixso-paragraph-2_861 {
display: block;
min-width: 0;
}
.gender-btn {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
padding: 6px 14px;
border-radius: 8px;
background-color: var(--bg-light, #F9FAFE);
flex-shrink: 0;
}
.gender-btn__icon {
width: 14px;
height: 14px;
display: block;
flex-shrink: 0;
}
.gender-btn__text {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-light, #8A99B4);
white-space: nowrap;
}
.gender-btn--active {
background-color: var(--accent-orange, #FF6B35);
}
.gender-btn--active .gender-btn__text {
color: var(--text-inverse, #ffffff);
}
.goal-tags {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
width: 100%;
box-sizing: border-box;
}
.goal-tag {
display: flex;
align-items: center;
justify-content: center;
padding: 7px 16px;
border-radius: 100px;
border: 1px solid rgba(209, 208, 205, 1);
background-color: var(--bg-white, #ffffff);
flex-shrink: 0;
box-sizing: border-box;
}
.goal-tag__text {
font-size: var(--font-size-base);
font-family: var(--font-family);
font-weight: 500;
color: var(--text-muted, #5E6F8D);
white-space: nowrap;
}
.goal-tag--selected {
border-color: var(--accent-orange, #FF6B35);
background-color: var(--accent-orange, #FF6B35);
}
.goal-tag--selected .goal-tag__text {
color: var(--text-inverse, #ffffff);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
@font-face {
font-family: "iconfont"; /* Project id */
src: url('tabbar.ttf?t=1780818759010') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-home:before {
content: "\e666";
}
.icon-course:before {
content: "\e692";
}
.icon-train:before {
content: "\e8be";
}
.icon-discover:before {
content: "\e726";
}
.icon-profile:before {
content: "\e501";
}
@@ -0,0 +1,97 @@
<template>
<view class="qr-status">
<!-- 加载中状态 -->
<view v-if="status === 'loading'" class="status-loading">
<view class="status-icon">
<view class="loading-spinner"></view>
</view>
<text>生成中...</text>
</view>
<!-- 签到成功状态 -->
<view v-else-if="status === 'scanned'" class="status-success">
<view class="status-icon">
<uni-icons type="checkmarkcircle" size="40rpx" color="#2ECC71"></uni-icons>
</view>
<text>签到成功</text>
</view>
<!-- 错误状态支持自定义文案 -->
<view v-else-if="status === 'error'" class="status-error">
<view class="status-icon">
<uni-icons type="closecircle" size="40rpx" color="#E74C3C"></uni-icons>
</view>
<text>{{ errorText || '签到失败,请重试' }}</text>
</view>
</view>
</template>
<script setup>
import { defineProps } from 'vue';
// 扩展Props,支持自定义错误文案
const props = defineProps({
status: {
type: String,
required: true,
default: ''
},
// 自定义错误文本(可选)
errorText: {
type: String,
required: false,
default: ''
}
});
</script>
<style scoped>
/* 保留原样式,新增加载中样式 */
.qr-status {
margin-bottom: 48rpx;
}
.status-loading,
.status-waiting,
.status-success,
.status-error {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
font-size: 29rpx;
}
.status-icon {
display: flex;
align-items: center;
}
/* 加载中样式 */
.status-loading {
color: #FF6B35;
}
.loading-spinner {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #E9EDF2;
border-top-color: #FF6B35;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.status-success {
color: #2ECC71;
}
.status-error {
color: #E74C3C;
}
/* 旋转动画 */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
@@ -0,0 +1,131 @@
<template>
<SkeletonBase>
<view class="skeleton-banner"></view>
<view class="skeleton-entry">
<view v-for="i in 4" :key="i" class="skeleton-entry-item">
<view class="skeleton-icon"></view>
<view class="skeleton-text"></view>
</view>
</view>
<view class="skeleton-section">
<view class="skeleton-section-title"></view>
<view v-for="i in 3" :key="i" class="skeleton-course-item">
<view class="skeleton-course-img"></view>
<view class="skeleton-course-info">
<view class="skeleton-course-title"></view>
<view class="skeleton-course-desc"></view>
</view>
</view>
</view>
</SkeletonBase>
</template>
<script setup>
import SkeletonBase from './SkeletonBase.vue'
</script>
<style lang="scss" scoped>
.skeleton-banner {
height: 300rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
margin: 20rpx;
border-radius: 20rpx;
}
.skeleton-entry {
display: flex;
justify-content: space-around;
padding: 30rpx 20rpx;
}
.skeleton-entry-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.skeleton-icon {
width: 80rpx;
height: 80rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 40rpx;
}
.skeleton-text {
width: 60rpx;
height: 24rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 12rpx;
}
.skeleton-section {
padding: 20rpx;
}
.skeleton-section-title {
height: 40rpx;
width: 200rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
margin-bottom: 24rpx;
}
.skeleton-course-item {
display: flex;
gap: 20rpx;
margin-bottom: 24rpx;
padding: 20rpx;
background: #fff;
border-radius: 20rpx;
}
.skeleton-course-img {
width: 160rpx;
height: 160rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 16rpx;
}
.skeleton-course-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-course-title {
height: 36rpx;
width: 80%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
}
.skeleton-course-desc {
height: 28rpx;
width: 60%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
@@ -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>
@@ -0,0 +1,45 @@
<template>
<view class="skeleton" :style="{ padding: padding }">
<slot />
</view>
</template>
<script setup>
defineProps({
padding: {
type: String,
default: '20rpx'
}
})
</script>
<style lang="scss" scoped>
.skeleton {
background-color: #f5f5f5;
min-height: 100vh;
}
:deep(.skeleton-shimmer) {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
:deep(.skeleton-line) {
height: 32rpx;
border-radius: 16rpx;
}
:deep(.skeleton-block) {
border-radius: 16rpx;
}
:deep(.skeleton-circle) {
border-radius: 50%;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
+292
View File
@@ -0,0 +1,292 @@
<!-- components/TabBar.vue -->
<template>
<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>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import {
PAGE,
TAB_ROUTES,
getCurrentRoutePath,
getTabIndexByRoute
} from '@/common/constants/routes.js'
const props = defineProps({
active: { type: Number, default: -1 },
activeTab: { type: Number, default: -1 }
})
const emit = defineEmits(['update:active', 'tab-change'])
const currentActiveIndex = ref(-1)
const shouldShowTabBar = ref(true)
const HIDE_TABBAR_PAGES = [
'pages/memberInfo/courseList',
'pages/memberInfo/courseDetail',
'pages/memberInfo/booking',
'pages/memberInfo/bodyTestReport',
'pages/groupCourse/list',
'pages/groupCourse/detail',
'pages/checkIn/checkIn',
'pages/memberInfo/myCourses',
'pages/memberInfo/coupons',
'pages/memberInfo/points',
'pages/memberInfo/pointsMall',
'pages/memberInfo/referral',
'pages/memberInfo/userInfo',
]
function getActiveIndexFromRoute() {
const routePath = getCurrentRoutePath()
const index = getTabIndexByRoute(routePath)
return index >= 0 ? index : 0
}
function syncActiveState() {
const routeIndex = getActiveIndexFromRoute()
if (routeIndex >= 0) {
currentActiveIndex.value = routeIndex
return
}
if (props.active >= 0) {
currentActiveIndex.value = props.active
} else if (props.activeTab >= 0) {
currentActiveIndex.value = props.activeTab
} else {
currentActiveIndex.value = 0
}
}
function checkShouldShow() {
let routePath = getCurrentRoutePath()
if (routePath.startsWith('/')) {
routePath = routePath.slice(1)
}
if (routePath.includes('?')) {
routePath = routePath.split('?')[0]
}
const shouldHide = HIDE_TABBAR_PAGES.includes(routePath)
shouldShowTabBar.value = !shouldHide
}
let routeWatcher = null
let appRouteCallback = null
let isNavigating = false
onMounted(() => {
syncActiveState()
checkShouldShow()
// #ifndef MP-WEIXIN
// H5和其他平台使用轮询监听路由变化
routeWatcher = setInterval(() => {
// 导航期间不更新状态,避免覆盖用户点击的索引
if (isNavigating) return
const newIndex = getActiveIndexFromRoute()
if (newIndex !== currentActiveIndex.value) {
currentActiveIndex.value = newIndex
}
checkShouldShow()
}, 200)
// #endif
// #ifdef MP-WEIXIN
if (typeof uni.onAppRoute === 'function') {
appRouteCallback = () => {
setTimeout(() => {
syncActiveState()
checkShouldShow()
}, 100)
}
uni.onAppRoute(appRouteCallback)
}
// #endif
})
onBeforeUnmount(() => {
// #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
})
watch(() => props.active, () => {
const routeIndex = getActiveIndexFromRoute()
if (routeIndex !== currentActiveIndex.value) { syncActiveState() }
})
// tabs 配置:只有"我的"用字体图标
const tabs = [
{
path: PAGE.INDEX,
icon: 'icon-home',
label: '首页',
useFontIcon: true,
fontSize:"36rpx"
},
{
path: PAGE.COURSE,
icon: 'icon-course',
label: '课程',
useFontIcon: true,
fontSize:"36rpx"
},
{
path: PAGE.MEMBER,
icon: 'icon-profile',
label: '我的',
useFontIcon: true,
fontSize:"36rpx"
}
]
let isSwitching = false
function onTabTap(index) {
if (isSwitching) return
const targetPath = TAB_ROUTES[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)
// 设置导航标志,阻止轮询覆盖状态
isNavigating = true
uni.showLoading({ title: '加载中...', mask: true })
isSwitching = true
uni.switchTab({
url: targetPath,
success: () => { console.log('switchTab 成功:', targetPath) },
fail: (err) => {
console.error('switchTab 失败:', err)
uni.reLaunch({ url: targetPath })
},
complete: () => {
uni.hideLoading()
setTimeout(() => {
isSwitching = false
isNavigating = false
// #ifdef MP-WEIXIN
syncActiveState()
// #endif
checkShouldShow()
}, 100)
}
})
}
</script>
<style lang="scss" scoped>
// 引入字体图标 CSS(定义 @font-face
@import '/common/style/tabbar_icon/tabbar.css';
// 固定容器 - 确保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);
-webkit-backdrop-filter: blur(24px);
display: flex;
justify-content: space-around;
align-items: center;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4rpx 24rpx var(--tabbar-shadow);
border-radius: 32rpx 32rpx 0 0;
/* 防闪烁优化 */
transform: translateZ(0);
-webkit-transform: translateZ(0);
will-change: transform;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
perspective: 1000;
-webkit-perspective: 1000;
}
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
padding: 12rpx 24rpx;
transition: all 0.1s ease;
}
.tab-item:active {
transform: scale(0.95);
}
// 图片图标样式
.tab-icon {
width: 40rpx;
height: 40rpx;
}
// 字体图标样式
.tab-icon-font {
font-size: 44rpx;
line-height: 1;
}
// 字体图标颜色控制(根据选中状态)
.tab-item .iconfont {
color: rgba(150, 150, 165, 1);
}
.tab-item.active .iconfont {
color: rgba(130, 220, 130, 0.9);
}
.tab-label {
font-size: 22rpx;
color: rgba(150, 150, 165, 1);
}
.tab-item.active .tab-label {
color: rgba(130, 220, 130, 0.9);
font-weight: 600;
}
</style>
@@ -0,0 +1,85 @@
<!-- components/GlobalLoading.vue -->
<template>
<view v-if="visible" class="global-loading">
<view class="loading-mask"></view>
<view class="loading-content">
<view class="loading-spinner"></view>
<text class="loading-text">{{ text }}</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
const visible = ref(false)
const text = ref('加载中...')
// 显示
function show(loadingText = '加载中...') {
visible.value = true
text.value = loadingText
}
// 隐藏
function hide() {
visible.value = false
}
// 挂载到全局
if (typeof uni !== 'undefined') {
uni.$globalLoading = { show, hide }
}
</script>
<style lang="scss" scoped>
.global-loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.loading-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
}
.loading-content {
position: relative;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 16rpx;
padding: 32rpx 48rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
}
.loading-spinner {
width: 48rpx;
height: 48rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
color: #fff;
font-size: 24rpx;
}
</style>
@@ -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>
@@ -0,0 +1,549 @@
<template>
<!-- 团课卡片容器 -->
<view class="course-card" @click="goDetail">
<!-- 卡片顶部图片区域 -->
<view class="card-top">
<!-- 图片骨架屏 -->
<view v-if="!imageLoaded" class="skeleton skeleton-image"></view>
<!-- 课程封面图片 -->
<image
:src="getCourseCoverUrl(course.coverImage)"
mode="aspectFill"
class="cover-image"
:class="{ hidden: !imageLoaded }"
@load="imageLoaded = true"
@error="imageLoaded = true"
/>
<!-- 课程状态标签 -->
<view :class="['status-tag', statusClass]">
{{ statusText }}
</view>
<!-- 剩余名额标签 -->
<view class="spots-tag" v-if="course.currentMembers < course.maxMembers">
<span class="iconfont_courseCard icon-renwu-ren"></span>
<text>{{ course.maxMembers - course.currentMembers }}个名额</text>
</view>
</view>
<!-- 卡片内容区域 -->
<view class="card-content">
<!-- 课程名称 -->
<view class="course-name-wrapper">
<text class="course-name">{{ course.courseName }}</text>
</view>
<!-- 课程信息 -->
<view class="course-info">
<!-- 上课时间 -->
<view class="info-item">
<span class="iconfont_courseCard icon-shijian "></span>
<text class="info-text">{{ formatTime(course.startTime) }}</text>
</view>
<!-- 上课地点 -->
<view class="info-item">
<span class="iconfont_courseCard icon-didian"></span>
<text class="info-text">{{ course.location }}</text>
</view>
</view>
<!-- 课程时长 -->
<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>
<!-- 卡片底部操作区域 -->
<view class="card-footer">
<!-- 价格信息 -->
<view class="price-info">
<!-- 免费课程 -->
<view v-if="course.storedValueAmount === 0 && course.pointCardAmount === 0" class="price-free">
<text class="free-text">免费</text>
</view>
<!-- 支持多种支付方式 -->
<view v-else-if="course.storedValueAmount > 0 && course.pointCardAmount > 0" class="price-multi">
<view class="price-item stored-value">
<text class="currency">¥</text>
<text class="amount">{{ course.storedValueAmount }}</text>
<text class="label">储值卡</text>
</view>
<view class="price-divider"></view>
<view class="price-item point-card">
<uni-icons type="shop" size="14" color="#FF6B35" />
<text class="amount">{{ course.pointCardAmount }}</text>
<text class="label">次卡</text>
</view>
</view>
<!-- 仅储值卡支付 -->
<view v-else-if="course.storedValueAmount > 0" class="price-single">
<text class="price">
<text class="currency">¥</text>{{ course.storedValueAmount }}
</text>
<text class="pay-label">储值卡</text>
</view>
<!-- 仅次卡支付 -->
<view v-else-if="course.pointCardAmount > 0" class="price-single">
<text class="price points">
<uni-icons type="shop" size="14" color="#FF6B35" />
<text>{{ course.pointCardAmount }}</text>
</text>
<text class="pay-label">次卡</text>
</view>
</view>
<!-- 预约按钮 -->
<view :class="['booking-btn', { disabled: !canBook }]" @click.stop="handleBooking">
<text>{{ buttonText }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { getCourseCoverUrl } from '@/utils/request.js'
// 图片加载状态
const imageLoaded = ref(false)
const props = defineProps({
course: {
type: Object,
required: true
},
courseTypeLabels: {
type: Array,
default: () => []
},
booked: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['booking', 'detail'])
const statusText = computed(() => {
const status = props.course.status
if (status === '0') return '进行中'
if (status === '1') return '已取消'
if (status === '2') return '已结束'
return '未知'
})
const statusClass = computed(() => {
const status = props.course.status
if (status === '0') return 'active'
if (status === '1') return 'canceled'
if (status === '2') return 'ended'
return ''
})
const canBook = computed(() => {
const status = props.course.status
const isFull = props.course.currentMembers >= props.course.maxMembers
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) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const month = date.getMonth() + 1
const day = date.getDate()
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const weekDay = weekDays[date.getDay()]
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${month}${day}${weekDay} ${hours}:${minutes}`
}
const formatDuration = (startStr, endStr) => {
if (!startStr || !endStr) return ''
const start = new Date(startStr)
const end = new Date(endStr)
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', Number(props.course.id))
}
const handleBooking = () => {
if (canBook.value) {
emit('booking', props.course)
}
}
</script>
<style lang="scss" scoped>
@import "@/common/style/iconfont_courseCard.css";
/* 团课卡片容器 */
.course-card {
background: #ffffff;
border-radius: 28rpx;
overflow: hidden;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.04);
margin-bottom: 28rpx;
position: relative;
z-index: 1;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
}
/* 卡片顶部图片区域 */
.card-top {
position: relative;
height: 320rpx;
overflow: hidden;
}
/* 课程封面图片 */
.cover-image {
width: 100%;
height: 100%;
transition: opacity 0.3s ease;
&.hidden {
opacity: 0;
visibility: hidden;
}
}
/* 骨架屏基础样式 */
.skeleton {
position: absolute;
top: 0;
left: 0;
background: linear-gradient(90deg, #f6f7f8 0%, #e0e0e0 20%, #f6f7f8 40%, #f6f7f8 100%);
background-size: 100% 100%;
animation: skeleton-loading 1.5s infinite;
border-radius: inherit;
}
/* 骨架屏动画 */
@keyframes skeleton-loading {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
/* 图片骨架屏 */
.skeleton-image {
width: 100%;
height: 100%;
}
/* 课程状态标签 */
.status-tag {
position: absolute;
top: 24rpx;
left: 24rpx;
padding: 10rpx 24rpx;
border-radius: 24rpx;
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
backdrop-filter: blur(8rpx);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
&.active {
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
}
&.canceled {
background: linear-gradient(135deg, #9CA3AF 0%, #6B7280 100%);
}
&.ended {
background: linear-gradient(135deg, #D1D5DB 0%, #9CA3AF 100%);
}
}
/* 剩余名额标签 */
.spots-tag {
position: absolute;
top: 24rpx;
right: 24rpx;
padding: 10rpx 20rpx;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8rpx);
border-radius: 24rpx;
font-size: 22rpx;
color: #ffffff;
display: flex;
align-items: center;
gap: 8rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
}
/* 卡片内容区域 */
.card-content {
padding: 28rpx 32rpx;
}
/* 课程名称容器 */
.course-name-wrapper {
margin-bottom: 20rpx;
}
/* 课程名称 */
.course-name {
font-size: 34rpx;
font-weight: 700;
color: #1F2937;
display: block;
line-height: 1.4;
letter-spacing: 0.5rpx;
}
/* 课程信息容器 */
.course-info {
display: flex;
flex-direction: column;
gap: 14rpx;
margin-bottom: 20rpx;
}
/* 信息项 */
.info-item {
display: flex;
align-items: center;
gap: 12rpx;
padding: 12rpx 16rpx;
background: linear-gradient(135deg, #F9FAFB 0%, #F3F4F6 100%);
border-radius: 16rpx;
transition: all 0.2s ease;
&:active {
background: linear-gradient(135deg, #F3F4F6 0%, #E5E7EB 100%);
}
}
/* 信息图标 */
.info-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
/* 图标字体垂直居中 */
.iconfont_courseCard {
vertical-align: middle;
}
/* 信息文字 */
.info-text {
font-size: 26rpx;
color: #4B5563;
line-height: 1.5;
flex: 1;
}
/* 课程标签区域 */
.course-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
/* 标签项 */
.tag-item {
display: inline-flex;
align-items: center;
gap: 6rpx;
padding: 10rpx 20rpx;
border-radius: 20rpx;
font-size: 24rpx;
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;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1rpx solid #F3F4F6;
margin-top: 4rpx;
}
/* 价格信息 */
.price-info {
display: flex;
align-items: center;
.price {
font-size: 36rpx;
font-weight: 700;
color: #FF6B35;
display: flex;
align-items: baseline;
gap: 4rpx;
.currency {
font-size: 24rpx;
font-weight: 600;
}
&.points {
color: #FF6B35;
gap: 6rpx;
}
}
}
/* 免费课程样式 */
.price-free {
.free-text {
font-size: 36rpx;
font-weight: 700;
color: #10B981;
}
}
/* 多种支付方式样式 */
.price-multi {
display: flex;
align-items: center;
gap: 16rpx;
.price-item {
display: flex;
align-items: center;
gap: 6rpx;
.currency {
font-size: 20rpx;
font-weight: 600;
color: #FF6B35;
}
.amount {
font-size: 30rpx;
font-weight: 700;
color: #FF6B35;
}
.label {
font-size: 20rpx;
color: #6B7280;
margin-left: 4rpx;
}
&.stored-value {
.currency, .amount {
color: #FF6B35;
}
}
&.point-card {
.amount {
color: #FF6B35;
}
}
}
.price-divider {
width: 2rpx;
height: 32rpx;
background: #E5E7EB;
}
}
/* 单一支付方式样式 */
.price-single {
display: flex;
align-items: baseline;
gap: 8rpx;
.pay-label {
font-size: 20rpx;
color: #6B7280;
padding: 4rpx 12rpx;
background: #F3F4F6;
border-radius: 8rpx;
}
}
/* 预约按钮 */
.booking-btn {
padding: 18rpx 48rpx;
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
border-radius: 44rpx;
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
box-shadow: 0 8rpx 20rpx rgba(255, 107, 53, 0.25);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
letter-spacing: 1rpx;
&:active {
transform: scale(0.95);
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;
}
}
</style>
@@ -0,0 +1,270 @@
<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" />
<text class="filter-text">{{ timeRangeText || '选择时间' }}</text>
<uni-icons type="right" size="20" color="#A0AEC0" class="filter-arrow" />
</view>
<!-- 排序方式 -->
<picker
mode="selector"
:range="sortOptions"
range-key="label"
:value="sortIndex"
@change="onSortChange"
>
<view class="filter-item">
<uni-icons type="list" size="18" color="#5E6F8D" class="filter-icon" />
<text class="filter-text">{{ sortOptions[sortIndex].label }}</text>
<uni-icons type="right" size="20" color="#A0AEC0" class="filter-arrow" />
</view>
</picker>
</view>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
const props = defineProps({
timeRangeText: {
type: String,
default: ''
},
sortOptions: {
type: Array,
default: () => [
{ 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', '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: 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: 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)
return params
}
const getSortType = () => {
return props.sortOptions[localSortIndex.value].value
}
const comparePrice = (a, b, ascending = true) => {
const getEffectivePrice = (item) => {
if (item.storedValueAmount > 0 && item.pointCardAmount > 0) {
return Math.min(item.storedValueAmount, item.pointCardAmount * 50)
} else if (item.storedValueAmount > 0) {
return item.storedValueAmount
} else if (item.pointCardAmount > 0) {
return item.pointCardAmount * 50
}
return 0
}
const priceA = getEffectivePrice(a)
const priceB = getEffectivePrice(b)
return ascending ? priceA - priceB : priceB - priceA
}
const compareByPaymentType = (a, b, sortType) => {
const getPaymentType = (item) => {
const hasPointCard = item.pointCardAmount > 0
const hasStoredValue = item.storedValueAmount > 0
if (hasPointCard && !hasStoredValue) return 1
if (!hasPointCard && hasStoredValue) return 2
if (hasPointCard && hasStoredValue) return 3
return 0
}
const typeA = getPaymentType(a)
const typeB = getPaymentType(b)
switch (sortType) {
case 'pointCardOnly':
if (typeA === 1 && typeB !== 1) return -1
if (typeA !== 1 && typeB === 1) return 1
return 0
case 'storedValueOnly':
if (typeA === 2 && typeB !== 2) return -1
if (typeA !== 2 && typeB === 2) return 1
return 0
case 'bothPayment':
if (typeA === 3 && typeB !== 3) return -1
if (typeA !== 3 && typeB === 3) return 1
return 0
default:
return 0
}
}
const sortCourses = (courses) => {
const sortType = getSortType()
if (!courses || !Array.isArray(courses)) return []
const sorted = [...courses]
switch (sortType) {
case 'priceAsc':
sorted.sort((a, b) => comparePrice(a, b, true))
break
case 'priceDesc':
sorted.sort((a, b) => comparePrice(a, b, false))
break
case 'spotsDesc':
sorted.sort((a, b) => (b.maxMembers - b.currentMembers) - (a.maxMembers - a.currentMembers))
break
case 'pointCardOnly':
case 'storedValueOnly':
case 'bothPayment':
sorted.sort((a, b) => compareByPaymentType(a, b, sortType))
break
default:
sorted.sort((a, b) => new Date(a.startTime) - new Date(b.startTime))
}
return sorted
}
defineExpose({
getFilterParams,
getSortType,
sortCourses
})
</script>
<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: 8rpx;
padding: 18rpx 16rpx;
background: #F5F7FA;
border-radius: 16rpx;
font-size: 24rpx;
color: #5E6F8D;
.filter-icon {
display: flex;
align-items: center;
}
.filter-arrow {
margin-left: auto;
display: flex;
align-items: center;
}
.filter-text {
max-width: 100rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>
@@ -0,0 +1,165 @@
<template>
<view class="search-bar-wrapper">
<!-- 搜索框 -->
<view class="search-bar">
<view class="search-input-wrapper">
<uni-icons type="search" size="20" color="#A0AEC0" class="search-icon" />
<input
class="search-input"
v-model="keyword"
placeholder="搜索课程名称"
placeholder-class="input-placeholder"
@confirm="handleSearch"
/>
<uni-icons
v-if="keyword"
type="closeempty"
size="16"
color="#A0AEC0"
class="clear-icon"
@click="clearSearch"
/>
</view>
<view class="search-btn" @click="handleSearch">搜索</view>
</view>
<!-- 热门关键词 -->
<view class="hot-keywords">
<text class="hot-label">热门搜索</text>
<view
v-for="(kw, index) in hotKeywords"
:key="index"
:class="['hot-tag', { active: keyword === kw }]"
@click="selectKeyword(kw)"
>
{{ kw }}
</view>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
hotKeywords: {
type: Array,
default: () => ['燃脂', '瑜伽', '单车', '普拉提', '高强度']
}
})
const emit = defineEmits(['update:modelValue', 'search'])
const keyword = ref(props.modelValue)
watch(() => props.modelValue, (val) => {
keyword.value = val
})
const handleSearch = () => {
console.log('[SearchBar] 搜索参数:', { keyword: keyword.value })
emit('update:modelValue', keyword.value)
emit('search', { keyword: keyword.value })
}
const clearSearch = () => {
keyword.value = ''
emit('update:modelValue', '')
console.log('[SearchBar] 已清除搜索关键词')
}
const selectKeyword = (kw) => {
keyword.value = kw
handleSearch()
}
const getSearchParams = () => {
console.log('[SearchBar] 获取搜索参数:', { keyword: keyword.value })
return { keyword: keyword.value }
}
defineExpose({
getSearchParams,
clearSearch
})
</script>
<style lang="scss" scoped>
/* 搜索框 */
.search-bar {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 20rpx;
.search-input-wrapper {
flex: 1;
display: flex;
align-items: center;
background: #F5F7FA;
border-radius: 44rpx;
padding: 0 24rpx;
height: 72rpx;
.search-icon {
margin-right: 12rpx;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #1a202c;
}
.input-placeholder {
color: #A0AEC0;
}
.clear-icon {
padding: 8rpx;
}
}
.search-btn {
padding: 0 32rpx;
height: 72rpx;
line-height: 72rpx;
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
border-radius: 44rpx;
}
}
/* 热门关键词 */
.hot-keywords {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12rpx;
.hot-label {
font-size: 26rpx;
color: #8A99B4;
}
.hot-tag {
padding: 8rpx 20rpx;
background: #F5F7FA;
color: #5E6F8D;
font-size: 24rpx;
border-radius: 28rpx;
transition: all 0.2s ease;
&.active {
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
color: #ffffff;
}
}
}
</style>
@@ -0,0 +1,191 @@
<template>
<view class="time-period-selector">
<view class="sort-header">
<uni-icons type="calendar" size="16" color="#FF6B35" class="sort-icon" />
<text class="sort-label">上课时段</text>
</view>
<view class="slider-wrapper">
<view
v-for="(option, index) in timePeriodOptions"
:key="index"
:class="['slider-item', { active: currentIndex === index }]"
@click="handlePeriodChange(index)"
>
<span class="iconfont_time_select" v-bind:class="getPeriodIcon(option.value)"></span>
<text :class="['slider-text', { active: currentIndex === index }]">
{{ option.label.split(' ')[0] }}
</text>
</view>
<!-- 滑动指示器 -->
<view
class="slider-indicator"
:style="{ left: `calc(8rpx + ${currentIndex} * (100% - 16rpx) / 4)` }"
></view>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
timePeriodOptions: {
type: Array,
default: () => [
{ label: '全部', value: 'all' },
{ label: '早上 (6-12 点)', value: 'morning', startHour: 6, endHour: 12 },
{ label: '下午 (12-18 点)', value: 'afternoon', startHour: 12, endHour: 18 },
{ label: '晚上 (18-24 点)', value: 'evening', startHour: 18, endHour: 24 }
]
},
modelValue: {
type: Number,
default: 0
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const currentIndex = ref(props.modelValue)
watch(() => props.modelValue, (val) => {
currentIndex.value = val
})
const getPeriodIcon = (value) => {
const iconMap = {
'all': 'icon-gengduo ',
'morning': 'icon-zaochen',
'afternoon': 'icon-xiawucha ',
'evening': 'icon-yewan '
}
return iconMap[value] || iconMap[all]
}
const handlePeriodChange = (index) => {
currentIndex.value = index
const option = props.timePeriodOptions[index]
console.log('[TimePeriodSelector] 时间段变更:', {
index,
value: option.value,
label: option.label
})
emit('update:modelValue', index)
emit('change', option)
}
const getTimePeriodParams = () => {
const option = props.timePeriodOptions[currentIndex.value]
const params = {
index: currentIndex.value,
value: option.value,
label: option.label,
startHour: option.startHour,
endHour: option.endHour
}
console.log('[TimePeriodSelector] 获取时间段参数:', params)
return params
}
defineExpose({
getTimePeriodParams,
getPeriodIcon
})
</script>
<style lang="scss" scoped>
@import "@/common/style/iconfont_time_select.css";
.time-period-selector {
padding: 24rpx;
background: linear-gradient(135deg, #F5F7FA 0%, #E9EDF2 100%);
border-radius: 20rpx;
box-shadow: inset 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
.sort-header {
display: flex;
align-items: center;
gap: 10rpx;
margin-bottom: 20rpx;
.sort-icon {
display: flex;
align-items: center;
}
.sort-label {
font-size: 28rpx;
color: #1a202c;
font-weight: 600;
}
}
.slider-wrapper {
position: relative;
display: flex;
background: #ffffff;
border-radius: 16rpx;
padding: 8rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
overflow: hidden;
.slider-item {
position: relative;
z-index: 2;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 20rpx 0;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.slider-icon {
display: flex;
align-items: center;
transition: all 0.3s ease;
}
.slider-text {
font-size: 24rpx;
color: #8A99B4;
font-weight: 500;
transition: all 0.3s ease;
&.active {
color: #ffffff;
font-weight: 600;
}
}
&.active {
.slider-icon {
color: #ffffff !important;
}
.slider-text {
color: #ffffff !important;
}
}
}
/* 滑动指示器 */
.slider-indicator {
position: absolute;
top: 8rpx;
left: 8rpx;
width: calc((100% - 16rpx) / 4);
height: calc(100% - 16rpx);
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
border-radius: 12rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.4);
transition: left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
}
}
}
</style>
@@ -0,0 +1,746 @@
<template>
<view>
<!-- 时间范围选择弹窗 -->
<Transition name="modal">
<view v-if="visible" class="time-range-modal">
<view class="modal-mask" @click="handleClose"></view>
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">选择时间范围</text>
<view class="header-actions">
<text class="modal-clear" @click="handleClear">清空</text>
<text class="modal-close" @click="handleClose">×</text>
</view>
</view>
<view class="modal-body">
<!-- 开始日期 -->
<view class="date-item">
<text class="date-label">开始日期</text>
<view class="date-value" @click="toggleStartPicker">
<text>{{ localStartDate || '请选择' }}</text>
<text class="date-arrow"></text>
</view>
</view>
<!-- 结束日期 -->
<view class="date-item">
<text class="date-label">结束日期</text>
<view class="date-value" @click="toggleEndPicker">
<text>{{ localEndDate || '请选择' }}</text>
<text class="date-arrow"></text>
</view>
</view>
<!-- 快捷选择 -->
<view class="quick-select">
<text class="quick-label">快捷选择</text>
<view class="quick-btns">
<view
v-for="item in quickOptions"
:key="item.value"
class="quick-btn"
:class="{ active: quickSelected === item.value }"
@click="applyQuickOption(item.value)"
>
{{ item.label }}
</view>
</view>
</view>
</view>
<view class="modal-footer">
<view class="btn btn-cancel" @click="handleClose">取消</view>
<view class="btn btn-confirm" @click="handleConfirm">确定</view>
</view>
</view>
<!-- 日期选择器弹窗 -->
<Transition name="date-picker">
<view v-if="showDatePicker" class="date-picker-modal">
<view class="date-mask" @click="showDatePicker = false"></view>
<view class="date-picker-content">
<view class="date-header">
<text class="date-prev" @click="prevMonth"></text>
<text class="date-title">{{ currentYear }}{{ currentMonth }}</text>
<text class="date-next" @click="nextMonth"></text>
</view>
<view class="date-weekdays">
<text v-for="day in weekdays" :key="day">{{ day }}</text>
</view>
<view class="date-days">
<view
v-for="(day, index) in calendarDays"
:key="index"
class="date-day"
:class="{
'other-month': !day.currentMonth,
'today': day.isToday,
'selected': day.date === selectedDate,
'disabled': day.disabled
}"
@click="selectDate(day)"
>
{{ day.day }}
</view>
</view>
</view>
</view>
</Transition>
</view>
</Transition>
</view>
</template>
<script setup>
import { ref, watch, computed, onMounted } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
startDate: {
type: String,
default: ''
},
endDate: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:visible', 'update:startDate', 'update:endDate', 'confirm', 'cancel'])
const localStartDate = ref(props.startDate)
const localEndDate = ref(props.endDate)
const showDatePicker = ref(false)
const pickerType = ref('start') // 'start' | 'end'
const currentYear = ref(2024)
const currentMonth = ref(1)
const selectedDate = ref('')
const quickSelected = ref('')
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' }
]
// 监听 props 变化
watch(() => props.startDate, (val) => {
localStartDate.value = val
})
watch(() => props.endDate, (val) => {
localEndDate.value = val
})
watch(() => props.visible, (val) => {
if (val) {
// 弹窗打开时设置当前日期
const now = new Date()
currentYear.value = now.getFullYear()
currentMonth.value = now.getMonth() + 1
}
})
// 计算时间范围文本
const timeRangeText = computed(() => {
if (localStartDate.value && localEndDate.value) {
return `${localStartDate.value}${localEndDate.value}`
} else if (localStartDate.value) {
return `${localStartDate.value}`
} else if (localEndDate.value) {
return `${localEndDate.value}`
}
return ''
})
// 生成日历日期
const calendarDays = computed(() => {
const days = []
const firstDay = new Date(currentYear.value, currentMonth.value - 1, 1)
const lastDay = new Date(currentYear.value, currentMonth.value, 0)
const startDay = firstDay.getDay()
const totalDays = lastDay.getDate()
// 上个月的天数
const prevMonthLastDay = new Date(currentYear.value, currentMonth.value - 1, 0).getDate()
for (let i = startDay - 1; i >= 0; i--) {
days.push({
day: prevMonthLastDay - i,
date: formatDate(currentYear.value, currentMonth.value - 1, prevMonthLastDay - i),
currentMonth: false,
isToday: false,
disabled: true
})
}
// 本月的天数
const today = new Date()
for (let i = 1; i <= totalDays; i++) {
const dateStr = formatDate(currentYear.value, currentMonth.value, i)
days.push({
day: i,
date: dateStr,
currentMonth: true,
isToday: isToday(currentYear.value, currentMonth.value, i),
disabled: isFuture(currentYear.value, currentMonth.value, i)
})
}
// 下个月的天数
const remaining = 42 - days.length
for (let i = 1; i <= remaining; i++) {
days.push({
day: i,
date: formatDate(currentYear.value, currentMonth.value + 1, i),
currentMonth: false,
isToday: false,
disabled: true
})
}
return days
})
// 格式化日期
function formatDate(year, month, day) {
const m = month.toString().padStart(2, '0')
const d = day.toString().padStart(2, '0')
return `${year}-${m}-${d}`
}
// 判断是否是今天
function isToday(year, month, day) {
const today = new Date()
return year === today.getFullYear() &&
month === today.getMonth() + 1 &&
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 > maxFuture
}
// 切换日期选择器
const toggleStartPicker = () => {
pickerType.value = 'start'
selectedDate.value = localStartDate.value
showDatePicker.value = true
}
const toggleEndPicker = () => {
pickerType.value = 'end'
selectedDate.value = localEndDate.value
showDatePicker.value = true
}
// 月份切换
const prevMonth = () => {
if (currentMonth.value === 1) {
currentYear.value--
currentMonth.value = 12
} else {
currentMonth.value--
}
}
const nextMonth = () => {
if (currentMonth.value === 12) {
currentYear.value++
currentMonth.value = 1
} else {
currentMonth.value++
}
}
// 选择日期
const selectDate = (day) => {
if (day.disabled) return
selectedDate.value = day.date
if (pickerType.value === 'start') {
localStartDate.value = day.date
emit('update:startDate', day.date)
} else {
localEndDate.value = day.date
emit('update:endDate', day.date)
}
showDatePicker.value = false
console.log(`[TimeRangePicker] ${pickerType.value === 'start' ? '开始' : '结束'}日期变更:`, day.date)
}
// 应用快捷选项(所有日期计算基于当前真实时间)
const applyQuickOption = (value) => {
quickSelected.value = value
const today = new Date()
let startDate, endDate
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 = 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
}
localStartDate.value = formatDate(startDate.getFullYear(), startDate.getMonth() + 1, startDate.getDate())
localEndDate.value = formatDate(endDate.getFullYear(), endDate.getMonth() + 1, endDate.getDate())
emit('update:startDate', localStartDate.value)
emit('update:endDate', localEndDate.value)
console.log('[TimeRangePicker] 快捷选择:', value, { start: localStartDate.value, end: localEndDate.value })
}
// 清空选择
const handleClear = () => {
localStartDate.value = ''
localEndDate.value = ''
quickSelected.value = ''
emit('update:startDate', '')
emit('update:endDate', '')
console.log('[TimeRangePicker] 已清空时间选择')
}
// 关闭弹窗
const handleClose = () => {
console.log('[TimeRangePicker] 关闭时间选择器')
emit('update:visible', false)
emit('cancel')
}
// 确认选择
const handleConfirm = () => {
console.log('[TimeRangePicker] 确认时间范围:', {
startDate: localStartDate.value,
endDate: localEndDate.value,
timeRangeText: timeRangeText.value
})
emit('update:visible', false)
emit('confirm', {
startDate: localStartDate.value,
endDate: localEndDate.value,
timeRangeText: timeRangeText.value
})
}
// 获取参数
const getTimeRangeParams = () => {
const params = {
startDate: localStartDate.value,
endDate: localEndDate.value,
timeRangeText: timeRangeText.value
}
console.log('[TimeRangePicker] 获取时间范围参数:', params)
return params
}
// 重置日期
const resetDate = () => {
localStartDate.value = ''
localEndDate.value = ''
quickSelected.value = ''
emit('update:startDate', '')
emit('update:endDate', '')
console.log('[TimeRangePicker] 已重置日期')
}
defineExpose({
getTimeRangeParams,
resetDate,
timeRangeText
})
</script>
<style lang="scss" scoped>
.time-range-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
.modal-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
transition: background 0.3s ease;
}
.modal-content {
position: relative;
width: 640rpx;
background: #ffffff;
border-radius: 24rpx;
overflow: hidden;
opacity: 1;
transform: scale(1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
border-bottom: 1rpx solid #E9EDF2;
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #1a202c;
}
.header-actions {
display: flex;
align-items: center;
gap: 24rpx;
}
.modal-clear {
font-size: 28rpx;
color: #8A99B4;
padding: 8rpx 16rpx;
}
.modal-close {
font-size: 48rpx;
color: #8A99B4;
line-height: 1;
}
}
.modal-body {
padding: 32rpx;
.date-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #F0F2F5;
&:last-of-type {
border-bottom: none;
}
.date-label {
font-size: 28rpx;
color: #6B7280;
}
.date-value {
display: flex;
align-items: center;
gap: 12rpx;
text {
font-size: 28rpx;
color: #1a202c;
}
.date-arrow {
font-size: 32rpx;
color: #C4C9D4;
}
}
}
.quick-select {
margin-top: 24rpx;
.quick-label {
font-size: 26rpx;
color: #9CA3AF;
margin-bottom: 16rpx;
display: block;
}
.quick-btns {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
.quick-btn {
padding: 16rpx 28rpx;
background: #F5F7FA;
border-radius: 32rpx;
font-size: 26rpx;
color: #6B7280;
&.active {
background: #FF6B35;
color: #ffffff;
}
}
}
}
}
.modal-footer {
display: flex;
border-top: 1rpx solid #E9EDF2;
.btn {
flex: 1;
padding: 32rpx;
text-align: center;
font-size: 30rpx;
&.btn-cancel {
color: #6B7280;
border-right: 1rpx solid #E9EDF2;
}
&.btn-confirm {
color: #FF6B35;
font-weight: 600;
}
}
}
}
}
.date-picker-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1001;
display: flex;
align-items: flex-end;
.date-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
transition: background 0.3s ease;
}
.date-picker-content {
position: relative;
width: 100%;
background: #ffffff;
border-radius: 32rpx 32rpx 0 0;
transform: translateY(0);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.date-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
.date-prev, .date-next {
font-size: 40rpx;
color: #1a202c;
width: 64rpx;
text-align: center;
}
.date-title {
font-size: 32rpx;
font-weight: 600;
color: #1a202c;
}
}
.date-weekdays {
display: flex;
padding: 0 24rpx;
text {
flex: 1;
text-align: center;
font-size: 26rpx;
color: #9CA3AF;
padding: 16rpx 0;
}
}
.date-days {
display: flex;
flex-wrap: wrap;
padding: 16rpx 24rpx 32rpx;
.date-day {
width: calc(100% / 7);
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #1a202c;
border-radius: 50%;
&.other-month {
color: #D1D5DB;
}
&.today {
background: #F5F7FA;
color: #FF6B35;
font-weight: 600;
}
&.selected {
background: #FF6B35;
color: #ffffff;
}
&.disabled {
color: #E5E7EB;
pointer-events: none;
}
}
}
}
}
/* 居中弹窗进入动画 */
.modal-enter-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-enter-active .modal-mask {
transition: background 0.3s ease;
}
.modal-enter-active .modal-content {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-enter-from .modal-mask {
background: rgba(0, 0, 0, 0);
}
.modal-enter-from .modal-content {
opacity: 0;
transform: scale(0.9);
}
/* 居中弹窗退出动画 */
.modal-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-leave-active .modal-mask {
transition: background 0.3s ease;
}
.modal-leave-active .modal-content {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-leave-to .modal-mask {
background: rgba(0, 0, 0, 0);
}
.modal-leave-to .modal-content {
opacity: 0;
transform: scale(0.9);
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
/* 日期选择器进入动画 */
.date-picker-enter-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.date-picker-enter-active .date-mask {
transition: background 0.3s ease;
}
.date-picker-enter-active .date-picker-content {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.date-picker-enter-from .date-mask {
background: rgba(0, 0, 0, 0);
}
.date-picker-enter-from .date-picker-content {
transform: translateY(100%);
}
/* 日期选择器退出动画 */
.date-picker-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.date-picker-leave-active .date-mask {
transition: background 0.3s ease;
}
.date-picker-leave-active .date-picker-content {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.date-picker-leave-to .date-mask {
background: rgba(0, 0, 0, 0);
}
.date-picker-leave-to .date-picker-content {
transform: translateY(100%);
}
</style>
@@ -0,0 +1,255 @@
<template>
<view class="banner-container">
<swiper
class="banner-swiper"
:circular="true"
:autoplay="true"
:interval="4000"
:duration="500"
:indicator-dots="false"
@change="onSwiperChange"
>
<swiper-item v-for="(banner, index) in banners" :key="index">
<view class="banner-content" @click="previewImage(index)">
<!-- 添加 lazy-load 属性实现懒加载 -->
<image
:src="banner.image"
mode="aspectFill"
class="banner-image"
lazy-load
:show-menu-by-longpress="false"
@load="onImageLoad(index)"
@error="onImageError(index)"
/>
<view class="banner-overlay"></view>
<view class="banner-text">
<text class="banner-title">{{ banner.title }}</text>
<text class="banner-subtitle">{{ banner.subtitle }}</text>
<text class="banner-desc">{{ banner.desc }}</text>
</view>
<!-- 可选添加加载占位符 -->
<view v-if="!imageLoaded[index]" class="image-placeholder">
<view class="loading-spinner"></view>
</view>
</view>
</swiper-item>
</swiper>
<view class="banner-dots">
<view
v-for="(_, index) in banners"
:key="index"
:class="['dot', { active: currentIndex === index }]"
></view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getActiveBanners } from '@/api/main.js'
// 默认静态数据(后端无数据时作为兜底)
const fallbackBanners = [
{
image: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=800&q=80',
title: '突破自我',
subtitle: '超越极限',
desc: '科学训练 · 遇见更好的自己'
},
{
image: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?w=800&q=80',
title: '专业指导',
subtitle: '高效训练',
desc: '私人定制 · 专属健身方案'
},
{
image: 'https://images.unsplash.com/photo-1571019614242-c5c5dee9f50b?w=800&q=80',
title: '健康生活',
subtitle: '从这里开始',
desc: '全方位 · 打造完美体态'
}
]
// 先用回退数据初始化,确保 swiper 立即可见
const banners = ref([...fallbackBanners])
const currentIndex = ref(0)
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
}
const previewImage = (index) => {
const urls = banners.map(banner => banner.image)
uni.previewImage({
urls: urls,
current: urls[index],
indicator: 'default',
loop: true
})
}
const onImageLoad = (index) => {
imageLoaded.value[index] = true
console.log(`图片 ${index} 加载完成`)
}
const onImageError = (index) => {
console.error(`图片 ${index} 加载失败, URL:`, banners.value[index]?.image?.substring(0, 120))
}
</script>
<style lang="scss" scoped>
.banner-container {
width: 100%;
padding: 0 24rpx;
box-sizing: border-box;
}
.banner-swiper {
width: 100%;
height: 360rpx;
border-radius: 20rpx;
overflow: hidden;
}
.banner-content {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.banner-image {
width: 100%;
height: 100%;
}
.image-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
border-top: 4rpx solid #7AB5CC;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.banner-overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
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;
top: 50%;
transform: translateY(-50%);
z-index: 3;
}
.banner-title {
display: block;
font-size: 48rpx;
font-weight: 800;
color: #ffffff;
margin-bottom: 8rpx;
text-shadow: 0 4rpx 16rpx rgba(80, 150, 190, 0.4);
}
.banner-subtitle {
display: block;
font-size: 56rpx;
font-weight: 800;
color: #E0F0FA;
margin-bottom: 16rpx;
text-shadow: 0 4rpx 16rpx rgba(80, 150, 190, 0.4);
}
.banner-desc {
display: block;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
}
.banner-dots {
display: flex;
justify-content: center;
gap: 16rpx;
padding: 20rpx 0 8rpx;
}
.dot {
width: 48rpx;
height: 8rpx;
border-radius: 9999rpx;
background: #D0E4EE;
transition: all 0.3s ease;
}
.dot.active {
width: 64rpx;
background: linear-gradient(90deg, #7AB5CC, #9CCFDF);
}
</style>
@@ -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>
@@ -0,0 +1,200 @@
<template>
<view class="quick-entry">
<view
v-for="(item, index) in entries"
:key="index"
class="entry-item"
@tap="handleEntryClick(item)"
>
<view :class="['entry-icon', { accent: item.accent }]">
<image :src="item.icon" mode="aspectFit" class="icon-img" />
</view>
<text class="entry-title">{{ item.title }}</text>
<text class="entry-desc">{{ item.desc }}</text>
</view>
</view>
</template>
<script setup>
import { qrSignInGroupCourse } from '@/api/groupCourse.js'
import { getMemberId } from '@/utils/request.js'
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/groupCourse/list"
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/checkIn.png',
title: '签到',
desc: '打卡签到',
accent: false,
path: "/pages/checkIn/checkIn"
}
]
</script>
<style lang="scss" scoped>
.quick-entry {
display: flex;
justify-content: space-between;
padding: 32rpx 24rpx;
background: #FFFFFF;
margin: 24rpx;
border-radius: 28rpx;
box-shadow: 0 8rpx 32rpx rgba(45,74,90,0.08);
}
.entry-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.entry-icon {
width: 104rpx;
height: 104rpx;
border-radius: 24rpx;
background: rgba(130, 220, 130, 0.9);
display: flex;
align-items: center;
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 {
width: 52rpx;
height: 52rpx;
}
.entry-icon.accent {
background: rgba(130, 220, 130, 0.9);
}
.entry-title {
font-size: 26rpx;
font-weight: 600;
color: #2D4A5A;
margin-bottom: 4rpx;
}
.entry-desc {
font-size: 22rpx;
color: var(--tabbar-text-inactive);
}
</style>
@@ -0,0 +1,343 @@
<template>
<!-- 推荐课程容器 -->
<view class="recommend-courses">
<!-- 区域标题栏 -->
<view class="section-header">
<!-- 区域标题 -->
<text class="section-title">推荐课程</text>
<!-- 查看更多按钮 -->
<view class="view-more" @click="handleViewMore">
<text>查看更多</text>
<text class="arrow">
<uni-icons type="right" size="20" color="#8CA0B0"/>
</text>
</view>
</view>
<!-- 课程横向滚动容器 -->
<scroll-view class="courses-scroll" scroll-x="true" :show-scrollbar="false">
<!-- 课程列表 -->
<view class="courses-list">
<!-- 课程卡片 -->
<view
v-for="(course, index) in courses"
:key="course.id || index"
class="course-card"
@click="handleJoinCourse(course)"
>
<!-- 课程图片区域 -->
<view class="course-image">
<!-- 课程封面图片 -->
<image :src="course.image" mode="aspectFill" class="img" />
<!-- 图片渐变遮罩 -->
<view class="course-overlay"></view>
<!-- 课程信息区域 -->
<view class="course-info">
<!-- 课程名称 -->
<text class="course-name">{{ course.name }}</text>
<!-- 课程元信息时长难度 -->
<view class="course-meta">
<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>
</view>
<!-- 课程底部区域 -->
<view class="course-footer">
<!-- 参与人数信息 -->
<view class="participants">
<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.stop="handleJoinCourse(course)">
<text>去参与</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</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('[RecommendCourses] 组件脚本已加载')
// 推荐课程数据列表
const courses = 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 工具函数处理
// 使用 @/utils/request.js 中的 getOssUrl 替代
// 获取推荐课程
const fetchRecommendCourses = async () => {
console.log('[RecommendCourses] fetchRecommendCourses 开始请求')
try {
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/groupCourse/detail?id=${course.id}` })
}
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">
.recommend-courses {
padding: 0 24rpx;
margin-bottom: 32rpx;
position: relative;
z-index: 1;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.section-title {
font-size: 34rpx;
font-weight: 700;
color: #2D4A5A;
}
.view-more {
display: flex;
align-items: center;
gap: 4rpx;
font-size: 26rpx;
color: var(--tabbar-text-inactive);
}
.arrow {
font-size: 32rpx;
}
.courses-scroll {
white-space: nowrap;
}
.courses-list {
display: inline-flex;
gap: 24rpx;
padding-right: 24rpx;
}
.course-card {
width: 338rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 8rpx 28rpx var(--shadow-blue-light);
border: 1rpx solid rgba(255, 255, 255, 0.6);
display: inline-block;
vertical-align: top;
}
.course-image {
height: 260rpx;
position: relative;
}
.img {
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-info {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 16rpx 20rpx;
z-index: 2;
}
.course-name {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.course-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8rpx;
}
.meta-left {
display: flex;
gap: 8rpx;
}
.meta-item {
display: flex;
align-items: center;
gap: 6rpx;
font-size: 20rpx;
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 20rpx;
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 {
width: 32rpx;
height: 32rpx;
}
.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,252 @@
<template>
<!-- 今日推荐容器 -->
<view class="today-recommend">
<!-- 区域标题栏 -->
<view class="section-header">
<!-- 区域标题 -->
<text class="section-title">今日推荐</text>
</view>
<!-- 推荐列表 -->
<view class="recommend-list">
<!-- 推荐项 -->
<view
v-for="(item, index) in recommends"
:key="item.id || index"
class="recommend-item"
@click="handleItemClick(item)"
>
<!-- 推荐项图片 -->
<image :src="item.image" mode="aspectFill" class="item-image" />
<!-- 推荐项内容区域 -->
<view class="item-content">
<!-- 推荐项标题 -->
<text class="item-title">{{ item.title }}</text>
<!-- 推荐项标签列表 -->
<view class="item-tags">
<text v-for="(tag, tagIndex) in item.tags" :key="tagIndex" class="tag">{{ tag }}</text>
</view>
<!-- 推荐项描述 -->
<text class="item-desc">{{ item.desc }}</text>
</view>
<!-- 推荐项操作区域 -->
<view class="item-action">
<!-- 开始训练按钮 -->
<view class="start-btn" @click.stop="handleItemClick(item)">
<text class="start-btn-text">开始训练</text>
</view>
<!-- 参与人数 -->
<text class="participants">{{ item.participants }}人参与</text>
</view>
</view>
</view>
</view>
</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 = 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>
.today-recommend {
padding: 0 24rpx;
position: relative;
z-index: 1;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.section-title {
font-size: 34rpx;
font-weight: 700;
color: #2D4A5A;
}
.recommend-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.recommend-item {
display: flex;
gap: 24rpx;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 24rpx;
padding: 20rpx;
box-shadow: 0 8rpx 28rpx var(--shadow-blue-light);
border: 1rpx solid rgba(255, 255, 255, 0.6);
}
.item-image {
width: 200rpx;
height: 160rpx;
border-radius: 16rpx;
flex-shrink: 0;
}
.item-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.item-title {
font-size: 30rpx;
font-weight: 600;
color: #2D4A5A;
margin-bottom: 12rpx;
}
.item-tags {
display: flex;
gap: 12rpx;
margin-bottom: 12rpx;
}
.tag {
padding: 6rpx 16rpx;
background: rgba(122, 181, 204, 0.12);
border-radius: 8rpx;
font-size: 22rpx;
color: #6BA8C0;
}
.item-desc {
font-size: 24rpx;
color: var(--tabbar-text-inactive);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-action {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 16rpx;
flex-shrink: 0;
}
.start-btn {
padding: 16rpx 28rpx;
background: rgba(130, 220, 130, 0.9);
border-radius: 9999rpx;
box-shadow: 0 6rpx 20rpx rgba(130, 220, 130, 0.4);
}
.start-btn-text {
font-size: 24rpx;
font-weight: 600;
color: #ffffff;
white-space: nowrap;
}
.participants {
font-size: 22rpx;
color: var(--tabbar-text-inactive);
white-space: nowrap;
}
</style>
@@ -0,0 +1,67 @@
<template>
<view class="bt-radar">
<canvas
:id="canvasId"
:canvas-id="canvasId"
type="2d"
class="bt-radar__canvas"
:style="{ width: width + 'px', height: height + 'px' }"
/>
</view>
</template>
<script setup>
import { ref, watch, onMounted, nextTick } from 'vue'
import { drawRadarChart } from '@/common/memberInfo/bodyTestChart.js'
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 lang="scss">
.bt-radar {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.bt-radar__canvas {
display: block;
}
</style>
@@ -0,0 +1,67 @@
<template>
<view class="bt-trend">
<canvas
:id="canvasId"
:canvas-id="canvasId"
type="2d"
class="bt-trend__canvas"
:style="{ width: width + 'px', height: height + 'px' }"
/>
</view>
</template>
<script setup>
import { ref, watch, onMounted, nextTick } from 'vue'
import { drawTrendChart } from '@/common/memberInfo/bodyTestChart.js'
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 lang="scss">
.bt-trend {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.bt-trend__canvas {
display: block;
}
</style>
@@ -0,0 +1,128 @@
<template>
<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="$emit('view-history')"
>
<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">
最新数据 · {{ report.date }}
</text>
<view
class="body-report-section__view-btn"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="$emit('view-report')"
>
<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">{{ report.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">{{ report.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">{{ report.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">{{ report.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">
状态{{ report.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">
较上次 {{ report.change }}
</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<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 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';
</style>
@@ -0,0 +1,265 @@
<template>
<view class="booking-section">
<view class="booking-section__inner">
<view class="booking-section__header">
<view class="booking-section__header-inner">
<text class="booking-section__title">我的预约</text>
<view
class="booking-section__link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="$emit('view-all')"
>
<text class="booking-section__view-all">查看全部</text>
<image
class="booking-section__link-arrow"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright2.png"
mode="aspectFit"
/>
</view>
</view>
</view>
<view class="booking-section__list">
<!-- 无数据 -->
<view v-if="!displayItems || displayItems.length === 0" class="booking-section__empty">
<text class="booking-section__empty-text">暂无预约记录</text>
</view>
<view v-else class="booking-section__list-inner">
<view
v-for="(item, 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>
</view>
</view>
</view>
</template>
<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 lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-tap.css';
.booking-section {
width: 100%;
height: auto;
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.booking-section__inner {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
width: 100%;
position: relative;
}
.booking-section__header {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.booking-section__header-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.booking-section__title {
font-size: var(--font-size-md);
font-family: var(--font-family);
font-weight: var(--font-weight-bold);
color: var(--text-dark);
}
.booking-section__list {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 14px;
box-shadow: var(--shadow-sm);
background-color: var(--bg-white);
border: 1px solid var(--border-light);
overflow: hidden;
}
.booking-section__list-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.booking-section__empty {
padding: 48rpx 32rpx;
display: flex;
align-items: center;
justify-content: center;
}
.booking-section__empty-text {
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>
@@ -0,0 +1,98 @@
<template>
<view class="checkin-section">
<view class="checkin-section__inner">
<view class="checkin-section__header">
<view class="checkin-section__header-inner">
<text class="checkin-section__title">签到记录</text>
<view
class="checkin-section__link"
hover-class="mi-tap--hover"
:hover-stay-time="150"
@tap="$emit('view-all')"
>
<text class="checkin-section__view-all">查看全部</text>
<image
class="checkin-section__link-arrow"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright2.png"
mode="aspectFit"
/>
</view>
</view>
</view>
<view class="checkin-section__list">
<!-- 无数据 -->
<view 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"
class="checkin-section__row"
>
<view v-if="index > 0" class="checkin-section__divider"></view>
<view
class="checkin-section__item"
hover-class="mi-tap-row--hover"
:hover-stay-time="150"
@tap="$emit('item-tap', item)"
>
<view class="checkin-section__item-inner">
<view
class="checkin-section__dot"
:class="'checkin-section__dot--' + item.tagTheme"
></view>
<view class="checkin-section__content">
<view class="checkin-section__content-inner">
<text class="checkin-section__desc">{{ item.title }}</text>
<text class="checkin-section__text">{{ item.time }}</text>
</view>
</view>
<view
class="checkin-section__tag-badge"
:class="'checkin-section__tag-badge--' + item.tagTheme"
>
<text
class="checkin-section__tag-text"
:class="'checkin-section__tag-text--' + item.tagTheme"
>
{{ item.tag }}
</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
defineProps({
items: {
type: Array,
default: () => []
}
})
defineEmits(['view-all', 'item-tap'])
</script>
<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>
@@ -0,0 +1,80 @@
<template>
<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="$emit('view-all')"
>
<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="$emit('use-coupon')"
>
<view class="coupon-section__coupon-inner">
<text class="coupon-section__amount">{{ data.amount }}</text>
<text class="coupon-section__desc">{{ data.couponDesc }}</text>
<view class="coupon-section__coupon-status">
<text class="coupon-section__status">{{ data.couponAction }}</text>
</view>
</view>
</view>
<view
class="coupon-section__points"
hover-class="mi-tap-card--hover"
:hover-stay-time="150"
@tap="$emit('redeem-points')"
>
<view class="coupon-section__points-inner">
<text class="coupon-section__num">{{ data.points }}</text>
<text class="coupon-section__points-label">{{ data.pointsLabel }}</text>
<view class="coupon-section__points-action">
<text class="coupon-section__text">{{ data.pointsAction }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<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 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';
</style>
@@ -0,0 +1,186 @@
<template>
<view class="profile-header">
<!-- 登录状态 -->
<view class="profile-card" v-if="isLogin" hover-class="mi-tap--hover" :hover-stay-time="150" @tap="handleUserTap">
<view class="profile-card__main">
<view class="profile-avatar">
<image
class="profile-avatar__img"
:key="userInfo.avatar"
:src="displayAvatar"
mode="aspectFill"
/>
</view>
<view class="profile-info">
<view class="profile-info__row">
<text class="profile-info__name">{{ userInfo.name || "活氧舱用户" }}</text>
<view class="profile-info__badge">
<image class="profile-info__badge-icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/crown0.png" mode="aspectFit" />
<text class="profile-info__level">{{ userInfo.memberLevel }}</text>
</view>
</view>
<text class="profile-info__phone">{{ userInfo.phone }}</text>
</view>
</view>
<view class="profile-card__stats">
<view class="profile-stat">
<text class="profile-stat__value">{{ stats.checkInCount ?? 0 }}</text>
<text class="profile-stat__label">累计签到</text>
</view>
<view class="profile-stat">
<text class="profile-stat__value">{{ stats.courseCount ?? 0 }}</text>
<text class="profile-stat__label">报课次数</text>
</view>
</view>
</view>
<!-- 未登录状态 -->
<view class="profile-card profile-card--guest" v-else @tap="handleGuestLogin">
<view class="profile-card__main">
<view class="profile-avatar">
<image class="profile-avatar__img" src="/static/OIP-C.png" mode="aspectFill" />
</view>
<view class="profile-info">
<text class="profile-info__name">小伙伴点我登录</text>
<text class="profile-info__phone">陪你遇见更好的自己 ~</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
userInfo: { type: Object, required: true },
stats: { type: Object, required: true },
isLogin: { type: Boolean }
})
const emit = defineEmits(['user-info', 'guest-login'])
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 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>
@@ -0,0 +1,25 @@
<template>
<view class="logout-section">
<view
class="logout-section__btn"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="$emit('logout')"
>
<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>
</template>
<script setup>
defineEmits(['logout'])
</script>
<style lang="scss">
@import '@/common/style/memberInfo/member-info-logout.css';
</style>
@@ -0,0 +1,48 @@
<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 actions"
: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">
<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>
</template>
<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 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';
</style>
@@ -0,0 +1,97 @@
<template>
<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="$emit('view-rules')"
>
<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">{{ data.code }}</text>
</view>
<view
class="referral-section__copy-btn"
hover-class="mi-tap-btn--hover"
:hover-stay-time="150"
@tap="copyCode"
>
<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">
{{ data.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">
{{ data.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">
{{ data.purchased }}
</text>
<text class="referral-section__stat-label">已购课</text>
</view>
</view>
</view>
</view>
</template>
<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 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';
</style>
@@ -0,0 +1,89 @@
<template>
<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 items"
: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="$emit('setting', 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>
</template>
<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'
},
{
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 lang="scss">
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-settings.css';
</style>
@@ -0,0 +1,14 @@
<template>
<view class="status-bar">
<view class="status-bar__inner">
<text class="status-bar__time">{{ statusBarTime }}</text>
<text class="status-bar__icons">...</text>
</view>
</view>
</template>
<script setup>
defineProps({
statusBarTime: { type: String, default: '9:41' }
})
</script>
@@ -0,0 +1,101 @@
<template>
<view class="sub-nav">
<view class="sub-nav__toolbar" :style="toolbarStyle">
<view class="sub-nav__nav">
<view class="sub-nav__back" @tap.stop="$emit('back')">
<image
class="sub-nav__back-icon"
src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronleft.png"
mode="aspectFit"
/>
</view>
<text class="sub-nav__title">{{ title }}</text>
<view class="sub-nav__right">
<view
v-if="rightText"
class="sub-nav__action"
:class="{ 'sub-nav__action--button': actionButton }"
@tap.stop="$emit('right-action')"
>
<text class="sub-nav__action-text">{{ rightText }}</text>
</view>
<view class="sub-nav__capsule" :class="{ 'sub-nav__capsule--h5': isH5 }" :style="capsuleStyle"></view>
</view>
</view>
</view>
<view class="sub-nav__spacer" :style="toolbarSpacerStyle"></view>
</view>
</template>
<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'
}
toolbarStyle.value = {
paddingTop: `${statusBarHeight}px`
}
toolbarSpacerStyle.value = {
height: `${statusBarHeight + navHeight + extraGap}px`
}
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 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>
@@ -0,0 +1,320 @@
import { ref, computed, watch } from 'vue'
import { getGroupCoursePage, getTypeLabels, searchGroupCourse } from '@/api/groupCourse.js'
export function useGroupCourseList() {
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', 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 },
{ label: '下午 (12-18 点)', value: 'afternoon', startHour: 12, endHour: 18 },
{ label: '晚上 (18-24 点)', value: 'evening', startHour: 18, endHour: 24 }
])
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]
if (startDate.value && courseDate < startDate.value) return false
if (endDate.value && courseDate > endDate.value) return false
return true
})
}
const timePeriod = timePeriodOptions.value[timePeriodIndex.value]
if (timePeriod.value !== 'all') {
result = result.filter(course => {
const courseHour = new Date(course.startTime).getHours()
return courseHour >= timePeriod.startHour && courseHour < timePeriod.endHour
})
}
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 }
const timePeriodParams = timePeriodRef?.getTimePeriodParams?.() || { index: timePeriodIndex.value, value: timePeriodOptions.value[timePeriodIndex.value].value }
const timeRangeParams = timeRangePickerRef?.getTimeRangeParams?.() || { startDate: startDate.value, endDate: endDate.value, timeRangeText: timeRangeText.value }
const allParams = {
search: searchParams,
filter: filterParams,
timePeriod: timePeriodParams,
timeRange: timeRangeParams
}
console.log('[useGroupCourseList] 获取所有搜索参数:', allParams)
return allParams
}
const handleSearch = (params) => {
console.log('[useGroupCourseList] 搜索触发:', params)
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.navigateTo({
url: `/pages/groupCourse/detail?id=${course.id}`
})
}
const goDetail = (courseId) => {
console.log('[useGroupCourseList] 跳转到课程详情:', courseId)
uni.navigateTo({
url: `/pages/groupCourse/detail?id=${courseId}`
})
}
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 currentPage = isLoadMore ? pageNum.value + 1 : pageNum.value
pageNum.value = currentPage
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]
} else {
courseList.value = list
}
total.value = totalCount
totalPages.value = pages
hasMore.value = currentPage < pages - 1
console.log('[useGroupCourseList] 团课列表获取成功:', {
total: total.value,
currentPage: pageNum.value,
totalPages: totalPages.value,
hasMore: hasMore.value
})
} else {
console.error('[useGroupCourseList] 获取团课列表失败:', {
result: result,
message: result?.message || '未知错误',
code: result?.code,
success: result?.success
})
}
} catch (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
fetchCourseList(true)
}
const onScrollToLower = () => {
loadMore()
}
return {
pageNum,
pageSize,
total,
totalPages,
loading,
hasMore,
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
}
}
+234
View File
@@ -0,0 +1,234 @@
# 团课管理系统 API 文档
## 1. 项目概述
本项目是一个基于 UniApp 的健身房团课管理系统,包含完整的 API 层设计,支持开发/生产环境的快速切换。
### 1.1 项目结构
```
gym-manage-uniapp/
├── api/ # API 层目录
│ ├── requestBase.js # 基础请求封装
│ ├── groupCourse.js # 团课真实API
│ ├── groupCourse.mock.js # 团课模拟数据API
│ └── envConfig.js # 环境配置与服务导出
├── pages/
│ └── groupCourse/
│ └── list.vue # 团课列表页面
└── components/ # 组件目录
```
---
## 2. API 层设计
### 2.1 架构设计
| 层级 | 文件 | 职责 |
|------|------|------|
| 基础层 | `requestBase.js` | 封装 uni.request,统一处理加载状态、错误提示 |
| 接口层 | `groupCourse.js` | 定义真实后端API接口 |
| 模拟层 | `groupCourse.mock.js` | 提供模拟数据,支持开发测试 |
| 配置层 | `envConfig.js` | 环境配置,统一服务导出入口 |
### 2.2 环境切换机制
通过修改 `envConfig.js` 中的 `ENV_MODE` 变量实现环境切换:
```javascript
// envConfig.js
export const ENV_MODE = 'development' // 'production' | 'development'
```
- **`production`**:生产环境,使用真实网络请求
- **`development`**:开发环境,使用模拟数据
---
## 3. 文件详细说明
### 3.1 requestBase.js - 基础请求封装
**功能定位**:封装 `uni.request`,提供统一的请求处理逻辑。
**核心特性**
- 自动显示/隐藏加载提示
- 统一的响应状态码处理
- 统一的错误提示机制
**接口定义**
| 方法 | 说明 | 参数 | 返回值 |
|------|------|------|--------|
| `request(options)` | 发起网络请求 | `options` 对象 | `Promise` |
**options 参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `url` | string | 是 | 请求路径 |
| `method` | string | 否 | 请求方法,默认 `GET` |
| `data` | object | 否 | 请求数据 |
| `header` | object | 否 | 请求头 |
**响应数据结构**
```javascript
// 成功响应
{
code: 0, // 状态码,0表示成功
message: 'success', // 提示信息
data: {} // 业务数据
}
```
### 3.2 groupCourse.js - 团课真实 API
**功能定位**:定义团课相关的真实后端接口。
**接口列表**
| 方法 | 说明 | 参数 | HTTP方法 | 路径 |
|------|------|------|----------|------|
| `getList()` | 获取团课列表 | 无 | GET | `/api/groupCourse/list` |
| `getDetail(id)` | 获取团课详情 | `id`: 课程ID | GET | `/api/groupCourse/detail/{id}` |
| `book(data)` | 预约团课 | `data`: 预约数据 | POST | `/api/groupCourse/book` |
| `cancelBooking(id)` | 取消预约 | `id`: 预约记录ID | POST | `/api/groupCourse/cancel/{id}` |
**book 方法参数结构**
```javascript
{
courseId: string, // 课程ID
memberId: string // 会员ID
}
```
### 3.3 groupCourse.mock.js - 团课模拟数据 API
**功能定位**:提供模拟数据,支持开发测试,无需后端服务。
**特性**
- 模拟网络延迟(300-500ms
- 接口签名与真实API完全一致
- 包含6条完整的模拟团课数据
**模拟数据结构**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | string | 课程唯一标识 |
| `courseName` | string | 课程名称 |
| `coachName` | string | 教练姓名 |
| `coachAvatar` | string | 教练头像 |
| `startTime` | string | 开始时间(ISO格式) |
| `endTime` | string | 结束时间(ISO格式) |
| `duration` | number | 课程时长(分钟) |
| `location` | string | 上课地点 |
| `maxMembers` | number | 最大人数 |
| `currentMembers` | number | 当前人数 |
| `storedValueAmount` | number | 储值卡价格(元) |
| `pointCardAmount` | number | 次卡次数 |
| `courseType` | string | 课程类型 |
| `level` | string | 难度级别 |
| `description` | string | 课程描述 |
| `tags` | array | 标签列表 |
| `status` | string | 状态(available/closed |
### 3.4 envConfig.js - 环境配置
**功能定位**:统一服务导出入口,根据环境模式自动选择 API 实现。
**导出内容**
| 导出项 | 类型 | 说明 |
|--------|------|------|
| `ENV_MODE` | string | 当前环境模式 |
| `groupCourseService` | object | 团课服务实例 |
---
## 4. 使用示例
### 4.1 在页面中使用
```javascript
// pages/groupCourse/list.vue
import { groupCourseService } from '@/api/envConfig.js'
// 获取团课列表
const fetchCourseList = async () => {
try {
const data = await groupCourseService.getList()
courseList.value = data
console.log('团课列表获取成功:', data)
} catch (error) {
console.error('获取团课列表异常:', error)
}
}
```
### 4.2 切换环境
```javascript
// api/envConfig.js
// 开发环境 - 使用模拟数据
export const ENV_MODE = 'development'
// 生产环境 - 使用真实网络请求
export const ENV_MODE = 'production'
```
---
## 5. 数据流转图
```
┌─────────────────────────────────────────────────────────────┐
│ 页面层 (View) │
│ pages/groupCourse/list.vue │
└───────────────────────────┬─────────────────────────────────┘
│ import & call
┌─────────────────────────────────────────────────────────────┐
│ 环境配置层 (Config) │
│ api/envConfig.js │
│ 根据 ENV_MODE 选择对应的服务实现 │
└───────────────────────────┬─────────────────────────────────┘
┌─────────────────┴─────────────────┐
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────────┐
│ groupCourse.js │ │ groupCourse.mock.js │
│ (production 环境) │ │ (development 环境) │
└───────────┬─────────────┘ └─────────────┬───────────────┘
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────┐
│ requestBase.js │ │ 本地模拟数据 │
│ (真实网络请求) │ │ (无需后端) │
└───────────┬─────────────┘ └─────────────────────┘
┌─────────────────────────┐
│ 后端服务器 API │
└─────────────────────────┘
```
---
## 6. 注意事项
1. **环境变量配置**:部署前务必确认 `ENV_MODE` 设置正确
2. **数据一致性**:模拟数据结构应与真实API保持一致
3. **错误处理**:所有API调用都应包含 try-catch 错误处理
4. **日志记录**:建议在关键节点添加日志,便于调试
---
## 7. 版本历史
| 版本 | 日期 | 更新内容 |
|------|------|----------|
| v1.0 | 2026-06-04 | 初始版本,完成基础API层设计 |
+570
View File
@@ -0,0 +1,570 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>健身房管理系统 | 动感配色方案</title>
<!-- 引入Font Awesome 6 (免费图标库,增强视觉) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
body {
background: #F5F7FC; /* 整体背景柔和灰白,衬托主色 */
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
padding: 24px 20px 48px;
color: #1E2A3A;
}
/* 主色调变量定义 – 活力运动风格
主色: 动感深蓝 (#0B2B4B) – 权威、专业、沉稳
辅色: 活力橙 (#FF6B35) – 热情、能量、行动召唤
背景浅色: #F9FAFE, 卡片白, 强调蓝绿渐变点缀
*/
:root {
--primary-dark: #0B2B4B; /* 深蓝主色,用于头部、重要按钮、边框强调 */
--primary-deep: #1A4A6F; /* 中深蓝,用于hover、次级强调 */
--accent-orange: #FF6B35; /* 活力橙,CTA、会员卡片、高亮标签 */
--accent-orange-light: #FF8C5A;
--bg-light: #F9FAFE;
--card-white: #FFFFFF;
--text-dark: #1E2A3A;
--text-muted: #5E6F8D;
--border-light: #E9EDF2;
--success-green: #2ECC71;
--warning-amber: #F39C12;
--shadow-sm: 0 8px 20px rgba(0, 0, 0, 0.03), 0 2px 6px rgba(0, 0, 0, 0.05);
--shadow-md: 0 12px 28px rgba(0, 0, 0, 0.08);
--gradient-orange: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
--gradient-blue: linear-gradient(135deg, #0B2B4B 0%, #1A4A6F 100%);
}
/* 整体容器 */
.demo-container {
max-width: 450px;
margin: 0 auto;
background: var(--bg-light);
border-radius: 36px;
box-shadow: var(--shadow-md);
overflow: hidden;
position: relative;
}
/* 状态栏模拟 (小程序顶部风格) */
.status-bar {
background: var(--primary-dark);
padding: 12px 20px 6px;
color: white;
display: flex;
justify-content: space-between;
font-size: 14px;
font-weight: 500;
}
/* 主头部导航 */
.main-header {
background: var(--primary-dark);
padding: 12px 20px 20px;
color: white;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 20px;
}
.logo-area h2 {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.3px;
display: flex;
align-items: center;
gap: 6px;
}
.logo-area h2 i {
color: var(--accent-orange);
font-size: 1.7rem;
}
.logo-area p {
font-size: 0.7rem;
opacity: 0.8;
letter-spacing: 0.5px;
}
.header-actions i {
font-size: 1.4rem;
background: rgba(255,255,255,0.15);
padding: 8px;
border-radius: 30px;
margin-left: 8px;
cursor: default;
}
/* 欢迎卡片 + 活力橙强调 */
.welcome-card {
background: white;
border-radius: 28px;
padding: 18px 20px;
margin-top: 12px;
box-shadow: var(--shadow-sm);
display: flex;
justify-content: space-between;
align-items: center;
}
.welcome-text h4 {
font-size: 1rem;
color: var(--text-muted);
font-weight: 500;
}
.welcome-text h3 {
font-size: 1.4rem;
margin-top: 4px;
color: var(--primary-dark);
}
.badge-member {
background: var(--gradient-orange);
padding: 8px 16px;
border-radius: 40px;
color: white;
font-weight: 600;
font-size: 0.8rem;
box-shadow: 0 4px 8px rgba(255,107,53,0.2);
}
/* 主要指标卡片区 */
.stats-grid {
display: flex;
gap: 12px;
padding: 20px;
background: var(--bg-light);
}
.stat-card {
background: var(--card-white);
flex: 1;
border-radius: 24px;
padding: 14px 0;
text-align: center;
box-shadow: var(--shadow-sm);
transition: all 0.2s;
border: 1px solid var(--border-light);
}
.stat-card i {
font-size: 1.8rem;
color: var(--accent-orange);
margin-bottom: 6px;
}
.stat-number {
font-size: 1.6rem;
font-weight: 800;
color: var(--primary-dark);
}
.stat-label {
font-size: 0.7rem;
color: var(--text-muted);
}
/* 功能菜单 grid */
.menu-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 8px 20px 20px;
}
.menu-item {
background: var(--card-white);
border-radius: 20px;
padding: 12px 6px;
text-align: center;
transition: all 0.2s ease;
border: 1px solid var(--border-light);
cursor: default;
}
.menu-item i {
font-size: 1.8rem;
color: var(--primary-deep);
margin-bottom: 6px;
display: block;
}
.menu-item span {
font-size: 0.7rem;
font-weight: 500;
color: var(--text-dark);
}
.menu-item:active {
transform: scale(0.96);
background: #FEF5F0;
}
/* 课程/热门活动区域 */
.section-title {
display: flex;
justify-content: space-between;
padding: 8px 20px 4px;
align-items: baseline;
}
.section-title h3 {
font-size: 1.2rem;
font-weight: 700;
color: var(--primary-dark);
}
.section-title a {
font-size: 0.75rem;
color: var(--accent-orange);
font-weight: 600;
}
.class-list {
padding: 8px 20px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.class-card {
background: white;
border-radius: 20px;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 14px;
box-shadow: var(--shadow-sm);
border-left: 5px solid var(--accent-orange);
transition: all 0.2s;
}
.class-icon {
width: 48px;
height: 48px;
background: rgba(255,107,53,0.1);
border-radius: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.class-icon i {
font-size: 1.8rem;
color: var(--accent-orange);
}
.class-info {
flex: 1;
}
.class-info h4 {
font-size: 1rem;
font-weight: 700;
}
.class-info p {
font-size: 0.7rem;
color: var(--text-muted);
margin-top: 4px;
}
.class-time {
font-size: 0.75rem;
background: #F0F3F9;
padding: 4px 10px;
border-radius: 40px;
font-weight: 600;
color: var(--primary-deep);
}
/* 底部导航栏 (Tab Bar 模拟) */
.bottom-tab {
background: white;
backdrop-filter: blur(0px);
border-top: 1px solid var(--border-light);
display: flex;
justify-content: space-around;
padding: 10px 20px 22px;
border-radius: 0 0 36px 36px;
}
.tab-item {
text-align: center;
color: var(--text-muted);
transition: 0.1s;
cursor: default;
}
.tab-item i {
font-size: 1.5rem;
}
.tab-item span {
font-size: 0.7rem;
display: block;
margin-top: 4px;
}
.tab-item.active {
color: var(--accent-orange);
font-weight: 600;
}
/* 教练推荐卡片额外装饰 */
.trainer-spot {
background: var(--gradient-blue);
margin: 0 20px 20px;
border-radius: 28px;
padding: 16px 18px;
display: flex;
align-items: center;
justify-content: space-between;
color: white;
}
.trainer-info h4 {
font-size: 0.9rem;
opacity: 0.9;
}
.trainer-info h3 {
font-size: 1.1rem;
font-weight: 700;
}
.trainer-btn {
background: var(--accent-orange);
border: none;
padding: 8px 18px;
border-radius: 50px;
color: white;
font-weight: 600;
font-size: 0.8rem;
cursor: default;
}
/* 配色展示条 / 设计说明 */
.color-palette-show {
max-width: 450px;
margin: 28px auto 0;
background: white;
border-radius: 32px;
padding: 18px 20px;
box-shadow: var(--shadow-sm);
}
.color-title {
font-weight: 700;
margin-bottom: 12px;
color: var(--primary-dark);
font-size: 1rem;
display: flex;
align-items: center;
gap: 8px;
}
.color-swatches {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.swatch {
flex: 1;
min-width: 70px;
text-align: center;
}
.swatch-circle {
height: 50px;
border-radius: 16px;
margin-bottom: 8px;
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
}
.swatch span {
font-size: 0.7rem;
font-weight: 500;
color: var(--text-dark);
}
hr {
margin: 20px 0 8px;
border: none;
height: 1px;
background: var(--border-light);
}
.note {
font-size: 0.7rem;
color: var(--text-muted);
text-align: center;
margin-top: 12px;
}
i, .tab-item, .menu-item, .stat-card {
cursor: default;
}
</style>
</head>
<body>
<div class="demo-container">
<!-- 模拟小程序状态栏/胶囊区 -->
<div class="status-bar">
<span>9:41</span>
<span><i class="fas fa-signal"></i> <i class="fas fa-battery-full"></i></span>
</div>
<!-- 主头部深蓝区 -->
<div class="main-header">
<div class="header-top">
<div class="logo-area">
<h2><i class="fas fa-dumbbell"></i> IRONPULSE</h2>
<p>智能健身 · 即刻燃动</p>
</div>
<div class="header-actions">
<i class="fas fa-bell"></i>
<i class="fas fa-user-circle"></i>
</div>
</div>
<!-- 用户欢迎卡片,橙色高亮会员 -->
<div class="welcome-card">
<div class="welcome-text">
<h4>欢迎回来,</h4>
<h3>张峻铭 <span style="font-size: 0.8rem;">💪</span></h3>
</div>
<div class="badge-member">MVP 黑金会员</div>
</div>
</div>
<!-- 关键指标 活力橙点缀 -->
<div class="stats-grid">
<div class="stat-card">
<i class="fas fa-fire"></i>
<div class="stat-number">1,280</div>
<div class="stat-label">今日卡路里</div>
</div>
<div class="stat-card">
<i class="fas fa-clock"></i>
<div class="stat-number">126</div>
<div class="stat-label">运动分钟</div>
</div>
<div class="stat-card">
<i class="fas fa-calendar-check"></i>
<div class="stat-number">12</div>
<div class="stat-label">本月课程</div>
</div>
</div>
<!-- 功能区 网格菜单(四大模块) -->
<div class="menu-grid">
<div class="menu-item"><i class="fas fa-qrcode"></i><span>签到</span></div>
<div class="menu-item"><i class="fas fa-chalkboard-user"></i><span>团课预约</span></div>
<div class="menu-item"><i class="fas fa-chart-line"></i><span>体测报告</span></div>
<div class="menu-item"><i class="fas fa-cog"></i><span>我的会籍</span></div>
</div>
<!-- 热门课程板块 (动态橙边风格) -->
<div class="section-title">
<h3><i class="fas fa-heartbeat" style="color: var(--accent-orange); margin-right: 6px;"></i> 热门团课</h3>
<a href="#">查看全部 →</a>
</div>
<div class="class-list">
<div class="class-card">
<div class="class-icon"><i class="fas fa-bicycle"></i></div>
<div class="class-info">
<h4>极速燃脂 · 动感单车</h4>
<p>张教练 | 综合有氧</p>
</div>
<div class="class-time">19:30 满员</div>
</div>
<div class="class-card">
<div class="class-icon"><i class="fas fa-hand-fist"></i></div>
<div class="class-info">
<h4>搏击风暴 · 泰拳基础</h4>
<p>李娜 | 格斗区</p>
</div>
<div class="class-time">18:00 火热</div>
</div>
<div class="class-card">
<div class="class-icon"><i class="fas fa-person-walking"></i></div>
<div class="class-info">
<h4>普拉提核心唤醒</h4>
<p>Elena | 瑜伽室</p>
</div>
<div class="class-time">明早 09:30</div>
</div>
</div>
<!-- 教练推荐 使用深蓝渐变+橙色按钮 -->
<div class="trainer-spot">
<div class="trainer-info">
<h4>🌟 明星私教推荐</h4>
<h3>Alex 王 · 增肌塑形专家</h3>
<p style="font-size: 0.7rem; opacity:0.85;">剩余3个时段可约</p>
</div>
<div class="trainer-btn">立即预约</div>
</div>
<!-- 底部tab栏 当前选中“首页”凸显橙色-->
<div class="bottom-tab">
<div class="tab-item active"><i class="fas fa-home"></i><span>首页</span></div>
<div class="tab-item"><i class="fas fa-calendar-alt"></i><span>日程</span></div>
<div class="tab-item"><i class="fas fa-chart-simple"></i><span>数据</span></div>
<div class="tab-item"><i class="fas fa-user"></i><span>我的</span></div>
</div>
</div>
<!-- 配色方案展示区,直观看到颜色搭配,帮助设计决策 -->
<div class="color-palette-show">
<div class="color-title">
<i class="fas fa-palette" style="color: #FF6B35;"></i> 健身房管理系统 · 能量配色方案
</div>
<div class="color-swatches">
<div class="swatch">
<div class="swatch-circle" style="background: #0B2B4B;"></div>
<span>深蓝主色 #0B2B4B<br>专业信赖</span>
</div>
<div class="swatch">
<div class="swatch-circle" style="background: #FF6B35;"></div>
<span>活力橙 #FF6B35<br>行动/热情</span>
</div>
<div class="swatch">
<div class="swatch-circle" style="background: #1A4A6F;"></div>
<span>深邃辅助 #1A4A6F</span>
</div>
<div class="swatch">
<div class="swatch-circle" style="background: #F9FAFE; border:1px solid #E2E8F0;"></div>
<span>浅色背景 #F9FAFE</span>
</div>
<div class="swatch">
<div class="swatch-circle" style="background: #FFFFFF; border:1px solid #E2E8F0;"></div>
<span>卡片白 #FFFFFF</span>
</div>
</div>
<hr>
<div class="note">
<i class="fas fa-lightbulb" style="color: #FF6B35;"></i> 设计理念:深蓝色传递专业与稳定感,活力橙色提升运动热情与CTA转化。<br>
圆润卡片 + 强烈对比,适合健身管理小程序的年轻、力量与现代氛围。
</div>
</div>
<!-- 额外注释:颜色可访问性强,橙蓝互补但明度舒适 -->
</body>
</html>
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": "错误描述信息"
}
```
---
*文档结束*
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
import App from './App'
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
Vue.config.productionTip = false
App.mpType = 'app'
const app = new Vue({
...App
})
app.$mount()
// #endif
// #ifdef VUE3
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
// 全局混入:所有页面加载时自动隐藏 loading
app.mixin({
onLoad() {
// 页面加载完成,隐藏 loading
uni.hideLoading()
},
async onReady() {
uni.hideLoading()
}
})
return {
app
}
}
// #endif
+101 -36
View File
@@ -1,24 +1,31 @@
{
"name": "gym-manage-uniapp",
"appid": "",
"description": "Gym Management System Mobile App",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
"name" : "活氧舱",
"appid" : "__UNI__52E2F0D",
"description" : "",
"versionName" : "1.0.1",
"versionCode" : 101,
"transformPx" : false,
/* 5+App */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
/* */
"modules" : {
"Barcode" : {},
"OAuth" : {}
},
/* */
"distribute" : {
/* android */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
@@ -36,29 +43,87 @@
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
"ios": {},
"sdkConfigs": {}
/* ios */
"ios" : {
"dSYMs" : false
},
/* SDK */
"sdkConfigs" : {
"oauth" : {
"univerify" : {}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false
"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"
},
"usingComponents": true
"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"
},
"mp-alipay": {
"usingComponents": true
"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"
}
}
}
},
"mp-baidu": {
"usingComponents": true
"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" : {}
}
}
}
},
"mp-toutiao": {
"usingComponents": true
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "wx8f0d644d1d8985f6",
"setting" : {
"urlCheck" : false
},
"uniStatistics": {
"enable": false
"usingComponents" : true
},
"vueVersion": "3"
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3"
}
-19
View File
@@ -1,19 +0,0 @@
{
"name": "gym-manage-uniapp",
"version": "1.0.0",
"description": "Gym Management System Mobile App",
"main": "main.js",
"scripts": {
"dev:mp-weixin": "uni -p mp-weixin",
"build:mp-weixin": "uni build -p mp-weixin",
"dev:h5": "uni",
"build:h5": "uni build"
},
"keywords": [
"uniapp",
"gym",
"management"
],
"author": "Novalon",
"license": "MIT"
}

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