初步完成页面

This commit is contained in:
时舟年
2026-06-04 14:30:22 +08:00
85 changed files with 10736 additions and 36 deletions
+13
View File
@@ -0,0 +1,13 @@
/**
* 环境配置文件
* 当前仅使用模拟数据(开发模式)
*/
import { groupCourseMockApi } from './groupCourse.mock.js'
/**
* 团课服务(仅使用模拟数据)
*/
export const groupCourseService = groupCourseMockApi
export default groupCourseService
+407
View File
@@ -0,0 +1,407 @@
/**
* 团课模拟数据(测试环境使用)
* 数据格式与后端返回格式保持一致
*/
// 模拟团课列表数据(与后端返回格式一致)
const mockCourseList = [
{
"id": "1",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-06-01T11:00:00",
"updatedAt": "2026-06-01T11:00:00",
"deletedAt": null,
"courseName": "极速燃脂单车",
"coachId": "104",
"courseType": "2",
"startTime": "2026-06-02T16:45:00",
"endTime": "2026-06-15T20:20:00",
"maxMembers": 25,
"currentMembers": 0,
"status": "0",
"location": "单车房",
"coverImage": "https://picsum.photos/seed/spinning/640/360",
"description": "跟随音乐节奏变换阻力和速度,体验爬坡与冲刺的快感,一节课消耗800大卡。支持次数卡(1次)或储值卡(50元)支付。",
"pointCardAmount": 0,
"storedValueAmount": 0
},
{
"id": "3",
"createBy": "coach_zhang",
"updateBy": null,
"createdAt": "2026-06-01T14:30:00",
"updatedAt": "2026-06-01T14:30:00",
"deletedAt": null,
"courseName": "燃脂搏击",
"coachId": "102",
"courseType": "2",
"startTime": "2026-06-10T18:30:00",
"endTime": "2026-06-10T19:30:00",
"maxMembers": 20,
"currentMembers": 20,
"status": "0",
"location": "综合训练区",
"coverImage": "https://picsum.photos/seed/kickboxing/640/360",
"description": "高强度间歇训练,配合音乐快速燃脂,释放压力。名额已满,无法预约。支持次数卡(1次)或储值卡(60元)支付。",
"pointCardAmount": 1,
"storedValueAmount": 60
},
{
"id": "4",
"createBy": "coach_li",
"updateBy": null,
"createdAt": "2026-06-01T08:00:00",
"updatedAt": "2026-06-01T08:00:00",
"deletedAt": null,
"courseName": "哈他瑜伽",
"coachId": "101",
"courseType": "1",
"startTime": "2026-06-01T15:20:00",
"endTime": "2026-06-01T16:50:00",
"maxMembers": 12,
"currentMembers": 3,
"status": "0",
"location": "瑜伽教室B",
"coverImage": "https://picsum.photos/seed/yoga/640/360",
"description": "基础哈他瑜伽,适合所有级别。距开始不足30分钟,已停止预约。支持次数卡(1次)或储值卡(40元)支付。",
"pointCardAmount": 1,
"storedValueAmount": 40
},
{
"id": "5",
"createBy": "coach_wang",
"updateBy": null,
"createdAt": "2026-05-28T08:00:00",
"updatedAt": "2026-05-28T08:00:00",
"deletedAt": null,
"courseName": "周末冥想修复",
"coachId": "101",
"courseType": "1",
"startTime": "2026-06-20T15:00:00",
"endTime": "2026-06-20T16:00:00",
"maxMembers": 12,
"currentMembers": 3,
"status": "1",
"location": "冥想室",
"coverImage": "https://picsum.photos/seed/meditation/640/360",
"description": "通过呼吸和正念冥想,深度放松身心。该课程已被取消。支持次数卡(1次)或储值卡(30元)支付。",
"pointCardAmount": 1,
"storedValueAmount": 30
},
{
"id": "6",
"createBy": "coach_li",
"updateBy": null,
"createdAt": "2026-05-20T09:15:00",
"updatedAt": "2026-05-20T09:15:00",
"deletedAt": null,
"courseName": "蜜桃臀塑造",
"coachId": "103",
"courseType": "3",
"startTime": "2026-05-30T19:00:00",
"endTime": "2026-05-30T20:00:00",
"maxMembers": 10,
"currentMembers": 8,
"status": "2",
"location": "私教专区",
"coverImage": "https://picsum.photos/seed/glute/640/360",
"description": "针对性训练臀部肌肉群,课程已于5月30日结束,无法预约。支持次数卡(1次)或储值卡(80元)支付。",
"pointCardAmount": 1,
"storedValueAmount": 80
},
{
"id": "7",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-05-25T09:00:00",
"updatedAt": "2026-05-25T09:00:00",
"deletedAt": null,
"courseName": "午间冥想放松",
"coachId": "101",
"courseType": "1",
"startTime": "2026-05-31T12:00:00",
"endTime": "2026-05-31T13:00:00",
"maxMembers": 15,
"currentMembers": 6,
"status": "2",
"location": "冥想室",
"coverImage": "https://picsum.photos/seed/noonmeditation/640/360",
"description": "午间冥想课程,已于5月31日结束。支持次数卡(1次)或储值卡(25元)支付。",
"pointCardAmount": 1,
"storedValueAmount": 25
},
{
"id": "8",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-06-01T10:00:00",
"updatedAt": "2026-06-01T10:00:00",
"deletedAt": null,
"courseName": "燃脂搏击_次数卡课程",
"coachId": "102",
"courseType": "2",
"startTime": "2026-06-10T19:30:00",
"endTime": "2026-06-10T20:30:00",
"maxMembers": 20,
"currentMembers": 0,
"status": "0",
"location": "综合训练区",
"coverImage": null,
"description": "高强度间歇训练,配合音乐快速燃脂,消耗1次",
"pointCardAmount": 1,
"storedValueAmount": 0
},
{
"id": "9",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-06-01T10:00:00",
"updatedAt": "2026-06-01T10:00:00",
"deletedAt": null,
"courseName": "高端普拉提_储值卡课程",
"coachId": "103",
"courseType": "1",
"startTime": "2026-06-11T19:00:00",
"endTime": "2026-06-11T20:00:00",
"maxMembers": 15,
"currentMembers": 0,
"status": "0",
"location": "普拉提教室",
"coverImage": null,
"description": "精准训练核心肌群,消耗储值50元",
"pointCardAmount": 0,
"storedValueAmount": 20
},
{
"id": "11",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-06-02T10:00:00",
"updatedAt": "2026-06-02T10:00:00",
"deletedAt": null,
"courseName": "时间冲突测试_A_13点-15点",
"coachId": "102",
"courseType": "2",
"startTime": "2026-06-15T13:00:00",
"endTime": "2026-06-15T15:00:00",
"maxMembers": 20,
"currentMembers": 0,
"status": "0",
"location": "综合训练区",
"coverImage": null,
"description": "测试用团课A,用于验证时间冲突检测。支持次数卡(1次)或储值卡(50元)支付。",
"pointCardAmount": 1,
"storedValueAmount": 50
},
{
"id": "12",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-06-02T10:00:00",
"updatedAt": "2026-06-02T10:00:00",
"deletedAt": null,
"courseName": "时间冲突测试_B_14点-16点",
"coachId": "103",
"courseType": "1",
"startTime": "2026-06-15T14:00:00",
"endTime": "2026-06-15T16:00:00",
"maxMembers": 15,
"currentMembers": 0,
"status": "0",
"location": "普拉提教室",
"coverImage": null,
"description": "测试用团课B,与团课A时间重叠(14:00-15:00)。支持次数卡(1次)或储值卡(50元)支付。",
"pointCardAmount": 1,
"storedValueAmount": 50
},
{
"id": "13",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-06-02T10:00:00",
"updatedAt": "2026-06-02T10:00:00",
"deletedAt": null,
"courseName": "时间冲突测试_C_10点-12点",
"coachId": "101",
"courseType": "1",
"startTime": "2026-06-15T10:00:00",
"endTime": "2026-06-15T12:00:00",
"maxMembers": 15,
"currentMembers": 0,
"status": "0",
"location": "瑜伽教室",
"coverImage": null,
"description": "测试用团课C,与团课A/B不冲突。支持次数卡(1次)或储值卡(50元)支付。",
"pointCardAmount": 1,
"storedValueAmount": 50
},
{
"id": "14",
"createBy": "system",
"updateBy": "system",
"createdAt": "2026-06-02T17:32:50.532336",
"updatedAt": "2026-06-02T17:32:50.532336",
"deletedAt": null,
"courseName": "动感单车aaa",
"coachId": "2",
"courseType": "2",
"startTime": "2026-06-05T18:00:00",
"endTime": "2026-06-05T19:00:00",
"maxMembers": 25,
"currentMembers": 0,
"status": "0",
"location": "健身房B区",
"coverImage": "https://example.com/spinning.jpg",
"description": "高强度有氧运动课程",
"pointCardAmount": 1,
"storedValueAmount": 50
},
{
"id": "2",
"createBy": "admin",
"updateBy": null,
"createdAt": "2026-06-01T10:00:00",
"updatedAt": "2026-06-02T17:35:35.155616",
"deletedAt": null,
"courseName": "清晨流瑜伽",
"coachId": "101",
"courseType": "1",
"startTime": "2026-06-12T09:00:00",
"endTime": "2026-06-12T10:30:00",
"maxMembers": 15,
"currentMembers": 6,
"status": "0",
"location": "A座3楼瑜伽教室",
"coverImage": "https://picsum.photos/seed/yogaflow/640/360",
"description": "适合有一定基础的学员,通过流畅的体式连接呼吸,唤醒身体能量。支持次数卡(1次)或储值卡(45元)支付。",
"pointCardAmount": 1,
"storedValueAmount": 45
},
{
"id": "15",
"createBy": "system",
"updateBy": "system",
"createdAt": "2026-06-02T17:57:27.483488",
"updatedAt": "2026-06-02T17:57:27.483488",
"deletedAt": null,
"courseName": "动感单车",
"coachId": "2",
"courseType": "2",
"startTime": "2026-06-05T18:00:00",
"endTime": "2026-06-05T19:00:00",
"maxMembers": 25,
"currentMembers": 0,
"status": "0",
"location": "健身房B区",
"coverImage": "https://example.com/spinning.jpg",
"description": "高强度有氧运动课程",
"pointCardAmount": 1,
"storedValueAmount": 50
}
]
/**
* 模拟团课API(测试环境)
* 接口签名与真实API保持一致
*/
export const groupCourseMockApi = {
/**
* 获取团课列表(支持分页)
* @param {Object} params - 查询参数
* @param {number} params.pageNum - 页码(从1开始)
* @param {number} params.pageSize - 每页数量
* @returns {Promise} - 分页团课列表数据
*/
getList: (params = {}) => {
return new Promise((resolve) => {
setTimeout(() => {
const pageNum = params.pageNum || 1
const pageSize = params.pageSize || 10
const total = mockCourseList.length
const startIndex = (pageNum - 1) * pageSize
const endIndex = startIndex + pageSize
const list = mockCourseList.slice(startIndex, endIndex)
console.log('[groupCourse.mock.js] 模拟获取团课列表(分页):', {
pageNum,
pageSize,
total,
listCount: list.length
})
resolve({
code: 0,
message: 'success',
data: {
list,
total,
pageNum,
pageSize,
totalPages: Math.ceil(total / pageSize)
}
})
}, 500)
})
},
/**
* 获取团课详情
* @param {string} id - 课程ID
* @returns {Promise} - 团课详情数据
*/
getDetail: (id) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('[groupCourse.mock.js] 模拟获取团课详情:', id)
const course = mockCourseList.find(item => item.id === id)
if (course) {
resolve(course)
} else {
reject({ code: -1, message: '课程不存在' })
}
}, 300)
})
},
/**
* 预约团课
* @param {Object} data - 预约数据
* @param {string} data.courseId - 课程ID
* @param {string} data.memberId - 会员ID
* @returns {Promise} - 预约结果
*/
book: (data) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log('[groupCourse.mock.js] 模拟预约团课:', data)
resolve({
code: 0,
message: '预约成功',
data: { bookingId: `BK${Date.now()}` }
})
}, 400)
})
},
/**
* 取消预约
* @param {string} id - 预约记录ID
* @returns {Promise} - 取消结果
*/
cancelBooking: (id) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log('[groupCourse.mock.js] 模拟取消预约:', id)
resolve({
code: 0,
message: '取消成功',
data: null
})
}, 300)
})
}
}
export default groupCourseMockApi
+2 -1
View File
@@ -1,4 +1,5 @@
const BASE_URL = 'http://localhost:8080/api'
// const BASE_URL = 'http://localhost:8080/api'
const BASE_URL = '/api'
export const request = (options) => {
return new Promise((resolve, reject) => {
+152
View File
@@ -0,0 +1,152 @@
import { request, setToken, clearToken, clearAllCache, clearCache } from '@/utils/request.js'
// ========== 登录相关API ==========
/**
* 微信小程序登录
* @param {object} data - 登录参数
* @param {string} data.code - 微信登录code
* @param {string} [data.encryptedData] - 加密数据
* @param {string} [data.iv] - 加密向量
* @returns {Promise} 登录结果
*/
export const login = (data) => {
return request({
url: '/member/auth/miniapp/login',
method: 'POST',
data: data,
needToken: false // 登录请求不需要token
}).then(res => {
// 登录成功,保存token
if (res.data && res.data.token) {
setToken(res.data.token)
}
return res
})
}
/**
* 退出登录
* @returns {Promise} 退出结果
*/
export const logout = () => {
return request({
url: '/member/auth/logout',
method: 'POST'
}).then(res => {
// 退出成功,清除token和缓存
clearToken()
clearAllCache()
return res
}).catch(err => {
// 即使请求失败,也清除本地token
clearToken()
clearAllCache()
throw err
})
}
// ========== 签到相关API ==========
/**
* 获取签到二维码
* @param {boolean} [cache=true] - 是否启用缓存
* @returns {Promise} 二维码数据
*/
export const getQRCode = (cache = true) => {
return request({
url: '/checkIn/qrcode',
method: 'GET',
cache: cache,
cacheTime: 5 * 60 * 1000 // 5分钟缓存
})
}
/**
* 扫码签到
* @param {string} qrContent - 二维码内容
* @returns {Promise} 签到结果
*/
export const checkIn = (qrContent) => {
return request({
url: '/checkIn/scan',
method: 'POST',
data: { qrContent }
})
}
// ========== 用户相关API ==========
/**
* 获取用户信息
* @param {boolean} [cache=true] - 是否启用缓存
* @returns {Promise} 用户信息
*/
export const getUserInfo = (cache = true) => {
return request({
url: '/member/info',
method: 'GET',
cache: cache,
cacheTime: 30 * 60 * 1000 // 30分钟缓存
})
}
/**
* 更新用户信息
* @param {object} data - 用户信息
* @returns {Promise} 更新结果
*/
export const updateUserInfo = (data) => {
return request({
url: '/member/info',
method: 'PUT',
data: data
}).then(res => {
// 更新成功,清除用户信息缓存
const cacheKey = `GET_/member/info_{}`
clearCache(cacheKey)
return res
})
}
// ========== 课程相关API ==========
/**
* 获取推荐课程列表
* @param {boolean} [cache=true] - 是否启用缓存
* @returns {Promise} 课程列表
*/
export const getRecommendCourses = (cache = true) => {
return request({
url: '/course/recommend',
method: 'GET',
cache: cache,
cacheTime: 10 * 60 * 1000 // 10分钟缓存
})
}
/**
* 获取课程详情
* @param {number} id - 课程ID
* @param {boolean} [cache=true] - 是否启用缓存
* @returns {Promise} 课程详情
*/
export const getCourseDetail = (id, cache = true) => {
return request({
url: `/course/${id}`,
method: 'GET',
cache: cache,
cacheTime: 15 * 60 * 1000 // 15分钟缓存
})
}
export default {
login,
logout,
getQRCode,
checkIn,
getUserInfo,
updateUserInfo,
getRecommendCourses,
getCourseDetail
}
@@ -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,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>
+87
View File
@@ -2,6 +2,7 @@
<view class="tab-bar">
<view
v-for="(tab, index) in tabs"
<<<<<<< HEAD
:key="tab.path"
:class="['tab-item', { active: currentIndex === index }]"
hover-class="tab-item--hover"
@@ -12,12 +13,22 @@
mode="aspectFit"
class="tab-icon"
/>
=======
:key="index"
:class="['tab-item', { active: currentActive === index }]"
@click="switchTab(index)"
>
<!-- 导航栏图标 -->
<image :src="currentActive === index ? tab.iconActive : tab.icon" mode="aspectFit" class="tab-icon" />
<!-- 导航栏标签文字 -->
>>>>>>> 8cf3c9ccee0d9274f647f0126b50dc1e79178809
<text class="tab-label">{{ tab.label }}</text>
</view>
</view>
</template>
<script setup>
<<<<<<< HEAD
import { computed, ref } from 'vue'
import {
PAGE,
@@ -36,9 +47,29 @@ const props = defineProps({
})
const tapping = ref(false)
=======
import { ref, watch } from 'vue'
// 当前激活的导航栏索引(从外部传入)
const props = defineProps({
activeTab: {
type: Number,
default: 0
}
})
// 当前激活状态
const currentActive = ref(props.activeTab)
// 监听外部传入的激活状态变化
watch(() => props.activeTab, (newVal) => {
currentActive.value = newVal
})
>>>>>>> 8cf3c9ccee0d9274f647f0126b50dc1e79178809
const tabs = [
{
<<<<<<< HEAD
path: PAGE.INDEX,
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/home.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/home.png',
@@ -82,6 +113,58 @@ function onTabTap(index) {
setTimeout(() => {
tapping.value = false
}, 350)
=======
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/home.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/home.png',
label: '首页',
path: '/pages/index/index'
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/course.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/course.png',
label: '课程',
path: '/pages/groupCourse/list'
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/train.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/train.png',
label: '训练',
path: '/pages/train/index'
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/discover.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/discover.png',
label: '发现',
path: '/pages/discover/index'
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/profile.png',
iconActive: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/tabBar/active/profile.png',
label: '我的',
path: '/pages/profile/index'
}
]
// 切换标签页
const switchTab = (index) => {
currentActive.value = index
const tab = tabs[index]
// 获取当前页面路径
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const currentPath = '/' + currentPage.route
// 如果点击的是当前页面,不跳转
if (currentPath === tab.path) {
return
}
// 跳转对应页面
uni.redirectTo({
url: tab.path
})
>>>>>>> 8cf3c9ccee0d9274f647f0126b50dc1e79178809
}
</script>
@@ -100,7 +183,11 @@ function onTabTap(index) {
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
border-radius: 32rpx 32rpx 0 0;
<<<<<<< HEAD
z-index: 999;
=======
z-index: 100;
>>>>>>> 8cf3c9ccee0d9274f647f0126b50dc1e79178809
}
.tab-item {
@@ -0,0 +1,487 @@
<template>
<!-- 团课卡片容器 -->
<view class="course-card" @click="goDetail">
<!-- 卡片顶部图片区域 -->
<view class="card-top">
<!-- 图片骨架屏 -->
<view v-if="!imageLoaded" class="skeleton skeleton-image"></view>
<!-- 课程封面图片 -->
<image
:src="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-duration">
<uni-icons type="time" size="14" color="#8A99B4" />
<text>{{ formatDuration(course.startTime, course.endTime) }}</text>
</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>{{ canBook ? '立即预约' : (course.currentMembers >= course.maxMembers ? '已满员' : '已结束') }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
// 图片加载状态
const imageLoaded = ref(false)
const props = defineProps({
course: {
type: Object,
required: true
}
})
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
})
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)
const minutes = Math.floor((end.getTime() - start.getTime()) / 60000)
return `${minutes}分钟`
}
const goDetail = () => {
emit('detail', 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-duration {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 20rpx;
background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);
border-radius: 20rpx;
font-size: 24rpx;
color: #3B82F6;
font-weight: 500;
}
/* 卡片底部区域 */
.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,202 @@
<template>
<view class="filter-section">
<!-- 时间区间筛选 -->
<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 } from 'vue'
const props = defineProps({
timeRangeText: {
type: String,
default: ''
},
sortOptions: {
type: Array,
default: () => [
{ label: '默认排序', value: 'default' },
{ label: '价格从低到高', value: 'priceAsc' },
{ label: '价格从高到低', value: 'priceDesc' },
{ label: '剩余名额最多', value: 'spotsDesc' },
{ label: '仅次数卡', value: 'pointCardOnly' },
{ label: '仅储值卡', value: 'storedValueOnly' },
{ label: '两种支付', value: 'bothPayment' }
]
},
sortIndex: {
type: Number,
default: 0
}
})
const emit = defineEmits(['update:sortIndex', 'timePick'])
const localSortIndex = ref(props.sortIndex)
watch(() => props.sortIndex, (val) => {
localSortIndex.value = val
})
const onSortChange = (e) => {
localSortIndex.value = e.detail.value
console.log('[FilterSection] 排序方式变更:', {
index: localSortIndex.value,
value: props.sortOptions[localSortIndex.value]
})
emit('update:sortIndex', localSortIndex.value)
}
const handleTimePick = () => {
console.log('[FilterSection] 触发时间选择器')
emit('timePick')
}
const getFilterParams = () => {
const params = {
sortType: props.sortOptions[localSortIndex.value].value,
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;
gap: 16rpx;
.filter-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 20rpx 24rpx;
background: #F5F7FA;
border-radius: 16rpx;
font-size: 26rpx;
color: #5E6F8D;
.filter-icon {
display: flex;
align-items: center;
}
.filter-arrow {
margin-left: auto;
display: flex;
align-items: center;
}
}
}
</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,728 @@
<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: '本月', 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()
}
// 判断是否是未来日期
function isFuture(year, month, day) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const date = new Date(year, month - 1, day)
return date > today
}
// 切换日期选择器
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':
startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
endDate = today
break
case '30d':
startDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
endDate = today
break
case 'month':
startDate = new Date(today.getFullYear(), today.getMonth(), 1)
endDate = today
break
case 'lastMonth':
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>
@@ -6,6 +6,7 @@
v-for="(item, index) in entries"
:key="index"
class="entry-item"
@tap="QEClick(item.path)"
>
<!-- 入口图标容器 -->
<view :class="['entry-icon', { accent: item.accent }]">
@@ -21,37 +22,44 @@
</template>
<script setup>
const QEClick = () => {
uni.navigateTo({
url:"/pages/checkIn/checkIn"
})
}
// 快捷入口数据列表
const entries = [
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/course.png',
title: '找课程',
desc: '精品课程',
accent: false ,
accent: false
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/plan.png',
title: '训练计划',
desc: '个性定制',
accent: true ,
accent: true
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/data.png',
title: '健身数据',
desc: '记录分析',
accent: false ,
accent: false
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/message.png',
title: '消息',
desc: '通知消息',
accent: true,
accent: true
},
{
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/icons/checkIn.png',
title: '签到',
desc: '打卡签到',
accent: false,
path: "/pages/checkIn/checkIn"
}
]
</script>
@@ -0,0 +1,228 @@
import { ref, computed } from 'vue'
import { groupCourseService } from '@/api/envConfig.js'
export function useGroupCourseList() {
// 分页相关
const pageNum = ref(1)
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 sortOptions = ref([
{ label: '默认排序', value: 'default' },
{ label: '价格从低到高', value: 'priceAsc' },
{ label: '价格从高到低', value: 'priceDesc' },
{ label: '剩余名额最多', value: 'spotsDesc' }
])
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 showTimePicker = ref(false)
const startDate = ref('')
const endDate = ref('')
const timeRangeText = ref('')
// 筛选后的课程列表
const filteredCourseList = computed(() => {
let result = [...courseList.value]
// 关键词搜索
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].value
if (sortType === 'priceAsc') {
result.sort((a, b) => (a.storedValueAmount || a.pointCardAmount) - (b.storedValueAmount || b.pointCardAmount))
} else if (sortType === 'priceDesc') {
result.sort((a, b) => (b.storedValueAmount || b.pointCardAmount) - (a.storedValueAmount || a.pointCardAmount))
} else if (sortType === 'spotsDesc') {
result.sort((a, b) => (b.maxMembers - b.currentMembers) - (a.maxMembers - a.currentMembers))
}
return result
})
// 获取所有搜索参数
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)
uni.showToast({
title: params.keyword ? `搜索:${params.keyword}` : '请输入关键词',
icon: 'none'
})
}
// 时间段变化处理
const onTimePeriodChange = (option) => {
console.log('[useGroupCourseList] 时间段选择:', option)
}
// 时间范围确认处理
const onTimeRangeConfirm = (params) => {
console.log('[useGroupCourseList] 时间范围确认:', params)
timeRangeText.value = params.timeRangeText
}
// 预约处理
const handleBooking = (course) => {
console.log('[useGroupCourseList] 预约课程:', course)
uni.showToast({
title: `预约课程:${course.courseName}`,
icon: 'success'
})
}
// 跳转详情
const goDetail = (courseId) => {
console.log('[useGroupCourseList] 跳转到课程详情:', courseId)
uni.navigateTo({
url: `/pages/groupCourse/detail?id=${courseId}`
})
}
// 获取团课列表
const fetchCourseList = async (isLoadMore = false) => {
if (loading.value) return
loading.value = true
try {
const result = await groupCourseService.getList({
pageNum: pageNum.value,
pageSize: pageSize.value
})
if (result.code === 0 && result.data) {
const { list, total: totalCount, pageNum: currentPage, totalPages: pages } = result.data
if (isLoadMore) {
courseList.value = [...courseList.value, ...list]
} else {
courseList.value = list
}
total.value = totalCount
pageNum.value = currentPage
totalPages.value = pages
hasMore.value = pageNum.value < totalPages.value
console.log('[useGroupCourseList] 团课列表获取成功:', {
total: total.value,
currentPage: pageNum.value,
totalPages: totalPages.value,
hasMore: hasMore.value
})
} else {
console.error('[useGroupCourseList] 获取团课列表失败:', result.message)
}
} catch (error) {
console.error('[useGroupCourseList] 获取团课列表异常:', error)
} finally {
loading.value = false
}
}
// 加载更多
const loadMore = () => {
if (!hasMore.value || loading.value) return
pageNum.value++
fetchCourseList(true)
}
// 滚动到底部触发
const onScrollToLower = () => {
loadMore()
}
return {
// 状态
pageNum,
pageSize,
total,
totalPages,
loading,
hasMore,
courseList,
searchKeyword,
hotKeywords,
sortOptions,
sortIndex,
timePeriodOptions,
timePeriodIndex,
showTimePicker,
startDate,
endDate,
timeRangeText,
filteredCourseList,
// 方法
getAllSearchParams,
handleSearch,
onTimePeriodChange,
onTimeRangeConfirm,
handleBooking,
goDetail,
fetchCourseList,
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层设计 |
+1 -1
View File
@@ -50,7 +50,7 @@
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "wx8278fdbc9f158915",
"appid" : "wx8f0d644d1d8985f6",
"setting" : {
"urlCheck" : false
},
+13 -1
View File
@@ -212,6 +212,18 @@
"navigationStyle": "custom",
"navigationBarTitleText": "课程评价"
}
},
{
"path": "pages/checkIn/checkIn",
"style": {
"navigationBarTitleText": "会员签到"
}
},
{
"path": "pages/groupCourse/list",
"style": {
"navigationBarTitleText": "团课列表"
}
}
],
"globalStyle": {
@@ -260,4 +272,4 @@
]
},
"uniIdRouter": {}
}
}
+979
View File
@@ -0,0 +1,979 @@
<template>
<view class="checkin-page">
<!-- 顶部导航栏固定顶部 -->
<view class="header">
<view class="header-title">会员签到</view>
<view class="header-subtitle">扫码入场开启今日训练</view>
</view>
<!-- 会员信息卡片 -->
<view class="member-card card-default">
<view class="member-avatar">
<image src="/static/default-avatar.png" mode="aspectFill"></image>
</view>
<view class="member-info">
<view class="member-name">尊敬的会员</view>
<view class="member-level">
<text class="level-badge">黄金会员</text>
<text class="valid-date">有效期至 2026-12-31</text>
</view>
</view>
<view class="member-points">
<view class="points-value">1280</view>
<view class="points-label">积分</view>
</view>
</view>
<!-- 二维码核心区域 -->
<view class="qr-container card-default">
<view class="qr-header">
<view class="qr-title">出示二维码签到</view>
<view class="qr-tip">将二维码对准前台扫码设备</view>
</view>
<view class="QRBox">
<view class="QR">
<image :src="image" mode="" :style="{width: Math.min(width, 500) + 'rpx',height: Math.min(height, 500) + 'rpx' } "></image>
</view>
<view v-if="!image || STQRC" class="loadingBox" :style="{width: Math.min(width, 500) + 'rpx',height: Math.min(height, 500) + 'rpx' }">
<view class="loading-spinner">
<view v-if="!isCheckIn" class="spinner-circle"></view>
<view v-else>
<uni-icons type="checkmarkempty" size="30"></uni-icons>
</view>
<text class="loading-text">{{ QRStatus }}</text>
</view>
</view>
<!-- 二维码装饰边框 -->
<view v-else class="qr-border" :style="{width: Math.min(width, 500) + 80 + 'rpx',height: Math.min(height, 500) + 80 + 'rpx' }">
<view class="corner top-left"></view>
<view class="corner top-right"></view>
<view class="corner bottom-left"></view>
<view class="corner bottom-right"></view>
</view>
</view>
<!-- 状态组件传递状态和自定义错误文案- 已签到时不显示 -->
<QrStatus
v-if="!isCheckIn"
:status="status"
:errorText="errorText"
/>
</view>
<!-- 操作提示 -->
<view class="tips-section">
<view class="tips-title">温馨提示</view>
<view class="tips-list">
<view class="tip-item">
<view class="tip-dot"></view>
<text>二维码每5分钟自动刷新请勿截图使用</text>
</view>
<view class="tip-item">
<view class="tip-dot"></view>
<text>签到成功后可进入场馆开始训练</text>
</view>
<view class="tip-item">
<view class="tip-dot"></view>
<text>如有问题请联系前台工作人员</text>
</view>
</view>
</view>
<!-- 底部按钮z-index永久置顶 -->
<view class="bottom-actions">
<button @tap="handleLongPress">签到</button>
<button class="btn-refresh" @tap="refreshQR">
<uni-icons type="refresh" size="36rpx" color="#5E6F8D"></uni-icons>
<text>刷新二维码</text>
</button>
<!-- 测试用手动清除缓存按钮 -->
<button class="btn-clear-cache" @tap="handleClearCache">
<uni-icons type="trash" size="36rpx" color="#ef4444"></uni-icons>
<text>清除缓存</text>
</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onLoad, onUnload } from '@dcloudio/uni-app'
// 引入状态组件(路径与你保持一致)
import QrStatus from '@/components/QRCode/StatusCard.vue'
// 引入API封装
import { getQRCode, checkIn as apiCheckIn } from '@/api/main.js'
let image = ref("")
let width = ref(0)
let height = ref(0)
let status = ref('loading')
let socketTask = null
const scanStatus = ref(false)
const QRStatus = ref("生成中...")
const STQRC = ref(false)//是否扫码
const isCheckIn = ref(false)
const qrcode = ref("")
// 新增:自定义错误文本变量
const errorText = ref('')
/**
* 缓存键名前缀
*/
const CACHE_PREFIX = 'QR_'
/**
* 获取带前缀的缓存键名
* @param {string} key - 原始键名
* @returns {string} 带前缀的键名
*/
const getCacheKey = (key) => {
return CACHE_PREFIX + key
}
/**
* 获取今日23:59:59的时间戳(当日过期时间)
* @returns {number} 今日23:59:59的时间戳
*/
const getTodayExpireTime = () => {
const now = new Date()
const expire = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999)
return expire.getTime()
}
/**
* 检查缓存是否过期
* @param {string} key - 缓存键名(不带前缀)
* @returns {boolean} 是否有效(未过期)
*/
const isCacheValid = (key) => {
try {
const cacheKey = getCacheKey(key)
const cacheData = uni.getStorageSync(cacheKey)
if (!cacheData) return false
if (typeof cacheData === 'object' && cacheData.expireTime) {
const now = Date.now()
return now <= cacheData.expireTime
}
return false
} catch (e) {
console.error('检查缓存有效性失败:', e)
return false
}
}
/**
* 获取缓存数据(自动校验过期时间)
* @param {string} key - 缓存键名(不带前缀)
* @returns {any} 缓存数据(过期返回null)
*/
const getCacheData = (key) => {
try {
if (!isCacheValid(key)) {
// 缓存已过期,清除缓存
uni.removeStorageSync(getCacheKey(key))
return null
}
const cacheData = uni.getStorageSync(getCacheKey(key))
if (typeof cacheData === 'object' && cacheData.data !== undefined) {
return cacheData.data
}
return null
} catch (e) {
console.error('获取缓存数据失败:', e)
return null
}
}
/**
* 设置缓存数据(自动设置当日23:59:59过期)
* @param {string} key - 缓存键名(不带前缀)
* @param {any} data - 要缓存的数据
*/
const setCacheData = (key, data) => {
try {
const cacheData = {
data: data,
expireTime: getTodayExpireTime()
}
uni.setStorageSync(getCacheKey(key), cacheData)
} catch (e) {
console.error('设置缓存数据失败:', e)
}
}
/**
* 清除所有QR_开头的缓存(用于测试阶段)
*/
const clearQRCache = () => {
try {
const keys = uni.getStorageInfoSync().keys || []
let clearedCount = 0
for (const key of keys) {
if (key.startsWith(CACHE_PREFIX)) {
uni.removeStorageSync(key)
clearedCount++
}
}
console.log(`已清除 ${clearedCount} 个QR_开头的缓存`)
return clearedCount
} catch (e) {
console.error('清除QR缓存失败:', e)
return 0
}
}
/**
* 测试用:手动清除缓存按钮点击事件
*/
const handleClearCache = () => {
uni.showModal({
title: '清除缓存',
content: '确定要清除所有签到相关的缓存吗?(测试用)',
confirmText: '确定',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
const clearedCount = clearQRCache()
// 重置页面状态
image.value = ""
width.value = 0
height.value = 0
status.value = 'loading'
QRStatus.value = "生成中..."
STQRC.value = false
isCheckIn.value = false
uni.showToast({
title: `已清除 ${clearedCount} 个缓存`,
icon: 'success',
duration: 2000
})
// 重新请求二维码
setTimeout(() => {
getStorage(null)
}, 500)
}
}
})
}
onLoad(() => {
uni.showLoading({
title: '生成签到二维码...',
mask: true
})
// 读取签到状态缓存(自动检查过期)
// isCheckIn 代表签到状态,从缓存读取
const cachedIsCheckIn = getCacheData("isCheckIn")
// checkInTime 代表具体签到时间,从缓存读取(显示在 loading-text 中)
const cachedCheckInTime = getCacheData("checkInTime")
if(cachedIsCheckIn != null) {
console.log("进入缓存 - 签到状态")
isCheckIn.value = cachedIsCheckIn
STQRC.value = true
}
// 如果已经签到成功,直接显示成功状态,不需要请求后端
if(isCheckIn.value) {
console.log("已签到且有缓存,无需请求后端")
// 读取二维码图片缓存用于显示
const cachedQRInfo = getCacheData("QRInfo")
if(cachedQRInfo) {
image.value = cachedQRInfo.qrCodeBase64
width.value = cachedQRInfo.width * 2
height.value = cachedQRInfo.height * 2
}
// QRStatus 显示具体签到时间(从缓存读取,显示在 loading-text 中)
QRStatus.value = cachedCheckInTime || "已完成签到"
uni.hideLoading()
return
}
// 未签到或缓存失效,需要请求后端获取二维码
// QRStatus 重置为默认的请求状态
QRStatus.value = "生成中..."
getStorage(null)
})
// 页面卸载时关闭WebSocket连接(不清除缓存,让缓存自然过期)
onUnload(() => {
closeWebSocket()
// 缓存会在当日23:59:59自动过期,页面卸载时不主动清除
// 如需测试,使用页面上的"清除缓存"按钮手动清除
})
// 获取二维码接口
const fetchQRCode = () => {
console.log(1111)
status.value = ''
errorText.value = '' // 重置错误文本
image.value = ""
getQRCode(true).then(res => {
console.log(res)
// 保存到本地缓存(用于签到状态判断)
setCacheData("QRInfo", res)
getStorage(res)
qrcode.value = res.qrContent
}).catch(err => {
console.error('获取二维码失败:', err)
status.value = 'error'
errorText.value = err.message || '获取二维码失败'
uni.showToast({
title: errorText.value,
icon: 'error'
})
}).finally(() => {
uni.hideLoading()
})
}
const refreshQR = () => {
// 如果已签到,不允许刷新二维码
if (isCheckIn.value) {
uni.showToast({
title: '您已签到',
icon: 'none',
duration: 2000
})
return
}
image.value = ""
QRStatus.value = "正在刷新二维码..."
setTimeout(() => {
getStorage(null)
}, 500)
}
const getStorage = res => {
let data = res
if(data == null) {
// 使用带过期检查的缓存读取
data = getCacheData("QRInfo")
uni.hideLoading()
}
if(!data) fetchQRCode()
if(data) {
image.value = data.qrCodeBase64
// 使用带过期时间的缓存(当日23:59:59过期)
setCacheData("QRInfo", data)
width.value = data.width * 2
height.value = data.height * 2
// 只有在未签到的情况下才连接WebSocket等待扫码
if (data.qrContent && !isCheckIn.value) {
console.log("未签到,连接WebSocket等待扫码")
connectWebSocket(data.qrContent)
} else if (isCheckIn.value) {
console.log("已签到,无需连接WebSocket")
}
}
}
const handleLongPress = () => {
uni.scanCode({
onlyFromCamera: false,
scanType: ['qrCode'],
success: (res) => {
console.log(res)
checkIn(res.result)
},
fail: (err) => {
console.error('扫码失败:', err)
uni.showToast({
title: '扫码失败',
icon: 'none'
})
}
})
}
// 手动签到接口
const checkIn = (qrContent) => {
console.log(qrContent)
apiCheckIn(qrContent).then(res => {
closeWebSocket()
console.log(res)
status.value = 'scanned'
errorText.value = '' // 成功重置错误文本
QRStatus.value = res.dateTime + " 成功签到"
isCheckIn.value = true
// 使用带过期时间的缓存(当日23:59:59过期)
setCacheData("checkInTime", QRStatus.value)
setCacheData("isCheckIn", isCheckIn.value)
uni.showToast({
title: '签到成功!',
icon: 'success',
duration: 2000
})
}).catch(err => {
console.error('签到请求失败:', err)
status.value = 'error'
errorText.value = err.message || '签到失败,请重试' // 对应错误文案
})
}
// 建立WebSocket连接
const connectWebSocket = (qrContent) => {
const wsUrl = `ws://192.168.43.89:8084/webSocket/checkIn`
console.log('WebSocket 连接地址:', wsUrl)
socketTask = uni.connectSocket({
url: wsUrl,
success: () => {
console.log('WebSocket 连接中...')
},
fail: (err) => {
console.error('WebSocket 连接失败:', err)
status.value = 'error'
errorText.value = '连接失败,请重试' // 对应错误文案
}
})
// 连接打开成功
socketTask.onOpen(() => {
console.log('WebSocket 连接成功')
status.value = 'waiting'
errorText.value = '' // 成功重置错误文本
sendQRCodeInfo(qrContent)
})
// 发送二维码信息到后端
const sendQRCodeInfo = (qrContent) => {
const qrCodeDto = {
qrContent: qrContent,
used: false
}
console.log('发送二维码信息:', qrCodeDto)
socketTask.send({
data: JSON.stringify(qrCodeDto),
success: () => {
console.log('二维码信息发送成功')
},
fail: (err) => {
console.error('发送失败:', err)
status.value = 'error'
errorText.value = '连接失败,请重试' // 对应错误文案
}
})
}
// 接收后端消息
socketTask.onMessage((res) => {
console.log('收到 WebSocket 消息:', res.data)
const message = res.data
if (message === '正在进行签到') {
QRStatus.value = "正在进行签到..."
STQRC.value = true
// status.value = 'scanned'
// errorText.value = '' // 成功重置错误文本
// uni.showToast({
// title: '签到成功!',
// icon: 'success',
// duration: 2000
// })
} else if (message.startsWith('二维码无效')) {
uni.showToast({
title: message,
icon: 'none',
duration: 2000
})
status.value = 'error'
errorText.value = '二维码无效,请刷新' // 对应错误文案
setTimeout(() => {
closeWebSocket()
}, 3000)
} else if (message === '消息格式错误') {
uni.showToast({
title: '消息格式错误',
icon: 'none'
})
status.value = 'error'
errorText.value = '消息格式错误' // 对应错误文案
} else {
console.log('未知消息:', message)
}
})
// 连接关闭
socketTask.onClose(() => {
console.log('WebSocket 连接关闭')
status.value = 'closed'
errorText.value = '' // 重置错误文本
})
// 连接错误
socketTask.onError((err) => {
console.error('WebSocket 错误:', err)
status.value = 'error'
errorText.value = '连接失败,请重试' // 对应错误文案
uni.showToast({
title: '连接失败',
icon: 'none'
})
})
}
// 关闭WebSocket连接
const closeWebSocket = () => {
if (socketTask) {
socketTask.close({
success: () => {
console.log('主动关闭 WebSocket')
}
})
socketTask = null
}
}
</script>
<style lang="scss" scoped>
.checkin-page {
min-height: 100vh;
background-color: #F9FAFE;
padding-top: 200rpx; /* 为固定顶部导航预留空间 */
padding-bottom: 160rpx; /* 为底部按钮预留空间 */
box-sizing: border-box;
}
/* 顶部导航(固定顶部) */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 99998; /* 低于底部按钮,确保底部按钮永远最上层 */
background: linear-gradient(135deg, #0B2B4B 0%, #1A4A6F 100%);
padding: 64rpx 48rpx 48rpx;
text-align: center;
color: #FFFFFF;
border-bottom-left-radius: 56rpx;
border-bottom-right-radius: 56rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
.header-title {
font-size: 45rpx;
font-weight: 700;
margin-bottom: 8rpx;
}
.header-subtitle {
font-size: 26rpx;
opacity: 0.8;
}
}
/* 通用卡片样式 */
.card-default {
background: #FFFFFF;
border-radius: 40rpx;
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.03), 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
border: 1rpx solid #E9EDF2;
padding: 32rpx;
}
/* 会员信息卡片 */
.member-card {
margin: 0 32rpx 48rpx;
display: flex;
align-items: center;
padding: 32rpx;
flex-wrap: nowrap;
.member-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 999px;
overflow: hidden;
border: 4rpx solid #FF6B35;
flex-shrink: 0;
image {
width: 100%;
height: 100%;
}
}
.member-info {
flex: 1;
margin-left: 32rpx;
flex-shrink: 1;
min-width: 0;
.member-name {
font-size: 32rpx;
font-weight: 500;
color: #1E2A3A;
margin-bottom: 8rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-level {
display: flex;
align-items: center;
gap: 16rpx;
flex-wrap: wrap;
.level-badge {
background: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%);
color: white;
padding: 4rpx 16rpx;
border-radius: 24rpx;
font-size: 22rpx;
font-weight: 500;
flex-shrink: 0;
}
.valid-date {
font-size: 22rpx;
color: #5E6F8D;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.member-points {
text-align: center;
flex-shrink: 0;
margin-left: 16rpx;
.points-value {
font-size: 38rpx;
font-weight: 700;
color: #FF6B35;
}
.points-label {
font-size: 22rpx;
color: #5E6F8D;
}
}
}
/* 二维码容器 */
.qr-container {
margin: 0 32rpx 48rpx;
padding: 48rpx 32rpx;
text-align: center;
.qr-header {
margin-bottom: 64rpx;
.qr-title {
font-size: 38rpx;
font-weight: 700;
color: #1E2A3A;
margin-bottom: 8rpx;
}
.qr-tip {
font-size: 26rpx;
color: #5E6F8D;
}
}
}
/* 二维码核心区域 */
.QRBox {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 0 auto 64rpx;
max-width: 100%;
.QR {
position: relative;
z-index: 2;
padding: 20rpx;
background: white;
border-radius: 40rpx;
}
.loadingBox {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 3;
display: flex;
justify-content: center;
align-items: center;
background: rgba(255, 255, 255, 0.95);
border-radius: 40rpx;
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
.spinner-circle {
width: 80rpx;
height: 80rpx;
border: 6rpx solid #E9EDF2;
border-top-color: #FF6B35;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
font-size: 26rpx;
color: #5E6F8D;
width: 250rpx;
}
}
}
/* 二维码装饰边框 */
.qr-border {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
.corner {
position: absolute;
width: 48rpx;
height: 48rpx;
border: 6rpx solid #FF6B35;
&.top-left {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
border-top-left-radius: 40rpx;
}
&.top-right {
top: 0;
right: 0;
border-left: none;
border-bottom: none;
border-top-right-radius: 40rpx;
}
&.bottom-left {
bottom: 0;
left: 0;
border-right: none;
border-top: none;
border-bottom-left-radius: 40rpx;
}
&.bottom-right {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
border-bottom-right-radius: 40rpx;
}
}
}
}
/* 温馨提示 */
.tips-section {
margin: 0 32rpx 48rpx;
.tips-title {
font-size: 32rpx;
font-weight: 500;
color: #1E2A3A;
margin-bottom: 32rpx;
}
.tips-list {
background: #FFFFFF;
border-radius: 40rpx;
padding: 32rpx;
border: 1rpx solid #E9EDF2;
.tip-item {
display: flex;
align-items: flex-start;
gap: 16rpx;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
.tip-dot {
width: 12rpx;
height: 12rpx;
background: #FF6B35;
border-radius: 50%;
margin-top: 12rpx;
flex-shrink: 0;
}
text {
font-size: 26rpx;
color: #5E6F8D;
line-height: 1.6;
}
}
}
}
/* 底部按钮(z-index永久置顶+安全区域适配) */
.bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 99999; /* 极高z-index确保永远在最上层 */
padding: 32rpx 48rpx calc(32rpx + env(safe-area-inset-bottom));
background: #FFFFFF;
border-top: 1rpx solid #E9EDF2;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
.btn-refresh {
width: 100%;
height: 88rpx;
background: #F2F5F9;
color: #5E6F8D;
border: none;
border-radius: 999px;
font-size: 29rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
margin-bottom: 20rpx;
&:active {
background: #E9EDF2;
}
}
}
/* 测试用:清除缓存按钮 */
.btn-clear-cache {
width: calc(100% - 96rpx);
height: 88rpx;
background: #FEF2F2;
color: #EF4444;
border: 1rpx solid #FECACA;
border-radius: 999px;
font-size: 29rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
&:active {
background: #FEE2E2;
}
}
/* 旋转动画 */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.checkin-page {
background-color: #121826;
}
.header {
background: linear-gradient(135deg, #123A5E 0%, #1A4A6F 100%);
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.3);
}
.card-default {
background: #1E2636;
border-color: #2A3346;
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.4);
}
.member-card .member-info .member-name {
color: #EDF2F7;
}
.member-card .member-info .member-level .valid-date,
.member-card .member-points .points-label {
color: #9AA9C1;
}
.qr-container .qr-header .qr-title {
color: #EDF2F7;
}
.qr-container .qr-header .qr-tip {
color: #9AA9C1;
}
.QRBox .loadingBox {
background: rgba(30, 38, 54, 0.95);
}
.QRBox .loadingBox .loading-spinner .loading-text {
color: #9AA9C1;
}
.tips-section .tips-title {
color: #EDF2F7;
}
.tips-section .tips-list {
background: #1E2636;
border-color: #2A3346;
}
.tips-section .tips-list .tip-item text {
color: #9AA9C1;
}
.bottom-actions {
background: #1E2636;
border-color: #2A3346;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.2);
}
.bottom-actions .btn-refresh {
background: #0F141F;
color: #9AA9C1;
&:active {
background: #2A3346;
}
}
.btn-clear-cache {
background: #3D1919;
color: #FCA5A5;
border-color: #5C2B2B;
&:active {
background: #4A2525;
}
}
}
</style>
@@ -0,0 +1,172 @@
<template>
<view class="group-course-page">
<!-- 搜索区域 -->
<view class="search-section">
<!-- 搜索框组件 -->
<SearchBar
v-model="searchKeyword"
:hot-keywords="hotKeywords"
@search="handleSearch"
ref="searchBarRef"
/>
<!-- 筛选条件组件 -->
<FilterSection
:time-range-text="timeRangeText"
:sort-options="sortOptions"
v-model:sort-index="sortIndex"
@time-pick="showTimePicker = true"
ref="filterSectionRef"
/>
<!-- 时间段选择组件 -->
<TimePeriodSelector
:time-period-options="timePeriodOptions"
v-model="timePeriodIndex"
@change="onTimePeriodChange"
ref="timePeriodRef"
/>
</view>
<!-- 团课列表 -->
<scroll-view
scroll-y
class="course-list"
@scrolltolower="onScrollToLower"
scroll-with-animation
>
<GroupCourseCard
v-for="course in filteredCourseList"
:key="course.id"
:course="course"
@booking="handleBooking"
@detail="goDetail"
></GroupCourseCard>
<!-- 加载更多提示 -->
<view class="load-more">
<text v-if="loading">加载中...</text>
<text v-else-if="!hasMore">没有更多数据了</text>
<text v-else class="load-more-text" @tap="loadMore">点击加载更多</text>
</view>
</scroll-view>
<!-- 底部导航 -->
<TabBar :active-tab="1" />
<!-- 时间选择器组件 -->
<TimeRangePicker
v-model:visible="showTimePicker"
v-model:start-date="startDate"
v-model:end-date="endDate"
@confirm="onTimeRangeConfirm"
ref="timeRangePickerRef"
/>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import TabBar from '@/components/TabBar.vue'
import GroupCourseCard from '@/components/groupCourse/CourseCard.vue'
import SearchBar from '@/components/groupCourse/SearchBar.vue'
import FilterSection from '@/components/groupCourse/FilterSection.vue'
import TimePeriodSelector from '@/components/groupCourse/TimePeriodSelector.vue'
import TimeRangePicker from '@/components/groupCourse/TimeRangePicker.vue'
import { useGroupCourseList } from '@/composables/useGroupCourseList.js'
// 组件引用
const searchBarRef = ref(null)
const filterSectionRef = ref(null)
const timePeriodRef = ref(null)
const timeRangePickerRef = ref(null)
// 使用组合式函数
const {
// 状态
loading,
hasMore,
searchKeyword,
hotKeywords,
sortOptions,
sortIndex,
timePeriodOptions,
timePeriodIndex,
showTimePicker,
startDate,
endDate,
timeRangeText,
filteredCourseList,
// 方法
getAllSearchParams,
handleSearch,
onTimePeriodChange,
onTimeRangeConfirm,
handleBooking,
goDetail,
fetchCourseList,
loadMore,
onScrollToLower
} = useGroupCourseList()
// 组件挂载时调用接口获取团课列表
onMounted(() => {
console.log('[list.vue] 页面组件已挂载,开始获取团课列表')
fetchCourseList()
console.log('[list.vue] 可用的搜索参数获取方法:')
console.log(' - searchBarRef.getSearchParams()')
console.log(' - filterSectionRef.getFilterParams()')
console.log(' - timePeriodRef.getTimePeriodParams()')
console.log(' - timeRangePickerRef.getTimeRangeParams()')
console.log(' - getAllSearchParams() 获取所有参数')
})
// 暴露方法供外部调用
defineExpose({
getAllSearchParams: () => getAllSearchParams(searchBarRef.value, filterSectionRef.value, timePeriodRef.value, timeRangePickerRef.value),
searchBarRef,
filterSectionRef,
timePeriodRef,
timeRangePickerRef
})
</script>
<style lang="scss" scoped>
.group-course-page {
min-height: 100vh;
background: #F5F7FA;
}
/* 搜索区域 */
.search-section {
background: #ffffff;
padding: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 24rpx;
}
/* 课程列表 */
.course-list {
height: calc(100vh - 380rpx);
padding: 24rpx 24rpx;
padding-bottom: calc(160rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
/* 加载更多提示 */
.load-more {
text-align: center;
padding: 30rpx 0;
color: #999999;
font-size: 26rpx;
}
.load-more-text {
color: #1890ff;
}
</style>
+215
View File
@@ -0,0 +1,215 @@
const BASE_URL = 'http://192.168.43.89:8084/api'
// 缓存相关常量
const CACHE_PREFIX = 'API_CACHE_'
const CACHE_EXPIRE_TIME = 5 * 60 * 1000 // 默认缓存时间5分钟
/**
* 获取缓存数据
* @param {string} key - 缓存键名
* @returns {any} 缓存数据(过期返回null)
*/
export const getCache = (key) => {
try {
const cacheData = uni.getStorageSync(CACHE_PREFIX + key)
if (cacheData && cacheData.expireTime && Date.now() < cacheData.expireTime) {
return cacheData.data
}
// 缓存过期,清除
uni.removeStorageSync(CACHE_PREFIX + key)
return null
} catch (e) {
console.error('获取缓存失败:', e)
return null
}
}
/**
* 设置缓存数据
* @param {string} key - 缓存键名
* @param {any} data - 要缓存的数据
* @param {number} expireTime - 过期时间(毫秒),默认5分钟
*/
export const setCache = (key, data, expireTime = CACHE_EXPIRE_TIME) => {
try {
const cacheData = {
data: data,
expireTime: Date.now() + expireTime
}
uni.setStorageSync(CACHE_PREFIX + key, cacheData)
} catch (e) {
console.error('设置缓存失败:', e)
}
}
/**
* 清除指定缓存
* @param {string} key - 缓存键名
*/
export const clearCache = (key) => {
try {
uni.removeStorageSync(CACHE_PREFIX + key)
} catch (e) {
console.error('清除缓存失败:', e)
}
}
/**
* 清除所有API缓存
*/
export const clearAllCache = () => {
try {
const keys = uni.getStorageInfoSync().keys || []
for (const key of keys) {
if (key.startsWith(CACHE_PREFIX)) {
uni.removeStorageSync(key)
}
}
} catch (e) {
console.error('清除所有缓存失败:', e)
}
}
/**
* 获取token
* @returns {string|null} token
*/
export const getToken = () => {
try {
return uni.getStorageSync('token') || null
} catch (e) {
console.error('获取token失败:', e)
return null
}
}
/**
* 设置token
* @param {string} token - token值
*/
export const setToken = (token) => {
try {
uni.setStorageSync('token', token)
} catch (e) {
console.error('设置token失败:', e)
}
}
/**
* 清除token
*/
export const clearToken = () => {
try {
uni.removeStorageSync('token')
} catch (e) {
console.error('清除token失败:', e)
}
}
/**
* 生成请求缓存键名
* @param {string} url - 请求URL
* @param {object} data - 请求参数
* @param {string} method - 请求方法
* @returns {string} 缓存键名
*/
const generateCacheKey = (url, data, method) => {
const params = JSON.stringify(data || {})
return `${method}_${url}_${params}`
}
/**
* 通用请求函数
* @param {object} options - 请求配置
* @param {string} options.url - 请求URL
* @param {string} [options.method='GET'] - 请求方法
* @param {object} [options.data={}] - 请求参数
* @param {object} [options.header={}] - 请求头
* @param {boolean} [options.cache=false] - 是否启用缓存
* @param {number} [options.cacheTime] - 缓存时间(毫秒)
* @param {boolean} [options.needToken=true] - 是否需要token
* @returns {Promise} 请求Promise
*/
export const request = (options) => {
return new Promise((resolve, reject) => {
const {
url,
method = 'GET',
data = {},
header = {},
cache = false,
cacheTime,
needToken = true
} = options
// 生成缓存键名
const cacheKey = cache ? generateCacheKey(url, data, method) : null
// 如果启用缓存且存在有效缓存,直接返回缓存数据
if (cache && cacheKey) {
const cachedData = getCache(cacheKey)
if (cachedData !== null) {
console.log(`[API] 命中缓存: ${url}`)
resolve(cachedData)
return
}
}
// 构建请求头
const requestHeader = {
'Content-Type': 'application/json',
...header
}
// 如果需要token,自动添加到请求头
if (needToken) {
const token = getToken()
if (token) {
requestHeader['Authorization'] = `Bearer ${token}`
}
}
uni.request({
url: BASE_URL + url,
method: method,
data: data,
header: requestHeader,
success: (res) => {
if (res.statusCode === 200) {
// 如果启用缓存,保存响应数据
if (cache && cacheKey && res.data) {
setCache(cacheKey, res.data, cacheTime)
}
resolve(res.data)
} else if (res.statusCode === 401) {
// token过期,清除token并提示重新登录
clearToken()
uni.showToast({
title: '登录已过期,请重新登录',
icon: 'none'
})
reject({ code: 401, message: '登录已过期' })
} else {
reject({ code: res.statusCode, message: res.data?.message || '请求失败' })
}
},
fail: (err) => {
console.error(`[API] 请求失败: ${url}`, err)
reject({ code: -1, message: '网络请求失败', error: err })
}
})
})
}
// 工具函数导出
export const requestUtils = {
getToken,
setToken,
clearToken,
getCache,
setCache,
clearCache,
clearAllCache
}
export default request
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import path from 'path'
// 用来匹配地址,解决跨域问题
export default defineConfig({
plugins: [uni()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
},
},
server: {
proxy: {
// 匹配所有 /api 开头的请求
'/api': {
target: 'http://192.168.43.89:8084', // 你的后端SpringBoot地址
changeOrigin: true, // 开启跨域伪装
// rewrite: (path) => path.replace(/^\/areyouok/, '')
// 举例:前端请求 /api/login → 代理成 http://localhost:8088/login
}
}
}
})