效果图
简要说明:
- 适合 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-view
class="date-picker-view"
immediate-change
indicator-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); // 存储定时器ID
watch(
() => 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-dd
let 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 取值为 NaN
if (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>