662 lines
16 KiB
Vue
662 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { EventEnum } from "@/constant/enums";
|
|
|
|
const { t } = useI18n();
|
|
import dayjs from "dayjs";
|
|
|
|
const storeBusinessHours = ref('');
|
|
const onlySelectDay = ref(true);
|
|
|
|
import { parseDeliveryScheduleTimes, isDeliveryScheduleDay } from '@/utils/deliverySchedule';
|
|
|
|
const allowedWeekdays = ref<number[]>([]);
|
|
const isAllowedDay = (date: Date): boolean => {
|
|
return isDeliveryScheduleDay(date, allowedWeekdays.value);
|
|
};
|
|
|
|
interface BusinessHours {
|
|
days: string[];
|
|
startTime: string;
|
|
endTime: string;
|
|
}
|
|
|
|
const parseBusinessHours = (businessHoursStr: string): BusinessHours[] => {
|
|
if (!businessHoursStr) return [];
|
|
|
|
const businessHours: BusinessHours[] = [];
|
|
const segments = businessHoursStr.split(";");
|
|
|
|
segments.forEach((segment) => {
|
|
const trimmedSegment = segment.trim();
|
|
if (!trimmedSegment) return;
|
|
|
|
const parts = trimmedSegment.split(" ");
|
|
if (parts.length !== 2) return;
|
|
|
|
const dayStr = parts[0].trim().toUpperCase();
|
|
const timeStr = parts[1];
|
|
|
|
const timeRange = timeStr.split("-");
|
|
if (timeRange.length !== 2) return;
|
|
|
|
const startTime = timeRange[0].trim();
|
|
const endTime = timeRange[1].trim();
|
|
|
|
const days = dayStr
|
|
.split("/")
|
|
.map((day) => day.trim())
|
|
.filter((day) => day);
|
|
|
|
businessHours.push({
|
|
days,
|
|
startTime,
|
|
endTime,
|
|
});
|
|
});
|
|
|
|
return businessHours;
|
|
};
|
|
|
|
const getBusinessHoursForDate = (date: Date): BusinessHours | null => {
|
|
if (!storeBusinessHours.value) return null;
|
|
|
|
const businessHours = parseBusinessHours(storeBusinessHours.value);
|
|
|
|
const dayNames = [
|
|
"SUNDAY",
|
|
"MONDAY",
|
|
"TUESDAY",
|
|
"WEDNESDAY",
|
|
"THURSDAY",
|
|
"FRIDAY",
|
|
"SATURDAY",
|
|
];
|
|
const dayName = dayNames[date.getDay()];
|
|
|
|
return businessHours.find((hours) => hours.days.includes(dayName)) || null;
|
|
};
|
|
|
|
const isDateOpen = (date: Date): boolean => {
|
|
if (!storeBusinessHours.value) return true;
|
|
return getBusinessHoursForDate(date) !== null;
|
|
};
|
|
|
|
const isDateSelectable = (date: Date): boolean => {
|
|
if (!isAllowedDay(date)) {
|
|
return false;
|
|
}
|
|
|
|
if (onlySelectDay.value) {
|
|
if (!storeBusinessHours.value) return true;
|
|
return isDateOpen(date);
|
|
}
|
|
|
|
if (!storeBusinessHours.value) return true;
|
|
if (!isDateOpen(date)) return false;
|
|
return hasAvailableTimeSlots(date);
|
|
};
|
|
|
|
const dateOptions = computed(() => {
|
|
const dates: Date[] = [];
|
|
const today = new Date();
|
|
|
|
for (let i = 0; i < 7; i++) {
|
|
const date = new Date(today);
|
|
date.setDate(today.getDate() + i);
|
|
dates.push(date);
|
|
}
|
|
|
|
return dates;
|
|
});
|
|
|
|
const thisWeekDates = computed(() => dateOptions.value.slice(0, 5));
|
|
const nextWeekDates = computed(() => dateOptions.value.slice(5, 7));
|
|
|
|
const selectedDate = ref<Date>();
|
|
|
|
const isTimeInBusinessHours = (
|
|
hour: number,
|
|
minute: number,
|
|
businessHours: BusinessHours
|
|
): boolean => {
|
|
const timeStr = `${hour.toString().padStart(2, "0")}:${minute
|
|
.toString()
|
|
.padStart(2, "0")}`;
|
|
return timeStr >= businessHours.startTime && timeStr <= businessHours.endTime;
|
|
};
|
|
|
|
const hasAvailableTimeSlots = (date: Date): boolean => {
|
|
const now = new Date();
|
|
const currentHour = now.getHours();
|
|
const currentMinute = now.getMinutes();
|
|
|
|
const businessHours = getBusinessHoursForDate(date);
|
|
|
|
if (!businessHours) {
|
|
return false;
|
|
}
|
|
|
|
const [startHour, startMinute] = businessHours.startTime
|
|
.split(":")
|
|
.map(Number);
|
|
const [endHour, endMinute] = businessHours.endTime.split(":").map(Number);
|
|
|
|
for (let hour = startHour; hour <= endHour; hour++) {
|
|
for (let minute = 0; minute < 60; minute += 30) {
|
|
if (!isTimeInBusinessHours(hour, minute, businessHours)) {
|
|
continue;
|
|
}
|
|
|
|
let nextHour = hour;
|
|
let nextMinute = minute + 30;
|
|
if (nextMinute >= 60) {
|
|
nextHour++;
|
|
nextMinute = 0;
|
|
}
|
|
|
|
if (!isTimeInBusinessHours(nextHour, nextMinute, businessHours)) {
|
|
if (
|
|
nextHour > endHour ||
|
|
(nextHour === endHour && nextMinute > endMinute)
|
|
) {
|
|
nextHour = endHour;
|
|
nextMinute = endMinute;
|
|
}
|
|
}
|
|
|
|
if (hour === nextHour && minute === nextMinute) {
|
|
continue;
|
|
}
|
|
|
|
if (date.toDateString() === now.toDateString()) {
|
|
if (
|
|
hour < currentHour ||
|
|
(hour === currentHour && minute <= currentMinute)
|
|
) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const userPickedDate = ref(false);
|
|
|
|
/** 从今天起查找最近可配送日(配送日 + 营业时间) */
|
|
const findNearestDeliverableDate = (maxDays = 30): Date | null => {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
for (let i = 0; i <= maxDays; i++) {
|
|
const d = new Date(today);
|
|
d.setDate(today.getDate() + i);
|
|
if (isDateSelectable(d)) {
|
|
return d;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const initializeSelectedDate = (resetUserPick = false) => {
|
|
if (resetUserPick) {
|
|
userPickedDate.value = false;
|
|
}
|
|
if (userPickedDate.value) {
|
|
return;
|
|
}
|
|
|
|
const nearest = findNearestDeliverableDate(30);
|
|
if (nearest) {
|
|
selectedDate.value = nearest;
|
|
selectedTimeSlot.value = "";
|
|
return;
|
|
}
|
|
|
|
selectedDate.value = dateOptions.value[0] ?? new Date();
|
|
selectedTimeSlot.value = "";
|
|
};
|
|
|
|
watch(
|
|
() => [...allowedWeekdays.value],
|
|
() => {
|
|
initializeSelectedDate();
|
|
}
|
|
);
|
|
|
|
watch(storeBusinessHours, () => {
|
|
initializeSelectedDate();
|
|
});
|
|
const selectedTimeSlot = ref<string>("");
|
|
|
|
const formatWeekdayCircle = (date: Date) => dayjs(date).format("dddd");
|
|
|
|
const formatCircleSubLine = (date: Date) => {
|
|
if (!isDateSelectable(date)) {
|
|
return t("pages.address.reservationTime.notAvailable");
|
|
}
|
|
return dayjs(date).format("MM/DD");
|
|
};
|
|
|
|
const timeSlots = computed(() => {
|
|
const slots: string[] = [];
|
|
const now = new Date();
|
|
const currentHour = now.getHours();
|
|
const currentMinute = now.getMinutes();
|
|
|
|
if (!selectedDate.value) {
|
|
return slots;
|
|
}
|
|
|
|
const businessHours = getBusinessHoursForDate(selectedDate.value);
|
|
|
|
if (!businessHours) {
|
|
for (let hour = 0; hour < 24; hour++) {
|
|
for (let minute = 0; minute < 60; minute += 30) {
|
|
if (selectedDate.value.toDateString() === now.toDateString()) {
|
|
if (
|
|
hour < currentHour ||
|
|
(hour === currentHour && minute <= currentMinute)
|
|
) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const startHour = hour.toString().padStart(2, "0");
|
|
const startMinute = minute.toString().padStart(2, "0");
|
|
const endHour =
|
|
minute === 30
|
|
? (hour + 1).toString().padStart(2, "0")
|
|
: hour.toString().padStart(2, "0");
|
|
const endMinute = minute === 30 ? "00" : "30";
|
|
|
|
const timeSlot = `${startHour}:${startMinute} - ${endHour}:${endMinute}`;
|
|
slots.push(timeSlot);
|
|
}
|
|
}
|
|
return slots;
|
|
}
|
|
|
|
const [startHour, startMinute] = businessHours.startTime
|
|
.split(":")
|
|
.map(Number);
|
|
const [endHour, endMinute] = businessHours.endTime.split(":").map(Number);
|
|
|
|
for (let hour = startHour; hour <= endHour; hour++) {
|
|
for (let minute = 0; minute < 60; minute += 30) {
|
|
if (!isTimeInBusinessHours(hour, minute, businessHours)) {
|
|
continue;
|
|
}
|
|
|
|
let nextHour = hour;
|
|
let nextMinute = minute + 30;
|
|
if (nextMinute >= 60) {
|
|
nextHour++;
|
|
nextMinute = 0;
|
|
}
|
|
|
|
if (!isTimeInBusinessHours(nextHour, nextMinute, businessHours)) {
|
|
if (
|
|
nextHour > endHour ||
|
|
(nextHour === endHour && nextMinute > endMinute)
|
|
) {
|
|
nextHour = endHour;
|
|
nextMinute = endMinute;
|
|
}
|
|
}
|
|
|
|
if (hour === nextHour && minute === nextMinute) {
|
|
continue;
|
|
}
|
|
|
|
if (selectedDate.value.toDateString() === now.toDateString()) {
|
|
if (
|
|
hour < currentHour ||
|
|
(hour === currentHour && minute <= currentMinute)
|
|
) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const startHourStr = hour.toString().padStart(2, "0");
|
|
const startMinuteStr = minute.toString().padStart(2, "0");
|
|
const endHourStr = nextHour.toString().padStart(2, "0");
|
|
const endMinuteStr = nextMinute.toString().padStart(2, "0");
|
|
|
|
const timeSlot = `${startHourStr}:${startMinuteStr} - ${endHourStr}:${endMinuteStr}`;
|
|
slots.push(timeSlot);
|
|
}
|
|
}
|
|
|
|
return slots;
|
|
});
|
|
|
|
watch(timeSlots, (newSlots) => {
|
|
if (onlySelectDay.value) return;
|
|
if (selectedDate.value && newSlots.length === 0) {
|
|
const nextBusinessDate = findNextBusinessDate(selectedDate.value);
|
|
if (nextBusinessDate) {
|
|
selectedDate.value = nextBusinessDate;
|
|
selectedTimeSlot.value = ""; // 清空已选择的时间段
|
|
uni.showToast({
|
|
title: t('pages.address.reservationTime.noAvailableTime'),
|
|
icon: "none",
|
|
duration: 2000,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
const findNextBusinessDate = (currentDate: Date): Date | null => {
|
|
const maxDays = 30;
|
|
for (let i = 1; i <= maxDays; i++) {
|
|
const nextDate = new Date(currentDate);
|
|
nextDate.setDate(currentDate.getDate() + i);
|
|
|
|
const isInRange = dateOptions.value.some((date) =>
|
|
dayjs(date).isSame(dayjs(nextDate), "day")
|
|
);
|
|
|
|
if (
|
|
isInRange &&
|
|
isAllowedDay(nextDate) &&
|
|
isDateOpen(nextDate) &&
|
|
hasAvailableTimeSlots(nextDate)
|
|
) {
|
|
return nextDate;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const selectDate = (date: Date) => {
|
|
if (!isDateSelectable(date)) {
|
|
uni.showToast({
|
|
title: t('pages.address.reservationTime.dateNotSelectable'),
|
|
icon: "none",
|
|
});
|
|
return;
|
|
}
|
|
|
|
userPickedDate.value = true;
|
|
selectedDate.value = date;
|
|
selectedTimeSlot.value = "";
|
|
};
|
|
|
|
const selectTimeSlot = (timeSlot: string) => {
|
|
selectedTimeSlot.value = timeSlot;
|
|
};
|
|
|
|
const isDateSelected = (date: Date) =>
|
|
!!selectedDate.value && dayjs(selectedDate.value).isSame(dayjs(date), "day");
|
|
|
|
const submitReservation = () => {
|
|
const dateVal = selectedDate.value;
|
|
if (!dateVal) {
|
|
return;
|
|
}
|
|
|
|
if (!onlySelectDay.value) {
|
|
if (!selectedTimeSlot.value) {
|
|
uni.showToast({
|
|
title: t('pages.address.reservationTime.selectTimeSlot'),
|
|
icon: "none",
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const selectedDateDayjs = dayjs(dateVal);
|
|
let startTime: dayjs.Dayjs;
|
|
let endTime: dayjs.Dayjs;
|
|
|
|
if (onlySelectDay.value) {
|
|
const bh = getBusinessHoursForDate(dateVal);
|
|
if (bh) {
|
|
const [startHour, startMinute] = bh.startTime.split(':').map(Number);
|
|
const [endHour, endMinute] = bh.endTime.split(':').map(Number);
|
|
startTime = selectedDateDayjs
|
|
.hour(startHour)
|
|
.minute(startMinute)
|
|
.second(0)
|
|
.millisecond(0);
|
|
endTime = selectedDateDayjs
|
|
.hour(endHour)
|
|
.minute(endMinute)
|
|
.second(0)
|
|
.millisecond(0);
|
|
} else {
|
|
startTime = selectedDateDayjs.startOf('day');
|
|
endTime = selectedDateDayjs.endOf('day');
|
|
}
|
|
} else {
|
|
const [startTimeStr, endTimeStr] = selectedTimeSlot.value.split(' - ');
|
|
const [startHour, startMinute] = startTimeStr.split(':').map(Number);
|
|
const [endHour, endMinute] = endTimeStr.split(':').map(Number);
|
|
startTime = selectedDateDayjs
|
|
.hour(startHour)
|
|
.minute(startMinute)
|
|
.second(0)
|
|
.millisecond(0);
|
|
endTime = selectedDateDayjs
|
|
.hour(endHour)
|
|
.minute(endMinute)
|
|
.second(0)
|
|
.millisecond(0);
|
|
}
|
|
|
|
console.log("预约信息:", {
|
|
date: dateVal,
|
|
timeSlot: onlySelectDay.value ? '' : selectedTimeSlot.value,
|
|
startTime: startTime.valueOf(),
|
|
endTime: endTime.valueOf(),
|
|
});
|
|
|
|
uni.$emit(EventEnum.CHOOSE_APPOINTMENT_TIME, {
|
|
merchantId: storeId.value,
|
|
date: dateVal,
|
|
timeSlot: onlySelectDay.value ? '' : selectedTimeSlot.value,
|
|
startTime: startTime.valueOf(),
|
|
endTime: endTime.valueOf(),
|
|
});
|
|
|
|
uni.showToast({
|
|
title: t('pages.address.reservationTime.reservationSuccess'),
|
|
icon: "none",
|
|
});
|
|
|
|
uni.navigateBack();
|
|
};
|
|
|
|
const storeId = ref(null);
|
|
|
|
onLoad((options: any) => {
|
|
if (options.merchantId) {
|
|
storeId.value = options.merchantId;
|
|
} else if (options.storeId) {
|
|
storeId.value = options.storeId;
|
|
}
|
|
if (options.deliveryScheduleTimes) {
|
|
allowedWeekdays.value = parseDeliveryScheduleTimes(
|
|
decodeURIComponent(options.deliveryScheduleTimes)
|
|
);
|
|
}
|
|
if (options.storeBusinessHours) {
|
|
storeBusinessHours.value = decodeURIComponent(options.storeBusinessHours);
|
|
onlySelectDay.value = true;
|
|
}
|
|
nextTick(() => {
|
|
initializeSelectedDate(true);
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<view class="reservation-time-page min-h-screen pb-180rpx">
|
|
<navbar
|
|
:title="t('pages.address.reservationTime.pageTitle')"
|
|
circle-back
|
|
custom-class="reservation-time-navbar"
|
|
/>
|
|
<view class="reservation-time-body px-40rpx pt-40rpx">
|
|
<view class="date-section">
|
|
<text class="section-label">{{ t("pages.address.reservationTime.thisWeek") }}</text>
|
|
<view class="date-row">
|
|
<view
|
|
v-for="(item, index) in thisWeekDates"
|
|
:key="index"
|
|
class="date-circle"
|
|
:class="{
|
|
'date-circle--selected': isDateSelected(item),
|
|
'date-circle--disabled': !isDateSelectable(item),
|
|
}"
|
|
@click="selectDate(item)"
|
|
>
|
|
<text class="date-circle__weekday">{{ formatWeekdayCircle(item) }}</text>
|
|
<text class="date-circle__sub">{{ formatCircleSubLine(item) }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
<view v-if="nextWeekDates.length" class="date-section date-section--next">
|
|
<text class="section-label">{{ t("pages.address.reservationTime.nextWeek") }}</text>
|
|
<view class="date-row">
|
|
<view
|
|
v-for="(item, index) in nextWeekDates"
|
|
:key="index"
|
|
class="date-circle"
|
|
:class="{
|
|
'date-circle--selected': isDateSelected(item),
|
|
'date-circle--disabled': !isDateSelectable(item),
|
|
}"
|
|
@click="selectDate(item)"
|
|
>
|
|
<text class="date-circle__weekday">{{ formatWeekdayCircle(item) }}</text>
|
|
<text class="date-circle__sub">{{ formatCircleSubLine(item) }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
<view v-if="!onlySelectDay" class="pb-138rpx mx-40rpx bg-white rounded-24rpx overflow-hidden mt-24rpx">
|
|
<view
|
|
v-for="(timeSlot, index) in timeSlots"
|
|
:key="index"
|
|
class="h-108rpx flex-center-sb px-30rpx"
|
|
:class="[
|
|
index === 0 ? '' : 'border-top',
|
|
timeSlots.length - 1 === index ? 'border-bottom' : '',
|
|
]"
|
|
@click="selectTimeSlot(timeSlot)"
|
|
>
|
|
<text class="text-32rpx font-regular">{{ timeSlot }}</text>
|
|
<image
|
|
:src="
|
|
selectedTimeSlot === timeSlot
|
|
? '/static/images/chef/133.png'
|
|
: '/static/images/chef/134.png'
|
|
"
|
|
class="w-48rpx h-48rpx shrink-0"
|
|
mode="aspectFit"
|
|
/>
|
|
</view>
|
|
</view>
|
|
|
|
<fixed-bottom-large-btn
|
|
class="z-100"
|
|
fixed
|
|
:text="`${t('common.submit')}`"
|
|
@click="submitReservation"
|
|
/>
|
|
</view>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.reservation-time-body {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.date-section {
|
|
margin-bottom: 48rpx;
|
|
|
|
&--next {
|
|
margin-bottom: 0;
|
|
}
|
|
}
|
|
|
|
.section-label {
|
|
display: block;
|
|
font-size: 32rpx;
|
|
line-height: 44rpx;
|
|
color: #111;
|
|
font-weight: 500;
|
|
margin-bottom: 24rpx;
|
|
}
|
|
|
|
.date-row {
|
|
display: flex;
|
|
flex-direction: row;
|
|
flex-wrap: nowrap;
|
|
gap: 20rpx;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.date-circle {
|
|
width: 118rpx;
|
|
height: 118rpx;
|
|
border-radius: 50%;
|
|
background-color: #fff;
|
|
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.06);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.date-circle__weekday {
|
|
font-size: 26rpx;
|
|
line-height: 32rpx;
|
|
color: #111;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.date-circle__sub {
|
|
font-size: 22rpx;
|
|
line-height: 28rpx;
|
|
margin-top: 6rpx;
|
|
color: #111;
|
|
opacity: 0.88;
|
|
}
|
|
|
|
.date-circle--selected {
|
|
background-color: #111;
|
|
box-shadow: none;
|
|
|
|
.date-circle__weekday,
|
|
.date-circle__sub {
|
|
color: #fff;
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.date-circle--disabled {
|
|
opacity: 0.45;
|
|
}
|
|
|
|
.date-circle--disabled.date-circle--selected {
|
|
opacity: 1;
|
|
}
|
|
</style>
|
|
|
|
<style lang="scss">
|
|
page {
|
|
background-color: #f7f7f9;
|
|
}
|
|
|
|
.reservation-time-page :deep(.reservation-time-navbar.wd-navbar) {
|
|
background-color: #f7f7f9 !important;
|
|
}
|
|
</style>
|