Composant de sélection d'horaires pour UniApp et MiniProgrammes
Ce composant permet la sélection d'horaires pour des réservations dans des applications MiniProgrammes ou H5. Il offre la possibilité de choisir des plages horiares spécifiques pour différents jours, prend en charge la désactivation et le partage des créneaux, et permet de définir une contrainte sur la durée maximalee de réservation.
1. Props Disponibles
/**
* @description Composant de sélection de créneaux de réservation
* @props {boolean} isShare - Indique si le créneau est partageable (défaut: false)
* @props {Array} checkTime - Tableau des créneaux déjà réservés (défaut: [])
* @props {Array} openTime - Tableau des heures d'ouverture [début, fin] (défaut: [])
* @props {string} meetingDate - Date sélectionnée pour la réservation (défaut: "")
* @props {number} start - Heure de début d'affichage (défaut: 8, doit être > 0 et entier)
* @props {number} end - Heure de fin d'affichage (défaut: 23, doit être < 24 et entier)
* @props {number} maxTimeLength - Durée maximale de réservation autorisée
*/
2. Code du Composant
<template>
<view>
<view class="time-selection-container">
<view :key="index" class="time-slot" v-bind:class="{active: slot.active, disabled: slot.disabled, selected: slot.isSelect, closed: slot.isClose, 'border-top-highlight': timeRange.start === slot.start, 'border-bottom-highlight': timeRange.end === slot.end}" v-for="(slot, index) in timeSlots" v-on:click="handleClickSlot(slot, index)">
<view :class="{'active-text': timeRange.start === slot.start}" class="time-label" v-if="timeRange.start === slot.start">{{slot.start}}</view>
<view :class="{'active-text': timeRange.end === slot.end}" class="time-label end-label" v-if="timeRange.end === slot.end">{{slot.end}}</view>
<view class="time-label" v-if="index % 2 === 0">{{slot.start}}</view>
<view class="time-label end-label" v-else-if="index === timeSlots.length - 1">{{slot.end}}</view>
<view :style="{height: slot.height + 'px'}" class="slot-title" v-if="slot.title">
<view class="title-text">{{slot.title}}</view>
</view>
<view :style="{height: slot.unavailableCount * slotItemHeight + 'px'}" class="unavailable-overlay" v-if="slot.unavailableCount">Ce créneau n'est pas disponible</view>
</view>
</view>
</view>
</template>
<script>
/**
* @description Composant de sélection de créneaux horaires.
* @props {boolean} isShare - Indique si le créneau est partageable (défaut: false).
* @props {Array} checkTime - Tableau des créneaux déjà réservés (défaut: []).
* @props {Array} openTime - Tableau des heures d'ouverture [début, fin] (défaut: []).
* @props {string} meetingDate - Date sélectionnée pour la réservation (défaut: "").
* @props {number} start - Heure de début d'affichage (défaut: 8, doit être > 0 et entier).
* @props {number} end - Heure de fin d'affichage (défaut: 23, doit être < 24 et entier).
* @props {number} maxTimeLength - Durée maximale de réservation autorisée.
*/
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) {
return value > 0 && Number.isInteger(value);
}
},
end: {
type: Number,
default: 23,
validator: function(value) {
return value < 24 && Number.isInteger(value);
}
},
maxTimeLength: Number
},
data() {
return {
timeSlots: [],
clickCount: 0,
disabledSlots: [],
selectedIndices: [],
slotItemHeight: 0
};
},
created() {
// Initialisation déclenchée par le composant parent via initTime()
},
computed: {
timeRange() {
let activeSlots = [];
this.timeSlots.forEach(slot => {
if (slot.active) {
activeSlots.push(slot);
}
});
let range = {
start: "",
end: ""
};
if (activeSlots.length) {
range.start = activeSlots[0].start.split(":")[0];
range.end = activeSlots[activeSlots.length - 1].end.split(":")[1];
}
return range;
}
},
methods: {
/**
* @description Définit les créneaux comme inaccessibles en fonction des réservations existantes et des règles.
*/
updateSlotStatus() {
if (this.isShare) {
this.mergeBookedSlots();
}
this.checkTime.forEach(reservation => {
if (this.isShare) {
this.disabledSlots.push(
this.generateTimeArray(reservation.start, reservation.lastTime || reservation.end)
);
} else {
this.disabledSlots.push(this.generateTimeArray(reservation.start, reservation.end));
}
});
let currentHour = new Date().getHours();
let unavailableStartIndex = -1;
this.timeSlots.forEach((slot, index) => {
this.disabledSlots.forEach((reservedRange, resIndex) => {
const isSlotInReservedRange = reservedRange.includes(slot.time);
const bookedSlot = this.checkTime[resIndex];
if (isSlotInReservedRange && !this.isShare) {
slot.title = bookedSlot.title;
slot.info = [bookedSlot];
slot.height = this.slotItemHeight * reservedRange.length + 1;
}
if (isSlotInReservedRange && this.isShare && !bookedSlot.isRecord) {
slot.title = `${bookedSlot.count || 1} réservation(s)`;
slot.info = bookedSlot.info || [bookedSlot];
slot.height = this.slotItemHeight * reservedRange.length + 1;
}
if (isSlotInReservedRange) {
if (this.isShare) {
slot.isSelect = true;
} else {
slot.disabled = true;
}
}
});
// Logique pour marquer les créneaux avant l'heure actuelle comme fermés (peut être activée/désactivée)
// const isPastTime = this.timeToDateObject(slot.start) < new Date();
// if (isPastTime) {
// slot.isClose = true;
// if (unavailableStartIndex === -1) unavailableStartIndex = index;
// const firstUnavailableSlot = this.timeSlots[unavailableStartIndex];
// firstUnavailableSlot.unavailableCount = (firstUnavailableSlot.unavailableCount || 0) + 1;
// }
if (this.openTime?.length === 2) {
if (this.timeToDateObject(slot.start) < this.timeToDateObject(this.openTime[0])) {
slot.isClose = true;
if (unavailableStartIndex === -1) unavailableStartIndex = index;
const firstUnavailableSlot = this.timeSlots[unavailableStartIndex];
firstUnavailableSlot.unavailableCount = (firstUnavailableSlot.unavailableCount || 0) + 1;
}
}
this.$set(this.timeSlots, index, slot);
});
},
/**
* @description Fusionne les créneaux réservés adjacents pour l'affichage.
*/
mergeBookedSlots() {
this.checkTime.forEach((res, index) => {
if (res.isRecord) return;
for (let i = 0; i < this.checkTime.length; i++) {
if (index === i) continue;
const nextRes = this.checkTime[i];
if (res.lastTime) {
if (
this.timeToDateObject(nextRes.start) >= this.timeToDateObject(res.start) &&
this.timeToDateObject(nextRes.start) <= this.timeToDateObject(res.lastTime)
) {
res.lastTime = nextRes.end;
nextRes.isRecord = true;
res.count = (res.count || 0) + 1;
res.info = res.info || [JSON.parse(JSON.stringify(res))];
res.info.push(nextRes);
}
} else {
if (
this.timeToDateObject(nextRes.start) >= this.timeToDateObject(res.start) &&
this.timeToDateObject(nextRes.start) <= this.timeToDateObject(res.end)
) {
res.lastTime = nextRes.end;
nextRes.isRecord = true;
res.count = (res.count || 0) + 1;
res.info = res.info || [JSON.parse(JSON.stringify(res))];
res.info.push(nextRes);
}
}
}
});
},
/**
* @description Convertit une chaîne horaire en objet Date.
* @param {string} timeStr - Chaîne horaire au format "HH:MM".
* @returns {Date} Objet Date.
*/
timeToDateObject(timeStr) {
return new Date(`${this.meetingDate} ${timeStr}:00`);
},
/**
* @description Génère un tableau de chaînes horaires entre deux temps donnés.
* @param {string} startTime - Heure de début au format "HH:MM".
* @param {string} endTime - Heure de fin au format "HH:MM".
* @returns {Array<string>} Tableau des créneaux horaires.
*/
generateTimeArray(startTime, endTime) {
const slots = [];
for (let hour = this.start; hour < this.end; hour++) {
const h = String(hour).padStart(2, '0');
slots.push(`${h}:00-${h}:30`, `${h}:30-${String(hour + 1).padStart(2, '0')}:00`);
}
const startIndex = slots.findIndex(slot => slot.startsWith(startTime));
const endIndex = slots.findIndex(slot => slot.endsWith(endTime));
return slots.slice(startIndex, endIndex + 1);
},
/**
* @description Initialise la liste des créneaux horaires.
*/
initTimeSlots() {
this.disabledSlots = [];
const slots = [];
for (let hour = this.start; hour < this.end; hour++) {
const h = String(hour).padStart(2, '0');
slots.push(
{
start: `${h}:00`,
end: `${h}:30`,
time: `${h}:00-${h}:30`,
disabled: false,
active: false
},
{
start: `${h}:30`,
end: `${String(hour + 1).padStart(2, '0')}:00`,
time: `${h}:30-${String(hour + 1).padStart(2, '0')}:00`,
disabled: false,
active: false
}
);
}
this.timeSlots = slots;
this.$nextTick(() => {
uni.createSelectorQuery().in(this).select('.time-slot').boundingClientRect(data => {
this.slotItemHeight = data.height;
this.updateSlotStatus();
}).exec();
});
},
/**
* @description Gère le clic sur un créneau horaire.
* @param {Object} slot - Le créneau horaire cliqué.
* @param {number} index - L'index du créneau horaire cliqué.
*/
handleClickSlot(slot, index) {
if (slot.disabled) {
return;
}
this.clickCount++;
if (this.clickCount === 1) {
this.selectedIndices.push(index);
}
if (this.clickCount === 2) {
this.selectedIndices.push(index);
if (this.maxTimeLength && (Math.abs(index - this.selectedIndices[0]) + 1) / 2 > this.maxTimeLength) {
this.selectedIndices.sort((a, b) => a - b);
const isTooLong = this.validateSelection(slot, true);
if (isTooLong) {
uni.showToast({
title: `La durée ne peut excéder ${this.maxTimeLength} heure(s)`,
icon: "none",
duration: 1500
});
}
return;
}
}
if (this.clickCount === 3) {
this.selectedIndices = [index];
}
this.selectedIndices.sort((a, b) => a - b);
if (this.clickCount % 3 === 0) {
this.clickCount = 1;
this.timeSlots.forEach(s => s.active = false);
slot.active = true;
}
this.validateSelection(slot);
},
/**
* @description Valide la sélection des créneaux et met à jour l'état actif.
* @param {Object} currentSlot - Le créneau horaire actuellement cliqué.
* @param {boolean} isErrorCheck - Indique si c'est une vérification d'erreur.
* @returns {boolean} True si la sélection est valide, false sinon.
*/
validateSelection(currentSlot, isErrorCheck) {
let isValid = true;
try {
const [startIndex, endIndex] = this.selectedIndices;
this.timeSlots.forEach((slot, idx) => {
if (idx >= startIndex && idx <= endIndex) {
if (slot.disabled) {
throw new Error("invalid selection");
}
if (isErrorCheck) return;
slot.active = true;
}
});
} catch (error) {
this.timeSlots.forEach(s => s.active = false);
currentSlot.active = true;
isValid = false;
}
if (!isErrorCheck) {
currentSlot.active = true;
}
this.$emit("update:timeRange", this.timeRange);
return isValid;
},
/**
* @description Gère le clic sur le titre d'un créneau réservé.
* @param {Array} slotInfo - Informations sur le créneau réservé.
*/
handleViewDetails(slotInfo) {
this.$emit("click:title", slotInfo.info);
}
}
};
</script>
<style lang="scss">
.time-selection-container {
padding: 20rpx 0 20rpx 80rpx;
}
.time-slot {
min-height: 48rpx;
line-height: 48rpx;
background: white;
border-bottom: 1px solid #d2d2d2;
position: relative;
.time-label {
width: 80rpx;
position: absolute;
left: -80rpx;
color: black;
font-size: 24rpx;
transform: translateY(-48%);
&.end-label {
transform: translateY(48%);
}
&.active-text {
color: red;
z-index: 2;
}
}
}
.time-slot.selected {
background: #e0f2e0; /* Light green for selected */
border-bottom: none;
}
.time-slot.active {
background: #ffe6db; /* Light red/orange for active selection */
border-bottom: none;
&::before {
content: "";
position: absolute;
width: 100%;
height: 100%;
background: #ffe6db;
z-index: 11;
}
}
.time-slot.active.border-top-highlight::before {
content: "";
position: absolute;
width: 100%;
top: -1px;
z-index: 11;
border-top: 1px solid red !important;
}
.time-slot.active.border-bottom-highlight::before {
content: "";
position: absolute;
width: 100%;
bottom: 0px;
z-index: 11;
border-bottom: 1px solid red !important;
}
.time-slot.disabled {
background: #e0f2e0; /* Light green for selected/disabled */
color: #999999;
border-bottom: none;
}
.slot-title,
.unavailable-overlay {
position: absolute;
width: 100%;
top: -1px;
z-index: 10;
line-height: initial;
padding: 0 10%;
pointer-events: none;
text-align: center;
font-size: 28rpx;
.title-text {
pointer-events: all;
}
}
.time-slot.closed {
pointer-events: none;
border-bottom: none;
.unavailable-overlay {
background: rgba(0, 0, 0, 0.3);
}
}
.time-slot.disabled .slot-title {
border-bottom: 1px solid #d2d2d2;
}
.time-slot.selected .slot-title {
border-top: 1px solid #98cf3b;
border-bottom: 1px solid #98cf3b;
}
</style>
3. Intégration du Composant
<timeselector :checktime="bookedSlots" :isshare="siteInfo.isShare" :maxtimelength="siteInfo.maxTimeLength" :meetingdate="currentDate" :opentime="siteInfo.openTime" ref="timeSelectorRef"></timeselector>
4. Exemple d'Utilisation
<template>
<view :class="timeRange.start ? 'page-with-footer' : 'page-no-footer'" class="booking-page">
<view class="date-navigator">
<u-scroll-list :indicator="false">
<view :class="{'active-date': currentDate === date.fullDate}" :key="index" class="date-item" v-for="(date, index) in dateList">
<view class="text-center">
<view class="date-display font-size-28">{{date.displayDate}}</view>
<view class="weekday-display font-size-24">{{date.weekday}}</view>
</view>
</view>
</u-scroll-list>
<timeselector :checktime="bookedSlots" :isshare="siteInfo.isShare" :maxtimelength="siteInfo.maxTimeLength" :meetingdate="currentDate" :opentime="siteInfo.openTime" ref="timeSelectorRef"></timeselector>
</view>
<view class="booking-footer" v-if="timeRange.start">
<view class="action-bar">
<view class="book-button flex-grow-1">Réserver {{timeRange.start}}-{{timeRange.end}}</view>
</view>
</view>
</view>
</template>
<script>
import TimeSelector from "./components/TimeSelector.vue"; // Assurez-vous que le chemin est correct
/**
* @description Page de réservation de salle.
*/
export default {
components: {
TimeSelector
},
data() {
return {
siteInfo: {
name: "Salle de conférence polyvalente",
advanceDays: 10,
isShare: false,
maxTimeLength: 2, // en heures
openTime: ["09:00", "20:00"]
},
currentDate: "",
dateList: [],
timeRange: {
start: "",
end: ""
},
bookedSlots: []
};
},
async onLoad(options) {
this.loadInitialData();
},
onShow() {
// Logique à exécuter à l'affichage de la page
},
onPullDownRefresh() {
this.loadInitialData();
},
methods: {
/**
* @description Charge les données initiales de la page.
*/
loadInitialData() {
const weekdays = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"];
const today = new Date();
for (let i = 0; i < this.siteInfo.advanceDays; i++) {
const futureDate = new Date(today);
futureDate.setDate(today.getDate() + i);
const dateObj = {
displayDate: this.formatDateTime(futureDate, "MM/DD"),
fullDate: this.formatDateTime(futureDate, "YYYY-MM-DD"),
weekday: weekdays[futureDate.getDay()]
};
this.dateList.push(dateObj);
}
if (this.dateList.length) {
this.selectDate(this.dateList[0]);
}
// Simulation des données de réservations existantes
const simulatedBookings = [
{ id: 1, beginTime: this.formatDateTime(today, "YYYY-MM-DD") + " 10:00", endTime: this.formatDateTime(today, "YYYY-MM-DD") + " 11:30", title: "Réunion Projet Alpha" },
{ id: 2, beginTime: this.formatDateTime(today, "YYYY-MM-DD") + " 15:00", endTime: this.formatDateTime(today, "YYYY-MM-DD") + " 16:30", title: "Présentation Client" }
];
this.bookedSlots = simulatedBookings.map(booking => ({
...booking,
start: this.formatDateTime(booking.beginTime, "HH:mm"),
end: this.formatDateTime(booking.endTime, "HH:mm"),
title: booking.title
}));
// Assurez-vous que le composant TimeSelector est prêt avant d'appeler initTimeSlots
this.$nextTick(() => {
this.$refs.timeSelectorRef?.initTimeSlots();
});
},
/**
* @description Gère la sélection d'une date.
* @param {Object} dateItem - L'objet date sélectionné.
*/
selectDate(dateItem) {
this.currentDate = dateItem.fullDate;
},
/**
* @description Met à jour la plage horaire sélectionnée.
* @param {Object} range - L'objet représentant la plage horaire sélectionnée.
*/
handleTimeRangeUpdate(range) {
this.timeRange = range;
},
/**
* @description Gère le clic sur le titre d'une réservation.
*/
handleTitleClick() {
// Implémenter la logique pour afficher les détails de la réservation si nécessaire
},
/**
* @description Confirme la réservation.
*/
confirmBooking() {
if (this.timeRange.start) {
// Logique de confirmation de réservation ici
console.log("Réservation confirmée pour:", this.currentDate, this.timeRange);
}
},
/**
* @description Formate une date/heure selon un modèle donné.
* @param {string|number|Date} dateTime - La date/heure à formater.
* @param {string} formatString - Le modèle de formatage (ex: "YYYY-MM-DD HH:mm").
* @returns {string} La date/heure formatée.
*/
formatDateTime(dateTime, formatString = "YYYY-MM-DD HH:mm:ss") {
if (!dateTime) return "";
const date = dateTime instanceof Date ? dateTime : new Date(String(dateTime).replace(/-/g, '/'));
const pad = (num) => String(num).padStart(2, '0');
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
const formatMap = {
'YYYY': year,
'MM': month,
'DD': day,
'HH': hours,
'mm': minutes,
'ss': seconds,
'YY': String(year).slice(-2)
};
let formatted = formatString;
for (const key in formatMap) {
formatted = formatted.replace(key, formatMap[key]);
}
// Gérer les formats courants si non spécifiés explicitement
if (formatString === "MM/DD") return `${month}/${day}`;
if (formatString === "YYYY-MM-DD") return `${year}-${month}-${day}`;
if (formatString === "HH:mm") return `${hours}:${minutes}`;
return formatted;
}
}
};
</script>
<style lang="scss" scoped="">
.date-item {
border-radius: 8rpx;
width: 104rpx;
height: 112rpx;
background-color: white;
margin-right: 16rpx;
&.active-date {
border-radius: 20rpx;
background: linear-gradient(-45deg, #f76a34, #f88e3c 98.473%);
.date-display,
.weekday-display {
color: white;
}
}
}
.page-with-footer,
.footer-container {
padding-bottom: calc(0rpx + env(safe-area-inset-bottom));
.book-button {
padding: 22rpx;
}
}
.background-primary {
background-color: #f7f8fa;
}
.page-with-footer {
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.padding-standard {
padding: 20rpx;
}
.border-top-accent {
border-top: 2rpx solid #eeeeee;
}
.footer-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
.background-white {
background-color: white;
}
.padding-lr-medium {
padding-left: 4%;
padding-right: 40rpx;
}
.margin-top-small {
margin-top: 10rpx;
}
.font-size-28 {
font-size: 28rpx;
}
.font-size-24 {
font-size: 24rpx;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.flex-shrink-zero {
flex-shrink: 0;
}
.flex-align-center {
display: flex;
align-items: center;
}
.text-align-center {
text-align: center;
}
.book-button {
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>