效果预览
功能:实现小程序或者h5的一个时间预约功能,支持不同日期的在不同的开放时段进行选择,支持禁用及共享。还支持最大预约时段的约束
1、支持参数
/**
* @description 预约时间选择组件
* @props {boolean} isShare - 是否可共享,默认值为 false
* @props {Array} checkTime - 已选择时间,默认值为 []
* @props {Array} openTime - 开放时段,默认值为 []
* @props {string} meetingDate - 会议日期,默认值为 ""
* @props {number} start - 绘制的开始时间,默认值为 8,需大于 0 且为整数
* @props {number} end - 绘制的结束时间,默认值为 23,需小于 24 且为整数
* @props {number} maxTimeLength - 最大预约时长
*/
2、具体代码-代码中注释
<template>
<view>
<view class="timeSelect-box">
<view
class="item"
v-bind:class="{active:item.active,disabled:item.disabled,select:item.isSelect,close:item.isClose,'border-top-red':timeList.start==item.start,'border-bottom-red':timeList.end==item.end}"
v-for="(item, index) in list"
:key="index"
v-on:click="clickItem(item,index)"
>
<view
class="time-text"
:class="{'active bg-color':timeList.start==item.start}"
v-if="timeList.start==item.start"
>{{item.start}}</view>
<view
class="time-text last"
:class="{'active bg-color':timeList.end==item.end}"
v-if="timeList.end==item.end"
>{{item.end}}</view>
<view class="time-text" v-if="index%2==0">{{item.start}}</view>
<view class="time-text last" v-else-if="index==list.length-1">{{item.end}}</view>
<view v-if="item.title" class="title-box d-center" :style="{height:item.height+'px'}">
<view class="text" @click.stop="clickViewDetail(item)">{{item.title}}</view>
</view>
<view
v-if="item.closeCount"
class="close-box d-center color-white"
:style="{height:item.closeCount*itemHeight+'px'}"
>该时段暂未开放</view>
</view>
</view>
</view>
</template>
<script>
/**
* @description 时间选择组件
* @props {boolean} isShare - 是否可共享,默认值为 false
* @props {Array} checkTime - 已选择时间,默认值为 []
* @props {Array} openTime - 开放时段,默认值为 []
* @props {string} meetingDate - 会议日期,默认值为 ""
* @props {number} start - 绘制的开始时间,默认值为 8,需大于 0 且为整数
* @props {number} end - 绘制的结束时间,默认值为 23,需小于 24 且为整数
* @props {number} maxTimeLength - 最大预约时长
*/
export default {
props: {
// 是否可共享
isShare: {
type: Boolean,
default: false
},
// 已选择时间
checkTime: {
type: Array,
default: () => []
},
// 开放时段
openTime: {
type: Array,
default: () => []
},
// 会议日期
meetingDate: {
type: String,
default: ""
},
// 绘制的开始时间
start: {
type: Number,
default: 8,
validator: function(value) {
// 验证开始时间需大于 0 且为整数
return value > 0 && Number.isInteger(value);
}
},
// 绘制的结束时间
end: {
type: Number,
default: 23,
validator: function(value) {
// 验证结束时间需小于 24 且为整数
return value < 24 && Number.isInteger(value);
}
},
// 最大预约时长
maxTimeLength: Number
},
data() {
return {
list: [], // 时间列表
clickNumber: 0, // 点击次数
disabledList: [], // 禁用的时间列表
indexArr: [], // 选中的索引数组
itemHeight: 0 // 每个时间项的高度
};
},
created() {
// 组件创建时初始化时间列表
// this.initTime();
},
mounted() {
// 组件挂载时可执行的操作,当前为空
},
computed: {
timeList() {
let list = [];
// 筛选出激活的时间项
this.list.forEach(item => {
if (item.active) {
list.push(item);
}
});
let obj = {
start: "",
end: ""
};
if (list.length) {
// 获取选中时间段的开始和结束时间
obj.start = list[0].time.split("-")[0];
obj.end = list[list.length - 1].time.split("-")[1];
}
return obj;
}
},
methods: {
/**
* @description 根据当前日期和已选则时间列表处理选择禁用
*/
setDisabled() {
if (this.isShare) {
// 合并激活的时间项
this.mergeActive();
}
this.checkTime.forEach(item => {
if (this.isShare) {
// 添加可共享情况下的禁用时间列表
this.disabledList.push(
this.getTimeArr(item.start, item.lastTime || item.end)
);
} else {
// 添加不可共享情况下的禁用时间列表
this.disabledList.push(this.getTimeArr(item.start, item.end));
}
});
let currentTime = new Date();
// 未开放时段的开始、结束下标
var closeStartIndex = -1;
var closeEndIndex = -1;
this.list.forEach((item, index2) => {
this.disabledList.forEach((vitem, index) => {
// 查找当前时间项是否在禁用列表中
const findIndex = vitem.findIndex(d => d == item.time);
const checkItem = this.checkTime[index];
if (findIndex == 0 && !this.isShare) {
// 设置不可共享情况下的标题和信息
item.title = checkItem.title;
item.info = [checkItem];
item.height = this.itemHeight * vitem.length + 1;
}
if (findIndex == 0 && this.isShare && !checkItem.isRecord) {
// 设置可共享情况下的标题和信息
item.title = `${checkItem.count || 1}个活动`;
item.info = checkItem.info || [checkItem];
item.height = this.itemHeight * vitem.length + 1;
}
if (findIndex > -1) {
if (this.isShare) {
// 可共享情况下设置为已选择
item.isSelect = true;
} else {
// 不可共享情况下设置为禁用
item.disabled = true;
}
}
});
// 24小时内禁止预约(可自行开启关闭)
// const isBeforeTime = this.getTime(item.start) < currentTime;
// if (isBeforeTime) {
// // 当前时间之前的时段设置为关闭
// item.isClose = true;
// closeStartIndex = closeStartIndex > -1 ? closeStartIndex : index2;
// var closeStartItem = this.list[closeStartIndex];
// closeStartItem.closeCount = closeStartItem.closeCount
// ? closeStartItem.closeCount
// : 0;
// closeStartItem.closeCount++;
// }
if (this.openTime?.length == 2) {
if (this.getTime(item.start) < this.getTime(this.openTime[0])) {
// 开放时段之前的时段设置为关闭
item.isClose = true;
closeStartIndex = closeStartIndex > -1 ? closeStartIndex : index2;
var closeStartItem = this.list[closeStartIndex];
closeStartItem.closeCount = closeStartItem.closeCount ?
closeStartItem.closeCount :
0;
closeStartItem.closeCount++;
}
this.$set(this.list, index2, item)
});
this.$set(this.list,index2,item)
});
},
/**
* @description 合并激活的时间项
*/
mergeActive() {
this.checkTime.map((item,index) => {
if (item.isRecord) return;
for (var i = 1; i < this.checkTime.length; i++) {
var nextTime = this.checkTime[i];
if(index==i)continue;
if (item.lastTime) {
if (
this.getTime(nextTime.start) >= this.getTime(item.start) &&
this.getTime(nextTime.start) <= this.getTime(item.lastTime)
) {
item.lastTime = nextTime.end;
nextTime.isRecord = true;
item.count++;
item.info.push(JSON.parse(JSON.stringify(nextTime)))
}
} else {
if (
this.getTime(nextTime.start) >= this.getTime(item.start) &&
this.getTime(nextTime.start) <= this.getTime(item.end)
) {
item.lastTime = nextTime.end;
nextTime.isRecord = true;
item.count = item.count ? item.count : 1;
item.count++;
item.info = item.info ? item.info : [JSON.parse(JSON.stringify(item))];
item.info.push(nextTime)
}
}
console.log(nextTime);
}
});
},
/**
* @description 根据时间字符串获取日期对象
* @param {string} time - 时间字符串,格式为 "HH:mm"
* @returns {Date} 日期对象
*/
getTime(time) {
return new Date(`${this.meetingDate} ${time}:00`);
},
/**
* @description 根据开始结束时间获取数组中的项
* @param {string} start - 开始时间,格式为 "HH:mm"
* @param {string} end - 结束时间,格式为 "HH:mm"
* @returns {Array} 时间数组
*/
getTimeArr(start, end) {
const list = [];
for (let home = this.start; home < this.end; home++) {
const h = `0${home}`.slice(-2);
// 生成半小时的时间间隔
list.push(`${h}:00-${h}:30`, `${h}:30-${`0${home + 1}`.slice(-2)}:00`);
}
return list.slice(
list.findIndex(v => v.startsWith(start)),
list.findIndex(v => v.endsWith(end)) + 1
);
},
/**
* @description 构建时间数组
*/
initTime() {
this.disabledList = [];
const list = [];
for (let home = this.start; home < this.end; home++) {
const h = `0${home}`.slice(-2);
// 生成半小时的时间项
list.push(
{
start: `${h}:00`,
end: `${h}:30`,
time: `${h}:00-${h}:30`,
disabled: false,
active: false
},
{
start: `${h}:30`,
end: `${`0${home + 1}`.slice(-2)}:00`,
time: `${h}:30-${`0${home + 1}`.slice(-2)}:00`,
disabled: false,
active: false
}
);
}
this.list = list;
this.$nextTick(() => {
uni
.createSelectorQuery()
.in(this)
.select(".item")
.boundingClientRect(data => {
// 获取每个时间项的高度
this.itemHeight = data.height;
// 处理选择禁用
this.setDisabled();
})
.exec();
});
},
/**
* @description 点击时间项的处理函数
* @param {Object} item - 当前点击的时间项
* @param {number} index - 当前点击的时间项的索引
*/
clickItem(item, index) {
if (item.disabled) {
// 禁用的时间项不处理点击事件
return;
}
this.clickNumber++;
if (this.clickNumber == 1) {
// 第一次点击,记录索引
this.indexArr.push(index);
}
if (this.clickNumber == 2) {
// 第二次点击,记录索引
this.indexArr.push(index);
if (
this.maxTimeLength &&
(Math.abs(index - this.indexArr[0]) + 1) / 2 > this.maxTimeLength
) {
// 检查是否超过最大预约时长
this.indexArr = this.indexArr.sort((a, b) => a - b);
var isFlag = this.getCheckList(item, true);
if (isFlag) {
// 显示提示信息
uni.showToast({
title: `预约时长不可超过${this.maxTimeLength}小时`,
icon: "none",
duration: 1500
});
}
return;
}
}
if (this.clickNumber == 3) {
// 第三次点击,重置索引
this.indexArr = [index];
}
this.indexArr = this.indexArr.sort((a, b) => a - b);
if (this.clickNumber % 3 == 0) {
// 每三次点击重置激活状态
this.clickNumber = 1;
this.list.forEach(v => (v.active = false));
item.active = true;
}
// 获取选中的时间列表
this.getCheckList(item);
},
/**
* @description 获取选中的时间列表
* @param {Object} vitem - 当前时间项
* @param {boolean} isError - 是否为错误检查
* @returns {boolean} 是否成功获取选中的时间列表
*/
getCheckList(vitem, isError) {
var isFlag = true;
try {
this.list.forEach((item, idx) => {
const [start, end] = this.indexArr;
if (idx >= start && idx <= end) {
if (item.disabled) {
// 遇到禁用的时间项,抛出错误
throw new Error("end");
}
if (isError) return;
// 设置选中的时间项为激活状态
item.active = true;
}
});
} catch (err) {
// 处理错误,重置激活状态
this.list.forEach(v => (v.active = false));
vitem.active = true;
isFlag = false;
}
if (!isError) {
// 设置当前时间项为激活状态
vitem.active = true;
}
// 触发自定义事件,传递选中的时间列表
this.$emit("getTime", this.timeList);
return isFlag;
},
/**
* @description 点击标题的处理函数
* @param {Array} item - 标题对应的信息列表
*/
clickViewDetail(item) {
// 触发自定义事件,传递标题对应的信息列表
this.$emit("clickTitle", item.info);
}
}
};
</script>
<style lang="scss">
// 通用样式
.timeSelect-box{
padding: 20rpx 0 20rpx 80rpx;
}
.item {
min-height: 48rpx;
line-height: 48rpx;
// background: rgba(65, 163, 254, .2);
background: white;
border-bottom: 1px solid #d2d2d2;
position: relative;
.time-text {
width: 80rpx;
position: absolute;
left: -80rpx;
color: black;
font-size: 24rpx;
transform: translateY(-48%);
&.last {
transform: translateY(48%);
}
&.active {
color: red;
z-index: 2;
}
}
}
.item.select {
background: rgb(239, 248, 223);
// color: red;
border-bottom: none;
}
.item.active {
// background: #5076B8;
// color: #fff;
background: rgb(255, 230, 219);
border-bottom: none;
&::before {
content: "";
position: absolute;
width: 100%;
height: 100%;
background: rgb(255, 230, 219);
z-index: 11;
}
}
.item.active.border-top-red::before {
content: "";
position: absolute;
width: 100%;
top: -1px;
z-index: 11;
border-top: 1px solid red !important;
}
.item.active.border-bottom-red::before {
content: "";
position: absolute;
width: 100%;
bottom: 0px;
z-index: 11;
border-bottom: 1px solid red !important;
}
.item.disabled {
background: rgb(239, 248, 223);
color: #999999;
border-bottom: none;
}
.title-box,
.close-box {
position: absolute;
width: 100%;
top: -1px;
z-index: 10;
line-height: initial;
padding: 0 10%;
pointer-events: none;
text-align: center;
font-size: 28rpx;
.text {
pointer-events: all;
}
}
.item.close {
// background: rgb(204, 204, 204);
pointer-events: none;
border-bottom: none;
.close-box {
background: rgba(0, 0, 0, 0.3);
}
}
.item.disabled .title-box {
border-bottom: 1px solid #d2d2d2;
}
.item.select .title-box {
border-top: 1px solid rgb(152, 207, 59);
border-bottom: 1px solid rgb(152, 207, 59);
}
</style>
3、引入
<TimeSelection
ref="timeSelectRef"
:isShare="siteInfo.isShare"
:openTime="siteInfo.openTime"
:maxTimeLength="siteInfo.maxTimeLength"
:checkTime="checkTime"
:meetingDate="currentDate"
@getTime="getTime"
@clickTitle="clickShowTitle"
/>
4、具体使用及案例
<template>
<view class="bg-color" :class="timeList.start?'page-fixed-bottom':'page-bottom'">
<view class="p-10">
<u-scroll-list :indicator="false">
<view
class="date-box d-center flex-shrink-0"
:class="{'active':currentDate==item.date}"
v-for="(item, index) in dateList"
:key="index"
@click="clickSelectDate(item)"
>
<view class="text-center">
<view class="date-text font-28">{{item.showDate}}</view>
<view class="week-text font-24">{{item.week}}</view>
</view>
</view>
</u-scroll-list>
<TimeSelection
ref="timeSelectRef"
:isShare="siteInfo.isShare"
:openTime="siteInfo.openTime"
:maxTimeLength="siteInfo.maxTimeLength"
:checkTime="checkTime"
:meetingDate="currentDate"
@getTime="getTime"
@clickTitle="clickShowTitle"
/>
</view>
<view class="fixed bg-white p-lr-20 border-top-1" v-if="timeList.start">
<view class="d-align-center mt-10">
<view
class="btn-brand flex-grow-1"
@click="clickReservation"
>预约 {{timeList.start}}-{{timeList.end}}</view>
</view>
</view>
</view>
</template>
<script>
// 导入时间选择组件
import timeSelection from "./component/timeSelection.vue";
// 从工具模块导入时间格式化函数
// import { formatTime } from "../../utils/util";
/**
* @description 场地预约页面组件
*/
export default {
// 注册组件
components: {
timeSelection
},
// 定义数据
data() {
return {
// 场地信息
siteInfo: {
// 场地名称
name: "多功能研讨室",
// 可提前预约的天数
advanceDay: 10,
// 是否可共享
isShare: false,
// 最大预约时长
maxTimeLength: 2,
// 开放时间
openTime: ["9:00", "20:00"]
},
// 当前选中的日期
currentDate: "",
// 日期列表
dateList: [],
// 选中的时间范围
timeList: {
start: "",
end: ""
},
// 已选时间段列表
checkTime: []
};
},
// 初始化,页面加载时执行
async onLoad(options) {
// if (options?.siteId) {
// 加载数据
this.loadData();
// }
},
// 页面显示时执行
onShow() {
// this.getAllActiveListFun()
},
/**
* 页面上拉触底事件的处理函数
*/
onPullDownRefresh() {
// 下拉刷新时重新加载数据
this.loadData();
},
// 页面触底时执行
onReachBottom() {},
methods: {
/**
* @description 加载数据
*/
loadData() {
// 定义星期数组
var weeks = ["周日", "周一", "周二", "周三", "周四", "周伍", "周六"];
// 循环生成可预约的日期列表
for (var i = 0; i < this.siteInfo.advanceDay; i++) {
// 获取当前时间
var currentTime = new Date();
// 计算当前日期加上 i + 1 天后的日期
var time = new Date(currentTime.setDate(currentTime.getDate() + i + 1));
// 构建日期对象
var date = {
// 显示的日期(月-日)
showDate: this.formatTime(time, "md"),
// 完整日期(年-月-日)
date: this.formatTime(time, "ymd"),
// 星期几
week: weeks[time.getDay()]
};
// 将日期对象添加到日期列表中
this.dateList.push(date);
}
// 如果日期列表不为空
if (this.dateList.length) {
// 点击第一个日期
this.clickSelectDate(this.dateList[0]);
}
// 获取当前时间
var currentTime = new Date();
// 模拟已预约的时间段数据
var data = [
{
id: 1,
// 开始时间
beginTime: this.formatTime(currentTime, "ymd") + " 10:00",
// 结束时间
endTime: this.formatTime(currentTime, "ymd") + " 11:30",
// 活动名称
activityName: "我为同学做实事:电影宣传(二)1"
},
{
id: 2,
beginTime: this.formatTime(currentTime, "ymd") + " 15:00",
endTime: this.formatTime(currentTime, "ymd") + " 16:30",
activityName: "我为同学做实事:电影宣传(二)2"
}
// {
// id: 3,
// beginTime: this.formatTime(currentTime, "ymd") + " 16:00",
// endTime: this.formatTime(currentTime, "ymd") + " 17:30",
// activityName: "我为同学做实事:电影宣传(二)3"
// }
];
// 转换已预约时间段数据格式
this.checkTime = data.map(item => {
return {
...item,
// 开始时间(时:分)
start: this.formatTime(item.beginTime, "hm"),
// 标题
title: item.activityName,
// 结束时间(时:分)
end: this.formatTime(item.endTime, "hm")
};
});
//获取数据后初始化组件数据
this.$refs.timeSelectRef?.initTime()
},
/**
* @description 点击选择日期
* @param {Object} item - 点击的日期对象
*/
clickSelectDate(item) {
// 更新当前选中的日期
this.currentDate = item.date;
},
/**
* @description 获取选中的时间范围
* @param {Object} val - 选中的时间范围对象
*/
getTime(val) {
// 更新选中的时间范围
this.timeList = val;
},
/**
* @description 点击显示处理事件
*/
clickShowTitle() {},
/**
* @description 格式化日期
* @param {string|number|Date} currentDate - 要格式化的日期
* @param {string} format - 日期格式,默认为 "ymdhm"
* @returns {string} 格式化后的日期字符串
*/
formatTime(currentDate, format = "ymdhm") {
if (!currentDate) return "";
let date;
if (typeof currentDate === "string") {
currentDate = currentDate.replace(/-/g, "/");
date = new Date(currentDate);
} else if (typeof currentDate === "number") {
date = new Date(currentDate);
} else {
date = currentDate;
}
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const hour = date.getHours();
const minute = date.getMinutes();
const second = date.getSeconds();
const formatNumber = n => {
n = n.toString();
return n[1] ? n : "0" + n;
};
const formatMap = {
ymd: `${[year, month, day].map(formatNumber).join("/")}`,
md: `${[month, day].map(formatNumber).join("/")}`,
ymdhm: `${[year, month, day].map(formatNumber).join("/")} ${[
hour,
minute
]
.map(formatNumber)
.join(":")}`,
ymdhms: `${[year, month, day].map(formatNumber).join("/")} ${[
hour,
minute,
second
]
.map(formatNumber)
.join(":")}`,
hms: `${[hour, minute, second].map(formatNumber).join(":")}`,
hm: `${[hour, minute].map(formatNumber).join(":")}`
};
return formatMap[format] || "";
}
}
};
</script>
<style lang="scss" scoped>
.date-box {
border-radius: 8rpx;
width: 104rpx;
height: 112rpx;
background-color: white;
margin-right: 16rpx;
&.active {
border-radius: 20rpx;
// box-shadow: 0px 5px 30px 0px rgba(209, 66, 1, 0.2),inset 0px 0px 20px 0px rgb(255, 255, 255);
background: linear-gradient(
-45deg,
rgb(247, 106, 52),
rgb(248, 142, 60) 98.473%
);
.date-text,
.week-text {
color: white;
}
}
}
.page-fixed-bottom,
.fixed {
padding-bottom: calc(0rpx + env(safe-area-inset-bottom));
.btn-brand {
padding: 22rpx;
}
}
//通用样式-可修改
.bg-color {
background-color: #f7f8fa;
}
.page-fixed-bottom {
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.p-10 {
padding: 20rpx;
}
.border-top-1 {
border-top: 2rpx solid #eeeeee;
}
.fixed {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
.bg-white {
background-color: white;
}
.p-lr-20 {
padding-left: 4%;
padding-right: 40rpx;
}
.mt-10 {
margin-top: 10rpx;
}
.font-28 {
font-size: 28rpx;
}
.font-24 {
font-size: 24rpx;
}
.d-center{
display: flex;
justify-content: center;
align-items: center;
}
.flex-shrink-0 {
flex-shrink: 0;
}
.d-align-center {
display: flex;
align-items: center;
}
.text-center {
text-align: center;
}
.btn-brand {
padding: 28rpx;
text-align: center;
color: white;
font-size: 32rpx;
border-radius: 100rpx;
box-shadow: 0px 10rpx 40rpx 0px rgba(247, 114, 54, 0.2);
background: linear-gradient(-45deg, #f76a34, #f88e3c 98.473%);
transition: -webkit-transform 0.3s;
transition: transform 0.3s;
transition: transform 0.3s, -webkit-transform 0.3s;
}
.flex-grow-1 {
flex-grow: 1;
}
</style>