完成团课列表页面布局以及基础交互,使用测试数据
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