完成团课列表页面布局以及基础交互,使用测试数据
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 环境配置文件
|
||||||
|
* 当前仅使用模拟数据(开发模式)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { groupCourseMockApi } from './groupCourse.mock.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团课服务(仅使用模拟数据)
|
||||||
|
*/
|
||||||
|
export const groupCourseService = groupCourseMockApi
|
||||||
|
|
||||||
|
export default groupCourseService
|
||||||
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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,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>
|
||||||
@@ -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层设计 |
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user