基于uniapp+vue3封装的一个日期选择组件
效果图
简要说明:
- 适合 uniapp+vue3 项目使用
- 日期格式支持两种:date: yyyy-mm-dd year-month: yyyy-mm ,具体可以看子组件的 mode 属性
- 支持设置默认日期、最大日期、最小日期
子组件代码 【组件名称:DatePicker 】
<template><view v-if="isVisible" class="custom-date-picker"><view :class="['custom-date-picker-mask', isVisible ? 'mask-enter' : '', isExecuteCloseAnimation ? 'mask-leave' : '']" @click="cancel"></view><view :class="['custom-date-picker-content', isVisible ? 'content-enter' : '', isExecuteCloseAnimation ? 'content-leave' : '']"><view class="date-picker-header"><view @click="cancel" class="date-picker-header-left" :style="{ color: cancelBtnColor }"><slot name="left"><text>取消</text></slot></view><view class="date-picker-header-title" :style="{ color: titleColor }"><slot name="title">选择日期</slot></view><view @click="confirm" class="date-picker-header-right" :style="{ color: confirmBtnColor }"><slot name="right"><text>确定</text></slot></view></view><view class="date-picker-body"><picker-viewclass="date-picker-view"immediate-changeindicator-class="select-line":indicator-style="`height: 44px`":value="dateValue"@change="bindChangeDate"><picker-view-column class="column-left" id="year"><view:key="index"v-for="(item, index) in years"class="date-picker-view-item":class="index == dateValue[0] ? 'active' : ''">{{ item }}</view></picker-view-column><picker-view-column class="column-center"><view:key="index"v-for="(item, index) in months"class="date-picker-view-item":class="index == dateValue[1] ? 'active' : ''">{{ item }}</view></picker-view-column><picker-view-column v-if="mode === 'date'" class="column-right"><view :key="index" v-for="(item, index) in days" class="date-picker-view-item" :class="index == dateValue[2] ? 'active' : ''">{{ item }}</view></picker-view-column></picker-view></view></view></view>
</template><script setup>
import { onMounted, watch, nextTick, ref } from "vue";const props = defineProps({show: {type: Boolean,default: false,},mode: {type: String,default: "date", // 日期格式: date: yyyy-mm-dd year-month: yyyy-mm},confirmBtnColor: {type: String,default: "#00bfc6",},cancelBtnColor: {type: String,default: "#333333",},titleColor: {type: String,default: "#101010",},minDate: {type: String,default: "1990-01-01",},maxDate: {type: String,default: "2026-06-01",},value: {// 默认日期type: String,default: "",},
});
const emits = defineEmits(["confirm", "cancel", "change"]);const years = ref([]); // 年份数组
const months = ref([]); // 月份数组 "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"
const days = ref([]);
const dateValue = ref([]);
const timeValue = ref("");
const isVisible = ref(props.show);
// 解决关闭弹窗时,弹窗直接消失,而没有动画效果
const isExecuteCloseAnimation = ref(false);
const timer = ref(null); // 存储定时器IDwatch(() => props.show,(newVal) => {if (newVal) {isVisible.value = newVal;} else {// console.log("执行关闭动画");// 先清除之前的定时器if (timer.value) clearTimeout(timer.value);isExecuteCloseAnimation.value = true;timer.value = setTimeout(() => {isVisible.value = false;isExecuteCloseAnimation.value = false;}, 300); // 与}}
);watch(() => props.value,(newVal) => {init();}
);onMounted(() => {init();
});function confirm() {const year = years.value[dateValue.value[0]];const month = months.value[dateValue.value[1]];const day = days.value[dateValue.value[2]];const date = formatDate(year, month, day);emits("confirm", {year,month,day,date,});
}
function cancel() {emits("cancel");
}function init() {// 默认日期: 优先级取值 props.value > (maxDate > minDate) > 默认日期const flag = props.maxDate ? isDateAfter(currentDate(), props.maxDate) : false;let defaultDate = props.value || (flag ? props.maxDate : currentDate());let date = new Date();if (defaultDate && defaultDate.length > 0) {date = new Date(defaultDate.replace(/^\s+|\s+$/g, ""));}// 获取对应范围值const year = date.getFullYear();const month = date.getMonth() + 1;const day = date.getDate();const yearsArr = [];// 计算年份范围// 1. 有没有最大 日期 maxDate// 2. 有没有最小 日期 minDate// 3. 最大日期和最小日期的年份范围// maxDate 日期格式 yyyy-mm-dd// minDate 日期格式 yyyy-mm-ddlet endYear = year; // 默认最大年份就是当前年份let startYear = year - 10; // 默认最小年份 = 当前年份 - 10 (10年前)let endMonth = month; // 默认最大月份let endDate = day; // 默认最大日期if (props.maxDate) {const maxDate = new Date(props.maxDate.replace(/-/g, "/"));endYear = maxDate.getFullYear();endMonth = maxDate.getMonth() + 1;endDate = maxDate.getDate();}if (props.minDate) {const minDate = new Date(props.minDate.replace(/-/g, "/"));startYear = minDate.getFullYear();}// 年份数组for (let i = startYear; i <= endYear; i++) {yearsArr.push(i);}years.value = yearsArr;// formate({ year: endYear, month: endMonth, day: endDate });formate({ year, month, day });// 定义默认选中初始值下标let index1 = 0;let index2 = 0;let index3 = 0;// 赋值默认选中日期if (defaultDate) {const _date = new Date(defaultDate.replace(/^\s+|\s+$/g, ""));index1 = years.value.findIndex((item) => item == _date.getFullYear());index1 = index1 >= 0 ? index1 : 0;index2 = months.value.findIndex((item) => item == _date.getMonth() + 1);index2 = index2 >= 0 ? index2 : 0;index3 = days.value.findIndex((item) => item == _date.getDate());index3 = index3 >= 0 ? index3 : 0;} else {index1 = years.value.length - 1;index2 = months.value.length - 1;index3 = days.value.length - 1;}const time = formatDate(year, month, day);timeValue.value = time;emits("change", time);nextTick(() => {dateValue.value = [index1, index2, index3];});
}
function bindChangeDate(e) {const { value } = e.detail;const year = parseInt(years.value[value[0]]);const month = parseInt(months.value[value[1]]);let day = parseInt(days.value[value[2]]);// 选中月份的总天数const currentMonthDays = new Date(year, month, 0).getDate();// 判断日期没有31号的情况if (day > currentMonthDays) {day = currentMonthDays;value[2] = day - 1;}// 更新 days 数组const daysArr = [];for (let i = 1; i <= new Date(year, month, 0).getDate(); i++) {daysArr.push(padStart(i));}days.value = daysArr;dateValue.value = value;// 问题: 解决有最大日期限制的情况下,导致 day 值越界,从而取到为 NaN 的问题// 举例:最大日期为 2025-07-15,当前日期为 2024-07-26,// 则当 day 值为 26时,切换年份为 2025 年,day 取值是 daysArr[25],// 而 2025 年 最大的 day 取值是 daysArr[14] , 从而出现数组越界的情况,day 取值为 NaNif (isNaN(day) && daysArr.length > 0) day = daysArr[daysArr.length - 1];formate({ year, month, day }, true);
}// 动态计算 年月日
function formate({ year, month, day }, status = false) {// console.log("formate", year, month, day);// 今天日期 (默认选中当前日期)let date = new Date(currentDate());const currentYear = date.getFullYear();const currentMonth = date.getMonth() + 1;const currentDay = date.getDate();// 最大日期 (可能没有最大日期const maxDate = props.maxDate ? new Date(props.maxDate.replace(/-/g, "/")) : null;const endYear = maxDate ? maxDate.getFullYear() : currentYear;const endMonth = maxDate ? maxDate.getMonth() + 1 : currentMonth;const endDay = maxDate ? maxDate.getDate() : currentDay;// 默认最大年份let maxYear = endYear;// 默认最大月份let maxMonth = 12;// 最大天数, 直接通过年月计算let maxDay = new Date(parseInt(year), parseInt(month), 0).getDate();// 重新赋值 年月日let monthArr = [];let dayArr = [];// console.log("year-month-day:", year, month, day);// console.log("最大日期: year-month-day:", endYear, endMonth, endDay);maxMonth = endMonth;maxDay = endDay;if (year == maxYear) {// 如果这里还是执行,说明当前切换的是最大年份的,月份和天数maxMonth = endMonth;maxDay = month < endMonth ? new Date(parseInt(year), parseInt(month), 0).getDate() : endDay;} else {maxMonth = 12;maxDay = new Date(parseInt(year), parseInt(month), 0).getDate();}for (let i = 1; i <= maxMonth; i++) {monthArr.push(padStart(i));}months.value = monthArr;for (let i = 1; i <= maxDay; i++) {dayArr.push(padStart(i));}days.value = dayArr;if (status) {const year = parseInt(years.value[dateValue.value[0]]);const month = parseInt(months.value[dateValue.value[1]]);const day = parseInt(days.value[dateValue.value[2]]);const time = formatDate(year, month, day);timeValue.value = time;emits("change", time);}
}// 数字小于10前面填充0
function padStart(val) {return val.toString().padStart(2, 0);
}/*** 格式化年月日为 YYYY-MM-DD 字符串* @param {number} year 年份* @param {number} month 月份 (1 - 12)* @param {number} day 日期 (1 - 31)* @returns {string} 格式为 YYYY-MM-DD 的日期字符串*/
function formatDate(year, month, day) {const dayStr = props.mode === "date" ? `-${padStart(day)}` : "";return `${year}-${padStart(month)}${dayStr}`;
}/*** 比较两个日期,判断 dateStr1 是否大于 dateStr2* @param {string} dateStr1 - 第一个日期字符串,格式如 "YYYY-MM-DD"* @param {string} dateStr2 - 第二个日期字符串,格式如 "YYYY-MM-DD"* @returns {boolean} 如果 dateStr1 > dateStr2 返回 true,否则返回 false*/
function isDateAfter(dateStr1, dateStr2) {const date1 = new Date(dateStr1);const date2 = new Date(dateStr2);// 确保是有效日期if (isNaN(date1.getTime()) || isNaN(date2.getTime())) {throw new Error("传入的日期格式不合法");}return date1 > date2;
}/*** 获取当前时间* @param format 时间格式* @returns 返回当前时间*/
function currentDate(format = "yyyy-mm-dd") {const date = new Date();const year = date.getFullYear();const month = padStart(date.getMonth() + 1);const day = padStart(date.getDate());if (format === "yyyy-mm-dd") {return `${year}-${month}-${day}`;}if (format === "yyyy/mm/dd") {return `${year}/${month}/${day}`;}return `${year}年${month}月${day}日`;
}
</script><style lang="scss" scoped>
@mixin flex-center {display: flex;justify-content: center;align-items: center;
}
.custom-date-picker {&-mask {position: fixed;top: 0;left: 0;right: 0;bottom: 0;background-color: rgba(0, 0, 0, 0.5);z-index: 9999;opacity: 0;/* 进场动画 */&.mask-enter {display: block;animation: mask-fade-in 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards;}/* 退场动画 */&.mask-leave {display: block;animation: mask-fade-out 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards;}}&-content {position: fixed;left: 0;bottom: 0;width: 100%;background: #fff;padding-bottom: calc(40rpx + constant(safe-area-inset-bottom)) !important;padding-bottom: calc(40rpx + env(safe-area-inset-bottom)) !important;border-radius: 24rpx 24rpx 0 0;z-index: 10000;overflow-y: auto;-webkit-overflow-scrolling: touch;transform: translateY(1000rpx);/* 进场动画 */&.content-enter {animation: content-slide-up 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards;}/* 退场动画 */&.content-leave {animation: content-slide-down 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards;}.date-picker-header {display: flex;align-items: center;height: 88rpx;padding: 0 40rpx;box-sizing: border-box;&-left {width: 100rpx;}&-title {flex: 1;font-size: 32rpx;text-align: center;font-weight: 600;}&-right {width: 100rpx;text-align: right;}}.date-picker-body {height: 620rpx;display: flex;align-items: center;justify-content: center;.column-left,.column-center,.column-right {.select-line {background: #f5f5f5;z-index: -1;&::before,&::after {border: none;}}}.column-left {.select-line {border-radius: 16rpx 0 0 16rpx;}}.column-right {.select-line {border-radius: 0 16rpx 16rpx 0;}}.date-picker-view {height: 420rpx;width: 100%;padding: 0 32rpx;box-sizing: border-box;&-item {height: 44px;line-height: 44px;font-size: 32rpx;font-weight: bold;transition: all 0.2s ease;@include flex-center;&.active {color: #101010;font-weight: 600;font-size: 40rpx;transition: all 0.2s ease;}}}}}/* 进场动画定义 */@keyframes mask-fade-in {from {opacity: 0;}to {opacity: 1;}}@keyframes content-slide-up {from {transform: translateY(100%);}to {transform: translateY(0);}}/* 退场动画定义 */@keyframes mask-fade-out {from {opacity: 1;}to {opacity: 0;}}@keyframes content-slide-down {from {transform: translateY(0);}to {transform: translateY(100%);}}
}
</style>
父组件使用
<template><view class="container"><DatePicker :show="show" @cancel="show = false" mode="date" @confirm="handleConfirm" /><view class="date-box" @click="show = true">{{ dateValue ? dateValue : "请选择日期" }}</view><view style="text-align: center">{{ dateInfo.year }}年{{ dateInfo.month }}月{{ dateInfo.day }}日</view></view>
</template>
<script setup>
import { ref } from "vue";import DatePicker from "@C/components/DatePicker/DatePicker.vue";const show = ref(false);
const dateValue = ref("");
const dateInfo = ref("");function handleConfirm(date) {console.log(date);show.value = false;dateInfo.value = date;dateValue.value = date.date;
}
</script><style lang="scss" scoped>
.container {min-height: 100vh;background-color: rgb(191, 241, 225);.date-box {text-align: center;padding: 600rpx 20rpx 20rpx;box-sizing: border-box;}
}
</style>