基于uniapp+vue3封装的一个日期选择组件

发布于:2025-07-16 ⋅ 阅读:(17) ⋅ 点赞:(0)

效果图

在这里插入图片描述

简要说明:

  1. 适合 uniapp+vue3 项目使用
  2. 日期格式支持两种:date: yyyy-mm-dd year-month: yyyy-mm ,具体可以看子组件的 mode 属性
  3. 支持设置默认日期、最大日期、最小日期

子组件代码 【组件名称: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>


网站公告

今日签到

点亮在社区的每一天
去签到