手搓日历组件,大屏样式最佳解决方案!

简介: 【8月更文挑战第23天】手搓日历组件,大屏样式最佳解决方案!

简介

大家好,我是石小石!在大屏开发中,我们可能会遇到日历组件的使用,然而,对于一些定制化的大屏,传统的第三方UI组件很难满足我们的样式要求。
image.png
这个时候,我们一般有两个选择:

  • 说服UI和老板,使用第三方组件(客户吐槽,最终可能要改)
  • 强行改组件库UI样式,让自己痛不欲生

实际上,大屏使用的日历组件大多只是展示使用,业务简单,我们手搓一个其实非常简单,最重要的是,样式完全可以自定义!
这篇文章,我将向大家展示如何封装一个vue日历组件,可以直接引入使用。它的样式功能如下

  • 基本日期展示
  • 时间切换
  • 禁用未来时间
  • 高亮当前样式
  • ....

233.gif

核心思路分析

日历组件的核心其实是每页日期数据的生成,日期数据是一个7*6的一维列表,我们只要计算出这个列表的第一项日期数据,后面的日期依次加1即可。其次,就是一些简单的样式处理,非当前月的日期进行置灰即可。

页面的数据结构

为了便于页面的渲染,我们可以将7*6的日期列表封装到一个数组里,数组的每一行对应日期的每一行数据
image.png
为了便于后续的样式拓展,我们可以给每个日期对象增加一些数据,比如

{
   
    
  // 显示的标签
  label: '30', 
  // 标签对应的实际数据
  date: '2024-06-30', 
  // 是否被选中
  active: false, 
  // 是否当前日期
  isCurrent: false
}

那么,一个比较完整的日期列表数据,结构就应该如下

[
    {
   
   
        "time": "2024-06-30",
        "children": [
            {
   
   
                "label": "30",
                "date": "2024-06-30",
                "active": false,
                "isCurrent": false
            },
            {
   
   
                "label": "1",
                "date": "2024-07-01",
                "active": true,
                "isCurrent": false
            },
            {
   
   
                "label": "2",
                "date": "2024-07-02",
                "active": true,
                "isCurrent": false
            },
            {
   
   
                "label": "3",
                "date": "2024-07-03",
                "active": true,
                "isCurrent": false
            },
            {
   
   
                "label": "4",
                "date": "2024-07-04",
                "active": true,
                "isCurrent": false
            },
            {
   
   
                "label": "5",
                "date": "2024-07-05",
                "active": true,
                "isCurrent": false
            },
            {
   
   
                "label": "6",
                "date": "2024-07-06",
                "active": true,
                "isCurrent": false
            }
        ]
    },
    {
   
      
        "time": "2024-07-07",//第二行起始日期
        "children": []       // 内容省略,格式同第一条数据
    },
    {
   
   
        "time": "2024-07-14",//第三行起始日期
        "children": []
    },
    {
   
   
        "time": "2024-07-21",//第四行起始日期
        "children": []
    },
    {
   
   
        "time": "2024-07-28",//第五行起始日期
        "children": []
    },
    {
   
   
        "time": "2024-08-04",//第六行起始日期
       "children": []
    }
]

如何生成日期列表

要生成日期列表,最核心的就是找到当前选中年月的日历面板的起始日期。
比如,我们选中2024年7月,我们的核心就是计算出当前面板的第一个标签的时间。
image.png
要计算当前日历面板的第一个时间,我们可以先找到当前选中年月的1号时间

const year = ref(dayjs().year())
const month = ref(dayjs().month() + 1)
// 获取当前选中月份的1号
let selectDay = `${
     
     year.value}-${
     
     month.value}-01`

然后,我们可以计算出选中月是周几,便可以计算出起始时间

// 选中时间是周几
const weekDay = dayjs(selectDay).day()
// 日历组件的起始日期
const firstDay = dayjs(selectDay).subtract(weekDay, 'day')

接下来,我们遍历生成整个日期列表即可

// 生成日期列表
const getTimeListByYearAndMonth = () => {
   
   
    // 获取当前选中月份的1号
    let selectDay = `${
     
     year.value}-${
     
     month.value}-01`;  // 构造当前选中年份和月份的第一天日期字符串
    // 选中时间是周几
    const weekDay = dayjs(selectDay).day();  // 获取当前选中日期是星期几(0-6,周日为0)
    // 日历组件的起始日期
    const firstDay = dayjs(selectDay).subtract(weekDay, 'day');  // 计算出日历显示的第一天,减去当前星期几的天数,得到上个月的最后几天或本月的第一天
    // 初始化日期列表数组
    const dayList = [];  
    // 遍历6周
    for (let i = 0; i < 6; i++) {
   
   
        // 初始化每周的日期数组
        const childrenList = [];  
        // 遍历每周的7天
        for (let time = 0; time < 7; time++) {
   
   
            // 计算当前日期
            let day = dayjs(firstDay).add(i * 7 + time, "day");  
            // 格式化当前日期为字符串
            const date = day.format("YYYY-MM-DD");  
            childrenList.push({
   
   
                label: day.format("D"),  // 获取当前日期的日
                date,  // 当前日期的完整格式
                // 判断当前日期是否属于选中的月份
                active: Number(day.format("M")) == month.value,  
                // 判断当前日期是否是今天
                isCurrent: date === dayjs().format("YYYY-MM-DD")  
            });
        }
        dayList.push({
   
   
            // 当前周的第一天日期
            time: dayjs(firstDay).add(i * 7, "day").format("YYYY-MM-DD"), 
            // 当前周的所有日期
            children: childrenList  
        });
    }
    // 返回生成的日期列表
    return dayList;  
}

技术方案

基础样式搭建

日历组件的样式其实非常简单,无非就是头部切换功能样式,和主体日历时间部分样式,这部分大家完全可以按照业务情况自己开发。
image.png

组件参数设计

作为一个公用组件,一些核心的组件props参数是必不可少的,这个组件可以传递默认选中的年份year、月份month,以及一些灵活的拓展参数:是否禁用未来日期disabledFutureDay、是否高亮当前日期highlightCurrentDay等等。
相应的,它还需要一些核心的emit事件抛出,如年份切换事件yearChange、月份切换事件 monthChange、日期切换事件dateChange。

interface Props {
   
   
    year?: number
    month?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
    // 是否禁用未来日期
    disabledFutureDay?: boolean
    // 是否高亮当前日期
    highlightCurrentDay?: boolean
}
interface TimeEmitInfo {
   
   
    year: number, month: number, date: number | null, time: string
}
const props = withDefaults(defineProps<Props>(), {
   
   
    year: dayjs().year(),
    month: dayjs().month() + 1,
    disabledFutureDay: true,
    highlightCurrentDay: true,
})
const emit = defineEmits<{
   
   
    yearChange: [params: TimeEmitInfo],
    monthChange: [params: TimeEmitInfo],
    dateChange: [params: TimeEmitInfo]
}>()

日期生成与页面渲染

日期的生成我们在【思路分析】章节已经详细分析过,结合数据渲染,他的页面结构和核心代码如下

<template>
    <div class="schedule-calendar">
        <!--日历组件年月栏-->
        <div class="time-switch-tool">

        </div>
        <!--日历组件星期显示区域-->
        <div class="week-day">
            <span></span><span></span><span></span><span></span>
            <span></span><span></span><span></span>
        </div>
        <!--日历组件时间遍历生成-->
        <div class="calendar-wrap">
            <div class="day-list" v-for="time in dayList  ">
                <div class="day-cell" v-for="item in time.children" @click="selectDay(item.date)">
                    <span class="day">
                        {
   
   {
   
    item.label }}
                    </span>
                </div>
            </div>
        </div>
    </div>
</template>
<script lang="ts" setup>   
import dayjs from "dayjs";
import weekOfYear from "dayjs/plugin/weekOfYear"
dayjs.extend(weekOfYear);

interface Props {
   
   
    year?: number
    month?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
    // 是否禁用未来日期
    disabledFutureDay?: boolean
    // 是否高亮当前日期
    highlightCurrentDay?: boolean
}
interface TimeEmitInfo {
   
   
    year: number, month: number, date: number | null, time: string
}
const props = withDefaults(defineProps<Props>(), {
   
   
    year: dayjs().year(),
    // @ts-ignore
    month: dayjs().month() + 1,
    disabledFutureDay: true,
    highlightCurrentDay: true,
})
const emit = defineEmits<{
   
   
    yearChange: [params: TimeEmitInfo],
    monthChange: [params: TimeEmitInfo],
    dateChange: [params: TimeEmitInfo]
}>()

// #选中年
const year = ref(props.year)
// #选中月
const month = ref(props.month)
// #选中的日
const selectDate = ref<null | string>(null)

// 选中日期后的事件处理
const selectDay = ()=>{
   
   }

// #生成日期列表
const getTimeListByYearAndMonth = () => {
   
   
    // !清空选中日期
    selectDate.value = null
    // 获取当前选中月份的1号
    let selectDay = `${
     
     year.value}-${
     
     month.value}-01`
    // 选中时间是周几
    const weekDay = dayjs(selectDay).day()
    // 日历组件的起始日期
    const firstDay = dayjs(selectDay).subtract(weekDay, 'day')
    const dayList = []
    for (let i = 0; i < 6; i++) {
   
   
        const childrenList = []
        for (let time = 0; time < 7; time++) {
   
   
            let day = dayjs(firstDay).add(i * 7 + time, "day")
            const date = day.format("YYYY-MM-DD")
            childrenList.push(
                {
   
    label: day.format("D"), date, active: Number(day.format("M")) == month.value, isCurrent: date === dayjs().format("YYYY-MM-DD") }
            )
        }
        dayList.push({
   
   
            time: dayjs(firstDay).add(i * 7, "day").format("YYYY-MM-DD"),
            children: childrenList
        })
    }
    console.log('dayList: ', dayList);

    return dayList
}

const dayList = ref(getTimeListByYearAndMonth())

上述代码基本就是日历组件的核心部分。注意,我们引入了dayjs用于处理时间,同时,引入了它的weekOfYear包用于处理日期周相关的逻辑。

逻辑功能实现

日期切换事件

切换日期后,我们应该告诉父组件我们当前选择的时间。

const selectDay = (day: string) => {
   
   
    if (day === selectDate.value) {
   
   
        selectDate.value = null
    } else {
   
   
        selectDate.value = day
    }
    emit("dateChange", day)
}

根据页面的遍历逻辑,上述代码中的day是一个具体日期的时间字符如:2024-06-30
实际上,我们传递给父组件的数据可以更加详细具体些,以便上层进行业务逻辑处理。我们可以将选中的时间进行更加详细的封装:

const emitTimeInfo = computed(() => {
   
   
    const time = selectDate.value || `${
     
     year.value}-${
     
     month.value}-01`;
    return {
   
    year: year.value, month: month.value, date: selectDate.value ? Number(dayjs(selectDate.value).format("D")) : null, time }
})
const selectDay = (day: string) => {
   
   
    if (day === selectDate.value) {
   
   
        selectDate.value = null
    } else {
   
   
        selectDate.value = day
    }
    emit("dateChange", emitTimeInfo.value)
}

年月切换

年月的切换,核心就是重新计算日历的起始时间,我们在点击的时候重新调用getTimeListByYearAndMonth方法给要渲染的列表dayList赋值即可。

// #增加年
const addYear = () => {
   
   
    if (disabledYear.value) return
    year.value && year.value++
    // !不可点击未来月时,避免时间溢出
    if (props.disabledFutureDay && month.value && month.value > dayjs().month() + 1) {
   
   
        // @ts-ignore
        month.value = dayjs().month() + 1
    }
    dayList.value = getTimeListByYearAndMonth()
    emit("yearChange", emitTimeInfo.value)
}
// 减少年
const minusYear = () => {
   
   
    year.value && year.value--
    dayList.value = getTimeListByYearAndMonth()
    emit("yearChange", emitTimeInfo.value)
}
// #增加月
const addMonth = () => {
   
   
    if (disabledMonth.value) return
    if (month.value == 12) {
   
   
        month.value = 1
        year.value && year.value++
    } else {
   
   
        month.value && month.value++
    }
    dayList.value = getTimeListByYearAndMonth()
    emit("monthChange", emitTimeInfo.value)
}
// 减少月
const minusMonth = () => {
   
   
    if (month.value == 1) {
   
   
        year.value && year.value--
        month.value = 12
    } else {
   
   
        month.value && month.value--
    }
    dayList.value = getTimeListByYearAndMonth()
    emit("monthChange", emitTimeInfo.value)
}

一些其他的样式逻辑

为了完善日历组件的样式,我们可以增加一些自定义的计算属性,用于控制样式。

// #选中年是否当前年
const isLastYear = computed(() => year.value === new Date().getFullYear())
// #选中月是否当前月
const isLastMonth = computed(() => isLastYear.value && month.value === dayjs().month() + 1)
// #年份禁用
const disabledYear = computed(() => props.disabledFutureDay && isLastYear.value)
// #月份禁用
const disabledMonth = computed(() => props.disabledFutureDay && isLastMonth.value)

完整代码

<template>
    <div class="schedule-calendar">
        <div class="time-switch-tool">
            <div class="time-tool">
                <IconMeriComponentArrowLeft class="icon" color="#8B949E" @click.stop="minusYear">
                </IconMeriComponentArrowLeft>
                <m-button type="text" size="small">
                    <div class="time">{
   
   {
   
    year }}</div>
                </m-button>
                <IconMeriComponentArrowRight class="icon" :class="{ disabled: isLastYear && disabledFutureDay }"
                    :color="disabledYear ? '' : '#8B949E'" @click.stop="addYear">
                </IconMeriComponentArrowRight>
            </div>
            <div class="time-tool">
                <IconMeriComponentArrowLeft class="icon" color="#8B949E" @click.stop="minusMonth">
                </IconMeriComponentArrowLeft>
                <m-button type="text" size="small">
                    <div class="time">{
   
   {
   
    month }}</div>
                </m-button>
                <IconMeriComponentArrowRight class="icon" :class="{ disabled: disabledMonth }"
                    :color="disabledMonth ? '' : '#8B949E'" @click.stop="addMonth">
                </IconMeriComponentArrowRight>
            </div>
        </div>
        <div class="week-day">
            <span></span><span></span><span></span><span></span>
            <span></span><span></span><span></span>
        </div>
        <div class="calendar-wrap">
            <div class="day-list" v-for="time in dayList  ">
                <div class="day-cell" v-for="item in time.children   " :class="{ select: item.date === selectDate }"
                    @click="selectDay(item.date)">
                    <span class="day"
                        :class="[{ active: item.active }, { current: item.isCurrent && props.highlightCurrentDay }]">
                        {
   
   {
   
    item.label }}
                    </span>
                    <span :class="{ dot: isShowDot(item.date) }"></span>
                </div>
            </div>
        </div>
    </div>
</template>
<script lang="ts" setup>
import {
   
    IconMeriComponentArrowLeft, IconMeriComponentArrowRight } from "xxx-icon";
import dayjs from "dayjs";
import weekOfYear from "dayjs/plugin/weekOfYear"
dayjs.extend(weekOfYear);

interface Props {
   
   
    year?: number
    month?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
    // 是否禁用未来日期
    disabledFutureDay?: boolean
    // 是否高亮当前日期
    highlightCurrentDay?: boolean
    // 选中的日期列表
    dotDateList?: string[]
}
interface TimeEmitInfo {
   
   
    year: number, month: number, date: number | null, time: string
}
const props = withDefaults(defineProps<Props>(), {
   
   
    year: dayjs().year(),
    // @ts-ignore
    month: dayjs().month() + 1,
    disabledFutureDay: true,
    highlightCurrentDay: true,
    dotDateList: () => []
})
const emit = defineEmits<{
   
   
    yearChange: [params: TimeEmitInfo],
    monthChange: [params: TimeEmitInfo],
    dateChange: [params: TimeEmitInfo]
}>()

// #选中年
const year = ref(props.year)
// #选中月
const month = ref(props.month)
// #选中的日
const selectDate = ref<null | string>(null)

const emitTimeInfo = computed(() => {
   
   
    const time = selectDate.value || `${
     
     year.value}-${
     
     month.value}-01`;
    return {
   
    year: year.value, month: month.value, date: selectDate.value ? Number(dayjs(selectDate.value).format("D")) : null, time }
})
const selectDay = (day: string) => {
   
   
    if (day === selectDate.value) {
   
   
        selectDate.value = null
    } else {
   
   
        selectDate.value = day
    }
    emit("dateChange", emitTimeInfo.value)
}
// #生成日期列表
const getTimeListByYearAndMonth = () => {
   
   
    // !清空选中日期
    selectDate.value = null
    // 获取当前选中月份的1号
    let selectDay = `${
     
     year.value}-${
     
     month.value}-01`
    // 选中时间是周几
    const weekDay = dayjs(selectDay).day()
    // 日历组件的起始日期
    const firstDay = dayjs(selectDay).subtract(weekDay, 'day')
    const dayList = []
    for (let i = 0; i < 6; i++) {
   
   
        const childrenList = []
        for (let time = 0; time < 7; time++) {
   
   
            let day = dayjs(firstDay).add(i * 7 + time, "day")
            const date = day.format("YYYY-MM-DD")
            childrenList.push(
                {
   
    label: day.format("D"), date, active: Number(day.format("M")) == month.value, isCurrent: date === dayjs().format("YYYY-MM-DD") }
            )
        }
        dayList.push({
   
   
            time: dayjs(firstDay).add(i * 7, "day").format("YYYY-MM-DD"),
            children: childrenList
        })
    }
    console.log('dayList: ', dayList);

    return dayList
}

const dayList = ref(getTimeListByYearAndMonth())

// #选中年是否当前年
const isLastYear = computed(() => year.value === new Date().getFullYear())
// #选中月是否当前月
const isLastMonth = computed(() => isLastYear.value && month.value === dayjs().month() + 1)
// #年份禁用
const disabledYear = computed(() => props.disabledFutureDay && isLastYear.value)
// #月份禁用
const disabledMonth = computed(() => props.disabledFutureDay && isLastMonth.value)
// #增加年
const addYear = () => {
   
   
    if (disabledYear.value) return
    year.value && year.value++
    // !不可点击未来月时,避免时间溢出
    if (props.disabledFutureDay && month.value && month.value > dayjs().month() + 1) {
   
   
        // @ts-ignore
        month.value = dayjs().month() + 1
    }
    dayList.value = getTimeListByYearAndMonth()
    emit("yearChange", emitTimeInfo.value)
}
// 减少年
const minusYear = () => {
   
   
    year.value && year.value--
    dayList.value = getTimeListByYearAndMonth()
    emit("yearChange", emitTimeInfo.value)
}
// #增加月
const addMonth = () => {
   
   
    if (disabledMonth.value) return
    if (month.value == 12) {
   
   
        month.value = 1
        year.value && year.value++
    } else {
   
   
        month.value && month.value++
    }
    dayList.value = getTimeListByYearAndMonth()
    emit("monthChange", emitTimeInfo.value)
}
// 减少月
const minusMonth = () => {
   
   
    if (month.value == 1) {
   
   
        year.value && year.value--
        month.value = 12
    } else {
   
   
        month.value && month.value--
    }
    dayList.value = getTimeListByYearAndMonth()
    emit("monthChange", emitTimeInfo.value)
}

// 是否展示dot
const isShowDot = (date: string) => {
   
   
    if (props.dotDateList?.length === 0) return false
    return props.dotDateList?.find((res) => dayjs(res).format("YYYY-MM-DD") === dayjs(date).format("YYYY-MM-DD"))
}
</script>

<style lang="less" scoped>
.schedule-calendar {
   
   
    user-select: none;

    .time-switch-tool {
   
   
        height: 22px;
        display: flex;
        justify-content: space-between;
        align-items: center;

        .time-tool {
   
   
            .icon {
   
   
                width: 16px;
                height: 16px;
                color: #8B949E;
                cursor: pointer;

                &.disabled {
   
   
                    cursor: not-allowed;
                }
            }

            .time {
   
   
                color: #1B2129;
                text-align: center;
                font-size: 14px;
                font-weight: 400;
                line-height: 22px;
                margin: 0 4px;
                user-select: none;
            }

            display: flex;
            justify-content: space-between;
            align-items: center;
        }
    }

    .week-day {
   
   
        margin-top: 7px;
        display: flex;
        justify-content: space-between;
        align-items: center;
        height: 28px;

        span {
   
   
            display: block;
            width: 28px;
            color: #8B949E;
            text-align: center;
            font-size: 14px;
            font-weight: 400;
            line-height: 22px;
        }
    }

    .calendar-wrap {
   
   
        .day-list {
   
   
            display: flex;
            margin-top: 4px;
            height: 28px;
            justify-content: space-between;
            align-items: center;

            .day-cell {
   
   
                width: 28px;
                height: 28px;
                position: relative;

                >span {
   
   
                    display: block;
                    text-align: center;
                    font-size: 14px;
                    font-weight: 400;
                    line-height: 28px;
                    cursor: pointer;
                    color: #C4C9CF;

                    &.active {
   
   
                        color: #2E3742;
                    }

                    &.current {
   
   
                        color: #246FE5;
                    }
                }

                &:hover {
   
   
                    background: #E8ECF0;
                    border-radius: 5px;
                }

                .dot {
   
   
                    position: absolute;
                    width: 4px;
                    height: 4px;
                    border-radius: 50%;
                    background: #246FE5;
                    bottom: 1px;
                    left: 12px;

                }
            }

            .select {
   
   
                border-radius: 4px;
                background: #246FE5;

                .day {
   
   
                    color: #FFF !important;
                }

                &:hover {
   
   
                    background: #246FE5;
                    border-radius: 4px;
                }

                .dot {
   
   
                    background: #FFF;
                }
            }
        }
    }
}
</style>

注意,上述代码中的年月切换箭头采用了其他UI库,实际使用时,需要进行替换。

效果演示
233.gif

总结

本文分享了一个日历组件的实现方案,样式、功能都比较完备,是一个可以直接在项目中使用的组件。 大家可以根据自己的业务需求、样式进行适当调整。

相关文章
|
定位技术 开发工具 开发者
为了让外卖小哥在地图里开上火箭🚀我用FLutter自定义了地图
花了五天时间,用Flutter自定义地图是什么体验?外卖小哥都开上火箭了?什么?我被女朋友赶出家门啦?欢迎观看被女友赶出家门之开火箭送外卖篇~
|
1月前
|
JavaScript
手搓日历组件,大屏样式最佳解决方案!
【10月更文挑战第6天】手搓日历组件,大屏样式最佳解决方案!
41 4
手搓日历组件,大屏样式最佳解决方案!
|
6月前
|
前端开发 移动开发 JavaScript
跨年动态炫酷烟花网页代码
利用Html5的Canvas技术,模拟出逼真的烟花效果,让用户在网页上欣赏到绚丽多彩的烟花盛宴。同时,通过交互式设计,让用户能够与烟花互动,增加趣味性。
75 0
跨年动态炫酷烟花网页代码
|
JSON 自然语言处理 前端开发
用D3制作一张有翻页特效的手撕日历(只需100行代码)
在D3中用十分简单的代码就可以实现丰富的动画,下面来看一下手撕日历的动画效果吧
274 1
用D3制作一张有翻页特效的手撕日历(只需100行代码)
|
数据可视化 前端开发
漏刻有时数据大屏CSS样式表成长教程(2):九宫格图表背景自适应的解决方案
漏刻有时数据大屏CSS样式表成长教程(2):九宫格图表背景自适应的解决方案
136 1
五款炫酷精美动态登录页面,彩虹气泡动态云层深海灯光水母炫酷星空蛛网HTMLCSS源码
五款炫酷精美动态登录页面,彩虹气泡动态云层深海灯光水母炫酷星空蛛网HTMLCSS源码
142 0
|
编解码 前端开发 JavaScript
手摸手带你实现一个时间轴组件
本文给大家带来一个时间轴的组件开发教程
729 0
|
数据库 数据安全/隐私保护
写代码的七八九十宗罪,多图、胆小慎入!
写代码的七八九十宗罪,多图、胆小慎入!
183 0
写代码的七八九十宗罪,多图、胆小慎入!
|
前端开发 IDE 开发工具
「趣学前端」优雅又精致,来看看别人家的表格样式是怎样实现
用技术实现梦想,用梦想打开创意之门。今天分享前端CSS中的表格的知识点。
225 0
|
前端开发
前端工作总结279-ele-图标使用
前端工作总结279-ele-图标使用
153 0
前端工作总结279-ele-图标使用