简介
大家好,我是石小石!在大屏开发中,我们可能会遇到日历组件的使用,然而,对于一些定制化的大屏,传统的第三方UI组件很难满足我们的样式要求。
这个时候,我们一般有两个选择:
- 说服UI和老板,使用第三方组件(客户吐槽,最终可能要改)
- 强行改组件库UI样式,让自己痛不欲生
实际上,大屏使用的日历组件大多只是展示使用,业务简单,我们手搓一个其实非常简单,最重要的是,样式完全可以自定义!
这篇文章,我将向大家展示如何封装一个vue日历组件,可以直接引入使用。它的样式功能如下
- 基本日期展示
- 时间切换
- 禁用未来时间
- 高亮当前样式
- ....
核心思路分析
日历组件的核心其实是每页日期数据的生成,日期数据是一个7*6的一维列表,我们只要计算出这个列表的第一项日期数据,后面的日期依次加1即可。其次,就是一些简单的样式处理,非当前月的日期进行置灰即可。
页面的数据结构
为了便于页面的渲染,我们可以将7*6的日期列表封装到一个数组里,数组的每一行对应日期的每一行数据
为了便于后续的样式拓展,我们可以给每个日期对象增加一些数据,比如
{ // 显示的标签 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月,我们的核心就是计算出当前面板的第一个标签的时间。
要计算当前日历面板的第一个时间,我们可以先找到当前选中年月的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; }
技术方案
基础样式搭建
日历组件的样式其实非常简单,无非就是头部切换功能样式,和主体日历时间部分样式,这部分大家完全可以按照业务情况自己开发。
组件参数设计
作为一个公用组件,一些核心的组件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库,实际使用时,需要进行替换。
效果演示
总结
本文分享了一个日历组件的实现方案,样式、功能都比较完备,是一个可以直接在项目中使用的组件。 大家可以根据自己的业务需求、样式进行适当调整。