Files
cheflinkuser/src/pages/address/reservation-time.vue
T
2026-06-05 15:03:32 +08:00

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>