Files
YGChatCS/src/components/Calender/index.vue

614 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<uni-popup
ref="popup"
type="bottom"
:safe-area="false"
@maskClick="handleMaskClick"
>
<!-- 弹窗主体 -->
<view class="calendar-popup" @tap.stop>
<!-- 头部区域 -->
<view class="calendar-header">
<view class="header-content">
<text class="header-title">日历选择</text>
<text v-if="props.rangeRequirePrice" class="header-subtitle"
>选择住宿日期以下价格为单晚参考价</text
>
</view>
<view class="header-close" @tap="handleClose">
<uni-icons type="closeempty" size="20" color="#8c8c8c"></uni-icons>
</view>
</view>
<!-- 周标题行 - 固定显示 -->
<view class="week-header">
<text class="week-day" v-for="day in weekDays" :key="day">
{{ day }}
</text>
</view>
<!-- 日历主体区域 -->
<view class="calendar-body">
<!-- 全年月份显示区域 -->
<view class="year-container">
<view
class="month-section"
v-for="monthData in yearMonthsGrid"
:key="monthData.key"
>
<text class="month-title">{{ monthData.title }}</text>
<view class="date-grid">
<view
class="date-cell"
v-for="(dateInfo, index) in monthData.grid"
:key="index"
:class="getDateCellClass(dateInfo)"
@tap="handleDateClick(dateInfo)"
>
<template v-if="dateInfo">
<text class="date-label" v-if="dateInfo.label">
{{ dateInfo.label }}
</text>
<text class="date-number">{{ dateInfo.day }}</text>
<text
class="date-price"
v-if="
dateInfo.price !== null && dateInfo.price !== undefined
"
>¥{{ dateInfo.price }}</text
>
</template>
</view>
</view>
</view>
</view>
</view>
</view>
</uni-popup>
</template>
<script setup>
import {
ref,
reactive,
computed,
watch,
onMounted,
onBeforeUnmount,
} from "vue";
// 定义组件名称
defineOptions({
name: "Calendar",
});
// uni-popup组件引用
const popup = ref(null);
// 定义Props
const props = defineProps({
// 弹窗显示控制
visible: {
type: Boolean,
default: false,
required: true,
},
// 价格数据数组
// 格式: [{ date: '2024-05-17', price: 449, stock: 3 }, ...]
priceData: {
type: [Object, Array],
default: () => ({}),
},
// 默认选中日期
defaultValue: {
type: [String, Array],
default: "",
},
// 选择模式
mode: {
type: String,
default: "single",
validator: (value) => ["single", "range"].includes(value),
},
// 范围选择时是否必须有价格(作为价格区间选择器)
rangeRequirePrice: {
type: Boolean,
default: false,
},
// 最小可选日期
minDate: {
type: String,
default: () => new Date().toISOString().split("T")[0],
},
// 最大可选日期
maxDate: {
type: String,
default: "",
},
// 禁用日期数组
disabledDates: {
type: Array,
default: () => [],
},
// 自定义标签
customLabels: {
type: Object,
default: () => ({}),
},
});
// 定义Emits
const emit = defineEmits([
"close",
"select",
"range-select",
"date-click",
"month-change",
]);
// 响应式数据
const weekDays = ref(["一", "二", "三", "四", "五", "六", "日"]);
const selectedDates = ref([]);
const currentYear = ref(new Date().getFullYear());
const isRangeSelecting = ref(false);
const rangeStart = ref(null);
const rangeEnd = ref(null);
const clickTimer = ref(null);
// 计算属性
// 生成从当前月份到明年同月份的日历数据
const yearMonthsGrid = computed(() => {
const months = [];
const now = new Date();
const currentYear = now.getFullYear();
const startMonth = now.getMonth() + 1; // 从当前月份开始getMonth()返回0-11需要+1
const totalMonths = 13; // 显示13个月当前月份到明年同月份
for (let i = 0; i < totalMonths; i++) {
const month = ((startMonth - 1 + i) % 12) + 1;
const year = currentYear + Math.floor((startMonth - 1 + i) / 12);
months.push({
key: `${year}-${month}`,
title: `${year}年${month}月`,
year: year,
month: month,
grid: generateCalendarGrid(year, month),
});
}
return months;
});
// 方法定义
// 处理遮罩点击
const handleMaskClick = () => {
handleClose();
};
// 处理关闭
const handleClose = () => {
if (popup.value) {
popup.value.close();
}
emit("close");
};
// 获取月份天数
const getDaysInMonth = (year, month) => {
return new Date(year, month, 0).getDate();
};
// 获取月份第一天是星期几 (0=周日, 1=周一...)
const getFirstDayOfMonth = (year, month) => {
const day = new Date(year, month - 1, 1).getDay();
return day === 0 ? 6 : day - 1; // 转换为周一开始 (0=周一, 6=周日)
};
// 获取指定日期的价格项(兼容对象或者数组)
const getPriceItem = (dateStr) => {
if (!props.priceData) return null;
// 对象映射格式:{ '2024-05-17': 449 }
if (!Array.isArray(props.priceData) && typeof props.priceData === "object") {
if (Object.prototype.hasOwnProperty.call(props.priceData, dateStr)) {
const val = props.priceData[dateStr];
// 可能只是数字价格,也可能是对象
if (val !== null && typeof val === "object") {
return { date: dateStr, price: val.price, stock: val.stock };
}
return { date: dateStr, price: val };
}
return null;
}
// 数组格式:[{date, price, stock}, ...]
if (Array.isArray(props.priceData)) {
const item = props.priceData.find((it) => it.date === dateStr);
return item || null;
}
return null;
};
// 获取价格值便捷函数
const getPriceForDate = (dateStr) => {
const item = getPriceItem(dateStr);
return item ? item.price : null;
};
// 生成日历网格数据
const generateCalendarGrid = (year, month) => {
const daysInMonth = getDaysInMonth(year, month);
const firstDay = getFirstDayOfMonth(year, month);
const grid = [];
// 填充空白格子
for (let i = 0; i < firstDay; i++) {
grid.push(null);
}
// 填充日期
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year}-${String(month).padStart(2, "0")}-${String(
day,
).padStart(2, "0")}`;
const priceItem = getPriceItem(dateStr);
grid.push({
date: dateStr,
day: day,
price: priceItem ? priceItem.price : null,
stock: priceItem ? priceItem.stock : undefined,
disabled: isDateDisabled(dateStr),
selected: isDateSelected(dateStr),
inRange: isDateInRange(dateStr),
label: getDateLabel(dateStr),
});
}
return grid;
};
// 判断日期是否禁用
const isDateDisabled = (dateStr) => {
const date = new Date(dateStr);
const minDate = new Date(props.minDate);
const maxDate = props.maxDate ? new Date(props.maxDate) : null;
// 过去或超出范围
if (date < minDate) return true;
if (maxDate && date > maxDate) return true;
if (props.disabledDates.includes(dateStr)) return true;
// 注意:不在此处基于价格全局禁用日期,
// 允许未定价的日期被点击作为离店日(结束日),
// 价格校验在范围完成时对夜晚(不含离店日)进行。
return false;
};
// 判断日期是否选中
const isDateSelected = (dateStr) => {
if (props.mode === "single") {
return selectedDates.value.includes(dateStr);
} else {
return dateStr === rangeStart.value || dateStr === rangeEnd.value;
}
};
// 判断日期是否在选择范围内
const isDateInRange = (dateStr) => {
if (props.mode !== "range" || !rangeStart.value || !rangeEnd.value) {
return false;
}
const date = new Date(dateStr);
const start = new Date(rangeStart.value);
const end = new Date(rangeEnd.value);
return date > start && date < end;
};
// 获取日期标签
const getDateLabel = (dateStr) => {
// 优先使用自定义标签
if (props.customLabels[dateStr]) {
return props.customLabels[dateStr];
}
// 为范围选择模式自动生成住/离标签
if (props.mode === "range") {
if (dateStr === rangeStart.value && dateStr === rangeEnd.value) {
// 当入住和离店是同一天时,显示"住/离"
return "住/离";
}
if (dateStr === rangeStart.value) {
return "入住";
}
if (dateStr === rangeEnd.value) {
return "离店";
}
}
return "";
};
// 获取日期格子样式类
const getDateCellClass = (dateInfo) => {
if (!dateInfo) return "date-cell-empty";
const classes = ["date-cell-content"];
if (dateInfo.disabled) classes.push("date-cell-disabled");
if (dateInfo.selected) classes.push("date-cell-selected");
if (dateInfo.inRange) classes.push("date-cell-in-range");
// 标注无价格但可选的日期(用于视觉区分)
if (
dateInfo.price === null ||
dateInfo.price === undefined ||
dateInfo.price === "-"
) {
classes.push("date-cell-no-price");
}
return classes.join(" ");
};
// 处理日期点击
const handleDateClick = (dateInfo) => {
if (!dateInfo) return;
if (dateInfo.disabled) {
uni.showToast({ title: "该日期不可选", icon: "none" });
// 仍然触发点击事件,供上层参考
emit("date-click", {
date: dateInfo.date,
price: dateInfo.price,
disabled: dateInfo.disabled,
selected: dateInfo.selected,
});
return;
}
// 防抖处理
if (clickTimer.value) {
clearTimeout(clickTimer.value);
}
clickTimer.value = setTimeout(() => {
if (props.mode === "single") {
handleSingleSelect(dateInfo);
} else if (props.mode === "range") {
handleRangeSelection(dateInfo);
}
// 触发点击事件
emit("date-click", {
date: dateInfo.date,
price: dateInfo.price,
disabled: dateInfo.disabled,
selected: dateInfo.selected,
});
}, 100);
};
// 处理单选
const handleSingleSelect = (dateInfo) => {
selectedDates.value = [dateInfo.date];
emit("select", {
date: dateInfo.date,
price: dateInfo.price,
mode: "single",
});
};
// 处理范围选择
const handleRangeSelection = (dateInfo) => {
// 如果当前没有开始日期或已经完成一次选择,则此次点击作为开始日期
if (!rangeStart.value || (rangeStart.value && rangeEnd.value)) {
// 开始新的范围选择:当作为价格区间选择器时,开始日必须有价格且有库存
if (props.rangeRequirePrice) {
const hasPrice =
dateInfo.price !== null &&
dateInfo.price !== undefined &&
dateInfo.price !== "-";
if (!hasPrice) {
uni.showToast({ title: "所选日期不可预订,请重新选择", icon: "none" });
return;
}
if (dateInfo.stock !== undefined && Number(dateInfo.stock) <= 0) {
uni.showToast({
title: "所选日期库存不足,请选择其他日期",
icon: "none",
});
return;
}
}
rangeStart.value = dateInfo.date;
rangeEnd.value = null;
isRangeSelecting.value = true;
return;
}
// 否则为结束日期(完成选择)
if (rangeStart.value === dateInfo.date) {
uni.showToast({ title: "离店日期不能与入住日期相同", icon: "none" });
return;
}
rangeEnd.value = dateInfo.date;
isRangeSelecting.value = false;
// 确保开始日期不大于结束日期
if (new Date(rangeStart.value) > new Date(rangeEnd.value)) {
[rangeStart.value, rangeEnd.value] = [rangeEnd.value, rangeStart.value];
}
// 检查日期跨度是否超过28天
const daysBetween = calculateDaysBetween(rangeStart.value, rangeEnd.value);
if (daysBetween > 28) {
uni.showToast({
title: "预定时间不能超过28天",
icon: "none",
duration: 3000,
});
rangeStart.value = null;
rangeEnd.value = null;
isRangeSelecting.value = false;
return;
}
// 如果作为价格区间选择器,验证夜晚(不包含离店日)是否都有价格/库存
if (props.rangeRequirePrice) {
const nights = generateNightsRange(rangeStart.value, rangeEnd.value);
const missing = nights.find(
(d) => d.price === null || d.price === undefined || d.price === "-",
);
if (missing) {
uni.showToast({
title: "所选区间包含无价格日期,请重新选择",
icon: "none",
});
rangeStart.value = null;
rangeEnd.value = null;
return;
}
const badStock = nights.find((d) => {
const item = getPriceItem(d.date);
return item && item.stock !== undefined && Number(item.stock) <= 0;
});
if (badStock) {
uni.showToast({
title: "所选区间包含库存不足的日期,请重新选择",
icon: "none",
});
rangeStart.value = null;
rangeEnd.value = null;
return;
}
}
const dateRange = generateDateRange(rangeStart.value, rangeEnd.value);
emit("range-select", {
startDate: rangeStart.value,
startPrice: getPriceForDate(rangeStart.value),
endDate: rangeEnd.value,
endPrice: getPriceForDate(rangeEnd.value),
totalDays: daysBetween,
dateRange: dateRange,
});
};
// 生成日期范围内所有日期的数组
const generateDateRange = (startDate, endDate) => {
const dateRange = [];
const start = new Date(startDate);
const end = new Date(endDate);
// 如果是同一天,只返回一个日期
if (startDate === endDate) {
return [
{
date: startDate,
price: getPriceForDate(startDate),
},
];
}
// 生成范围内所有日期
const current = new Date(start);
while (current <= end) {
const dateStr = current.toISOString().split("T")[0];
dateRange.push({
date: dateStr,
price: getPriceForDate(dateStr),
});
current.setDate(current.getDate() + 1);
}
return dateRange;
};
// 生成以入住日期为开始、离店日期为结束(离店日不计入夜)的夜晚数组
const generateNightsRange = (startDate, endDate) => {
const nights = [];
const start = new Date(startDate);
const end = new Date(endDate);
// 若相同日期,视为单晚(保留原有兼容行为)
if (startDate === endDate) {
return [
{
date: startDate,
price: getPriceForDate(startDate),
},
];
}
const current = new Date(start);
while (current < end) {
const dateStr = current.toISOString().split("T")[0];
nights.push({ date: dateStr, price: getPriceForDate(dateStr) });
current.setDate(current.getDate() + 1);
}
return nights;
};
// 计算两个日期之间的天数
const calculateDaysBetween = (startDate, endDate) => {
const start = new Date(startDate);
const end = new Date(endDate);
const diffTime = Math.abs(end - start);
const days = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// 如果是同一天返回1天而不是0天
return days === 0 ? 1 : days;
};
// 监听visible属性变化控制uni-popup显示
watch(
() => props.visible,
(newVisible) => {
if (newVisible) {
popup.value?.open();
} else {
popup.value?.close();
}
},
{ immediate: true },
);
// 生命周期钩子
onMounted(() => {
// 初始化选中状态
if (props.defaultValue) {
if (Array.isArray(props.defaultValue)) {
rangeStart.value = props.defaultValue[0];
rangeEnd.value = props.defaultValue[1];
} else {
selectedDates.value = [props.defaultValue];
}
}
});
onBeforeUnmount(() => {
if (clickTimer.value) {
clearTimeout(clickTimer.value);
}
});
</script>
<style lang="scss" scoped>
// 引入样式文件
@import "./styles/index.scss";
</style>