423 lines
10 KiB
Vue
423 lines
10 KiB
Vue
<template>
|
||
<uni-popup ref="popup" type="bottom" @maskClick="handleMaskClick">
|
||
<!-- 弹窗主体 -->
|
||
<view class="calendar-popup" @tap.stop>
|
||
<!-- 头部区域 -->
|
||
<view class="calendar-header">
|
||
<view class="header-content">
|
||
<text class="header-title">日历选择</text>
|
||
<text 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"
|
||
>¥{{ 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,
|
||
},
|
||
|
||
// 价格数据对象
|
||
priceData: {
|
||
type: Object,
|
||
default: () => ({}),
|
||
},
|
||
|
||
// 默认选中日期
|
||
defaultValue: {
|
||
type: [String, Array],
|
||
default: "",
|
||
},
|
||
|
||
// 选择模式
|
||
mode: {
|
||
type: String,
|
||
default: "single",
|
||
validator: (value) => ["single", "range"].includes(value),
|
||
},
|
||
|
||
// 最小可选日期
|
||
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 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")}`;
|
||
grid.push({
|
||
date: dateStr,
|
||
day: day,
|
||
price: props.priceData[dateStr] || null,
|
||
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) {
|
||
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");
|
||
|
||
return classes.join(" ");
|
||
};
|
||
|
||
// 处理日期点击
|
||
const handleDateClick = (dateInfo) => {
|
||
if (!dateInfo || dateInfo.disabled) 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)) {
|
||
// 开始新的范围选择
|
||
rangeStart.value = dateInfo.date;
|
||
rangeEnd.value = null;
|
||
isRangeSelecting.value = true;
|
||
} else {
|
||
// 完成范围选择
|
||
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显示错误提示
|
||
uni.showToast({
|
||
title: '预定时间不能超过28天',
|
||
icon: 'none',
|
||
duration: 3000
|
||
});
|
||
|
||
// 重置选择状态
|
||
rangeStart.value = null;
|
||
rangeEnd.value = null;
|
||
isRangeSelecting.value = false;
|
||
|
||
return;
|
||
}
|
||
|
||
emit("range-select", {
|
||
startDate: rangeStart.value,
|
||
endDate: rangeEnd.value,
|
||
startPrice: props.priceData[rangeStart.value],
|
||
endPrice: props.priceData[rangeEnd.value],
|
||
totalDays: daysBetween,
|
||
});
|
||
}
|
||
};
|
||
|
||
// 计算两个日期之间的天数
|
||
const calculateDaysBetween = (startDate, endDate) => {
|
||
const start = new Date(startDate);
|
||
const end = new Date(endDate);
|
||
const diffTime = Math.abs(end - start);
|
||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||
};
|
||
|
||
// 监听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> |