FullCalendar日历组件集成系列简明合订版

背景

有一些应用系统或应用功能,如日程管理、任务管理需要使用到日历组件。虽然Element Plus也提供了日历组件,但功能比较简单,用来做简单的数据展示勉强可用。但如果需要进行复杂的数据展示以及互动操作如通过点击添加事件,则需要做大量的二次开发。
此时就需要专门的日历组件来实现相应的功能。
FullCalendar是一款备受欢迎的开源日历组件,以其强大的功能而著称。其基础功能不仅免费且开源,为开发者提供了极大的便利,仅有少量高级功能需要收费。然而,尽管该组件功能卓越,其文档却相对简洁,导致在集成过程中需要开发者自行摸索与探索,这无疑增加了不少学习和验证的时间成本。
为此,本文通过日程管理系统的真实案例,手把手带你了解该组件的属性和功能,通过需求导向的方式,详细阐述FullCalendar组件的集成思路和实用解决方案。
在介绍过程中,我们将重点关注集成要点和注意事项,力求帮助开发者在集成过程中少走弯路,提供有效的避坑指南,从而提升开发效率,更好地利用这款优秀的日历组件。

先前采用一边摸索一边实现的方式,在实现业务系统的同时,同步输出了系列博客,该系列可以称之为“过程版”。开发过程中遇到的问题,考虑的解决思路与方案,分析与选择都一一记录了下来。
考虑到小伙伴只是需要解决某个功能点或线上的问题,相对过程,更关注结果,因此在过程版基础上进行二次整理,通过逻辑分类,使其更有条理性,比如集中说明日视图的关键属性配置;去除摸索过程中的思路和方案、走过的弯路等内容,使其更简明,因此该系列称之为“简明版”。

如果想跟着学习开发经验,建议阅读“过程版”。
如果想投入更少的时间精力快速获取FullCalendar某个功能集成和使用要点,建议阅读“简明版”。

输出了一款面向个人的时间管理系统:https://meet.popsoft.tech。
注意,该系统不仅仅可用于查看本系列博客对应的展示效果,也可以作为自己的时间管理工具,本人就在持续使用,并在使用过程中不断迭代完善。

FullCalendar官网:https://fullcalendar.io/
image.png
环境Vue3+Element Plus+FullCalendar 6.1.11。

问题清单

本系列的已解决的问题清单如下,还在持续更新中……

问题项 说明
整体预览 月、周、日、列表视图的预览
版本差异 免费版的主要功能,收费版的附加功能
安装说明 如何安装与基本使用
初始配置 默认初始化配置情况与效果
配置语种 界面显示为中文
设置周起始日 按照中国习惯,将周一设为一周的起始
设置头部工具栏 设置头部标题及按钮,变更默认配置,去除上一年下一年按钮,解决标题栏换行问题、上一个下一个按钮不显示问题,自定义按钮
插件介绍 插件概念及配置月、周、日视图和列表视图
更改allDay显示 配置allDay显示为中文
配置周次 打开显示周次开关,并自定义显示周次内容,由“W12”调整为“第12周”
设置主题风格 以使用bootrap5主题风格为例,说明如何更改组件自带的主题(此处误区较多)
按需加载事件 按照视图显示的起止时间,去后端获取指定时间范围内数据
调整单元格高度 解决默认设置留下大量空白影响美观的问题
限制事件显示最大数量 设置一个单元格显示最大数量,多出来的以“更多”方式聚合
设置事件最小高度 通过设置属性,避免自动缩放字体导致的不美观及字体过小查看不便的问题
显示事件时间起止时间 控制各视图中事件是否显示开始时间和结束时间
优化事件显示 利用“全天”机制优化事件显示
隐藏全天区域 不显示全天区域配置
开启视图间导航 通过调整配置,开始各视图下的链接与快速导航
开启时间线显示 开启当前时间线标识
设置工作时间 设置工作时间与非工作时间
设置可用时间段 控制夜间休息期间时间段不显示
设置事件颜色 根据事件自身属性或业务扩展属性,如优先级、重要程度、分类等设置不同的颜色
设置事件排序 基于组件内置规则与约定,灵活控制事件的显示顺序
设置时间片 设置时间段最小颗粒度
设置时间坐标显示 设置时间坐标轴的颗粒度和显示格式
为事件增加右键菜单 通过二次开发,实现事件邮件菜单,可以通过菜单快速复制、删除事件
事件处理 在日历视图中进行事件的创建、修改与显示,通过点击和拖放来自动填充起止时间,可以复制和删除
通过拖放调整起止时间 在日历视图中通过拖放事件调整开始时间和结束时间
通过缩放调整起止时间 在日历视图中通过缩放事件调整开始时间和结束时间
事件区域间拖动导致结束时间丢失问题修复 事件在全天与非全天区域间拖动,组件会清空结束时间,自己补写处理逻辑解决
从组件外拖拽事件至组件内部 组件外拖放事件至FullCalendar内部,自动设置起止时间,坑点较多
通过颜色区分任务完成状态 通过不同颜色区分任务是否完成
解决新增事件重复问题 解决因FullCalendar内置缓存机制导致的事件重复显示问题
持续更新中……

整体预览

首先看一下官方提供的预览效果,对FullCalendar·组件的功能和实现效果有个大概的了解。

月视图

支持中文,并且可以按照国人习惯,将周一放在一周的起始。
image.png

周视图

image.png

日视图

image.png

列表视图

image.png

版本差异

官方提供了三个版本,其中标准版是MIT协议。
image.png
基础功能免费,只有少量高级功能需要收费,例如邮件技术支持、打印功能的友好展现。
有差异的功能主要就是两个,一个是时间线视图,可自定义的水平时间轴和行形式的资源,即显示一个任务的当天或跨天情况。
image.png

另外一个是垂直资源视图,能够将资源显示为列,例如会议室预定系统,显示各会议室各时间段的预定情况。
image.png
以个人为对象的时间管理,不需要这两方面的功能,所以不考虑收费功能版本。有这方面需求的小伙伴,可以考虑购买商业版本。

安装

在vscode终端中执行以下命令安装日历组件相关的包,如下所示:

pnpm install  @fullcalendar/core  @fullcalendar/vue3 

image.png

初始配置

按照官方示例https://fullcalendar.io/docs/vue,写了一个初始化页面,源码如下:

<template>
  <FullCalendar :options="calendarOptions" />
</template>

<script>
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'

export default {
  name: 'ListByCalendar',
  components: { FullCalendar },
  data() {
    return {
      calendarOptions: {
        plugins: [dayGridPlugin, interactionPlugin],
        initialView: 'dayGridMonth'
      }
    }
  }
}
</script>

<style scoped></style>

运行报错,提示dayGridPlugin未找到,查阅官方文档有句话是Then install any additional FullCalendar plugins like @fullcalendar/daygrid,即所有的插件也都需要单独安装。

在vscode终端中执行以下命令安装两个插件相关的包。

pnpm install  @fullcalendar/daygrid @fullcalendar/interaction 

然后,组件可以正常加载了,如下图所示
image.png
界面不怎么美观,默认显示语种是英文,右上角切换上一个月和下一个月按钮就是两个黑块。

基本配置

FullCalendar是一个可高度定制的组件,提供了诸多配置选项供使用方根据自己的需求来调整。
接下来就重点介绍下配置选项。

设置中文语种

第一件要做的事情,就是把界面调整为中文,通过设置locale: 'zh-cn’来实现,如下:

  calendarOptions: {
        // 插件列表
        plugins: [dayGridPlugin, interactionPlugin],
        // 默认视图
        initialView: 'dayGridMonth',
        // 语言
        locale: 'zh-cn'
}

调整后效果如下:
image.png
但是汉化不完整,标题、周、日如期变成了中文,右上角的按钮依旧是英文。
补充说明:通过后面的深入探索,将语种配置为中文后,右上角按钮依旧显示是英文,其原因是日历组件并没有把所有的显示元素都放到了语言包里,而是提供了自定义配置功能,后续有详细说明。

设置周起始日

按照中国文化,一周的第一天应该是周一,官方demo演示中,把语种切换为中文,第一天会自动变为周一。上面我们设置了locale为中文,但第一天还是周日,需要自行手工来设置属性firstDay来解决,如下所示:

 calendarOptions: {
        // 插件列表
        plugins: [dayGridPlugin, interactionPlugin],
        // 默认视图
        initialView: 'dayGridMonth',
        // 语言
        locale: 'zh-cn',
        // 周起始日
        firstDay: 1
      }

注:官方规则周日开始计数,且起始值为0, 所以周一恰好对应的值是1。
然后查看效果,周一如预期变成一周的第一天了。
image.png

设置头部

头部在FullCalendar的设计与实现中,归属于为工具条Toolbar,且属于头部工具条headerToolBar。
官方说明地址:https://fullcalendar.io/docs/headerToolbar
image.png
默认的布局就是左侧为当前年月,右侧为今天和上一个、下一个按钮,实际是可配置的,如下所示:

{
  start: 'title',
  center: '',
  end: 'today prev,next' 
}

我们想左侧显示今天,居中显示当前年月,且两侧显示上一个、下一个及上一年和下一年,右侧显示月、周、日视图切换,调整配置如下:

 // 头部显示
  headerToolbar: {
    left: 'today',
    center: 'prevYear,prev title next,nextYear',
    right: 'dayGridMonth,dayGridWeek,dayGridDay'
  },

image.png
有三个问题需要解决:
1.按钮显示为英文,比如today、month、week、day
2.上一个、下一个等按钮显示为黑块
3.中部区域产生了换行,严重影响美观

设置按钮显示中文

默认按钮显示是英文的,比如today、week、day,通过以下配置来转换为中文,如下所示:

buttonText: {
    today: '今天',
    month: '月',
    week: '周',
    day: '日'
}

调整后符合预期,效果如下:
image.png

解决上一个、下一个等按钮显示为黑块问题

经反复验证,本质也是按钮文本设置问题。
需要通过设置buttonText属性来解决,官方文档里真没有,靠摸索。

buttonText: {
  today: '今天',
  month: '月',
  week: '周',
  day: '日',
  prev: '‹',
  next: '›',
  prevYear: '«',
  nextYear: '»'
}

调整后可以正常显示箭头了。

解决换行问题

尝试在left区域加了下按钮,没有出现换行现象,那问题基本定位是title的css导致的。
image.png
通过浏览器的开发者模式,分析标题的css层次,发现外层套了一个h2标签,于是验证了下:

<style scoped>
:deep(h2) {
  color: red;
  display: inline-block;
}
</style>

image.png
果然同行显示了,再微调下对齐,最终如下:

<style scoped>
:deep(h2) {
  display: inline-block;
  vertical-align: middle;
}
</style>

效果如下:
image.png

去除上一年与下一年按钮

从实际需求考虑,并不需要上一年与下一年这个大跨度,因此配置头部工具栏,去除上一年与下一年这两个按钮的显示,保留上一个与下一个两个按钮。

// 头部显示
headerToolbar: {
  left: 'today',
  center: 'prev title next',
  right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
}

自定义按钮

官方预置了添加自定义按钮的功能,可以通过如下方式定义一个自己的按钮:

customButtons: {
  showMessage: {
      text: '显示信息',
      click: this.showMessage
    }
}

其中text对应着显示内容,click对应着触发事件,而键值showMessage,则可以直接用于头部工具栏的配置,如下:

// 头部显示
headerToolbar: {
  left: 'today',
  center: 'pre title next',
  right: 'showMessage dayGridMonth,timeGridWeek,timeGridDay,listWeek'
}

效果如下:image.png
官方提供的方式还是非常便捷的,并且这里说是按钮,实际上也可以不设置调用方法,仅作为文本显示。

插件

作为一款优秀设计,FullCalendar实现了插件化。
在先前的配置中,我们参照官方示例实现了一个基本的Demo,引入了组件如下:

import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'

其中@fullcalendar/vue3是日历组件的本体,插件@fullcalendar/interaction提供了集成与交互功能,插件@fullcalendar/daygrid提供了日历视图,这三个是必选的。

daygrid插件

daygrid插件提供了月、周、日三种视图,如下:
image.png
image.png
image.png
可以看到,对于月视图还好,对于周视图和日视图,则缺少了时间维度,此时需要引入新的插件timegrid。

timegrid插件

timegrid插件需要安装,如下:

pnpm install @fullcalendar/timegrid

然后引入和配置:

import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import timeGridPlugin from '@fullcalendar/timegrid'

export default {
  name: 'ListByCalendar',
  components: { FullCalendar },
  data() {
    return {
      calendarOptions: {
        // 插件列表
        plugins: [dayGridPlugin, interactionPlugin, timeGridPlugin],

同时,需要将头部按钮调整下,使用timeGridWeek替代掉dayGridWeek,使用timeGridDay替代掉dayGridDay,如下:

// 头部显示
headerToolbar: {
  left: 'today',
  center: 'prev title next',
  right: 'dayGridMonth,timeGridWeek,timeGridDay,'
}

此后,再查看周视图和日视图,时间维度显示出来了,如下:
image.pngimage.png

listgrid

在尝试过程中发现组件还有一个列表插件不错,进行安装与配置。
安装新的插件list,如下:

pnpm install @fullcalendar/list

引用,配置插件,然后头部工具栏增加list视图,同时按钮文本增加列表,配置如下:
image.png
效果如下:
image.png

界面优化

更改all-day 显示文本

使用timegrid插件替代掉daygrid的周视图和日视图后,在周视图和日视图顶部有个区域,显示为英文 all-day,如下图所示:
image.png
通过以下属性设置:

// 更改all-day 显示文本
allDayText: '全天'

效果如下:
image.png

增加周次显示

周次,即本周是一年中的第几个周,是一个挺有用的信息,默认不显示。
如想显示,则修改下配置weekNumbers,打开日历组件的周次显示,如下:

// 周次
weekNumbers: true,

效果如下:
image.png

优化周次显示

前面我们配置了属性weekNumbers,设置为true后开启了周次显示功能。
默认的“W+周次数字”的模式虽然简明,但看上去像是中文日历中遗留了一个非汉化的元素,可以进一步通过配置解决,如下:

// 开启周次显示
weekNumbers: true,
// 显示周次文本
weekText: '周',

W被替换为了我们设置的“周”,效果如下:
image.png
这样看上去还是有点别扭,不符合中文习惯,颠倒一下顺序,如22周,或第22周更符合国人习惯。
通过使用回调方法来重写展现解决,如下所示:

 // 设置周次显示
weekNumberContent: this.weekNumberContent

methods: {
  // 设置周次显示
  weekNumberContent(arg) {
    return '第' + arg.num + '周'
  }
}

效果如下:
image.png

设置主题风格

当前外观风格与我们系统以蓝色为主色调的风格不太和谐,演示页面有诸多风格可选。
image.png
但是通过设置themeSystem属性,不起作用,认真琢磨了下,其实官方默认就只带了一种样式,就是上面截图显示的那种暗色调,其他包括bootstrap在内的样式,都需要额外配置。
经查看和对比,Bootstrap5的蓝色色调就不错,动手安装吧。

pnpm install @fullcalendar/bootstrap5
pnpm install  bootstrap
pnpm install  bootstrap-icons

经测试,以上3个一个都不能少,然后在配置中引入:

import bootstrap5Plugin from '@fullcalendar/bootstrap5'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-icons/font/bootstrap-icons.css'

然后注意bootstrap5同样是一个插件,需要加到calendarOptions的plugins数组中,如下:
image.png
刷新页面,效果终于出来了:
image.png

事件加载

FullCalendar中,事件是核心概念,与通常我们说的日程项是一个概念。
当前我是基于FullCalendar实现一个任务管理系统,任务对应着事件的概念。
FullCalendar官方事件对应的介绍参见https://fullcalendar.io/docs/event-object
image.png
注意:上图未截全,实际属性更多一些。
核心属性是三个,标题title、开始时间start和结束时间end,此外,唯一性标识id组件也很贴心的预置了,用来关联前后端数据,例如在点击事件时调用后端服务,将id属性作为参数传入来查询数据。

我们可以调用后端服务,一次性把所有任务数据都传回了前端,由前端将任务数据转换为FullCalendar的事件对象显示在日历中。这种模式系统刚开始使用,数据量比较小,系统会流畅运行。但随着时间的推移,数据越来越多,性能上可能存在问题,因此需要按需加载数据。
因官方文档过于简略,这地方花了很长时间摸索,详细说说。

功能需求

1.初始化页面时调用后端服务加载数据
2.点击头部按钮工具栏时,进行视图切换(月、周、日)或点击前一个、后一个时,调用后端服务来加载数据。

方案探索

按需加载的关键在于拿到起止时间。
通过查看官方文档,当前视图的起止时间,组件模型倒是提供了基础数据模型支撑(https://fullcalendar.io/docs/view-object),在视图对象中有几个关键属性:
activeStart:可见开始时间
activeEnd:可见结束时间
currentStart:真实开始时间
currentEnd:真实开始时间
两类起止时间,差异在于前者是可见,后者是真实,以月视图为例,默认是显示6行,42天。
image.png
以上图为例,activeStart是5月27日,activeEnd是7月7日,currentStart和currentEnd则是6月1日和6月30日。
结合上面例子,我们想控制后端返回数据的范围,使用的属性应该是activeStart和activeEnd。

接下来就在于如何拿到起止时间了,并且在视图切换时触发调用后端服务来获取数据。
翻了很长时间的官方文档,发现组件预置的几个按钮,没有暴漏接口出来,要想添加自己的逻辑,能想到的曲线救国的方式,就是使用自定义按钮去覆写整个头部工具栏,以上一个按钮为例:

 customButtons: {
    changeShowScopeButton: {
      text: '显示全部',
      click: this.changeShowScope
    },
    myPreButton: {
      text: '‹',
      click: this.navPre
    }
  }
 navPre(e) {
      const view = this.$refs.fullCalendar.calendar.view
      ……     
 }

这么做缺点明显,很繁琐……

进一步探索,组件自身是否在视图呈现时回调方法,获取到视图对象,拿到起止时间。
还真找到了https://fullcalendar.io/docs/view-render-hooks
添加属性与方法,如下:

// 视图展示回调
viewDidMount: this.viewDidMount

viewDidMount(view) {
  console.log(view)
}

测试了下,只有视图加载时才会触发,同一视图,如月视图,点击上一个或下一个按钮,都不会触发回调。

此外,还有一个隐含的关键问题,头部工具栏最右侧的四个切换按钮实际来自于三个不同的视图插件,周视图和日视图是公用一个插件timeGrid。而机制是只有视图切换时才会触发,因此日视图和周视图之间切换,并不会触发回调。这样就无法实现我们的功能需求了。

方案确认

一度打算采用最初的思路,重写头部工具栏的方式来实现,工作量略大,但整体上可行。
后来在系统地查看官方文档时,突然从一个角落找到了解决方案,即通过函数的方式来获取事件数据源。
官方文档:https://fullcalendar.io/docs/events-function
不得不说,太隐蔽了,当时查看文档时,仅当成一种提供事件数据源方式,没有点开细看。
按照官方说明,通过events属性指定一个回调方法,当用户点击上一个、下一个或者切换视图时触发,并且回调时会传入开始时间和结束时间。

做了下验证:

// 加载事件数据
events: this.loadEvent

// 加载事件数据
loadEvent(fetchInfo, successCallback, failureCallback) {
  this.startTime = this.$dateFormatter.formatUTCTime(fetchInfo.start)
  this.endTime = this.$dateFormatter.formatUTCTime(fetchInfo.end)
  console.log('loadEvent', this.startTime, this.endTime)
}   

打开控制台,点击按钮测试,结果如下:
image.png
组件内置的按钮(今天、上一个、下一个、月视图、周视图、日视图和列表视图),均能触发回调,并且内部做了逻辑判断,只有当前展示的数据本地没有,才会发起回调。
怎么理解呢?例如先加载了月视图,这时候拿到了一个月的事件数据,如果这时候切换到周视图,如果当前显示的周数据没超出已获取到的一个月范围内,则不会发起回调,如果超出,才会回调,这些细节只有测试和验证才能获取到。

方案实现

基于上述方案,实现如下:

// 加载事件数据
events: this.loadEvent

// 加载事件数据
loadEvent(fetchInfo, successCallback, failureCallback) {
  const startTime = this.$dateFormatter.formatUTCTime(fetchInfo.start)
  const endTime = this.$dateFormatter.formatUTCTime(fetchInfo.end)
  this.$api.personaltask.task.listWithScope(startTime, endTime).then((res) => {
    if (res.data) {
      const eventArray = res.data.map((task) => {
        return this.convertTaskToEvent(task)
      })
      successCallback(eventArray)
    }
  })
}

后端处理

前面实现了功能,不过说的主要的前端的事。
后端取数也比较重要,在这补充说明下。
后端取数逻辑需要考虑周全,涉及到四个时间的比较。
任务开始时间
任务结束时间
查询范围开始时间(FullCalendar组件)
查询范围结束时间(FullCalendar组件)

假设任务的起止时间都有值的情况下,实际需要考虑三种情况:

  • 任务开始时间>=区域开始时间且任务开始时间<=区域结束时间
  • 任务结束时间>=区域开始时间且任务结束时间<=区域结束时间
  • 任务开始时间<=区域开始时间且任务结束时间>=区域结束时间

即任务开始时间落在区域时间范围内,或任务结束时间落在区域时间范围内,或任务起止时间包含在区域时间内部。
服务方法如下:

 @Override
public List<Task> listWithScope(LocalDateTime startTime, LocalDateTime endTime) {

    QueryWrapper<Task> queryWrapper = new QueryWrapper<>();
    queryWrapper.lambda()
            // 任务开始时间落在区域范围内,即任务开始时间>=区域开始时间且任务开始时间<=区域结束时间
            .and(x -> x.ge(Task::getStartTime, startTime).le(Task::getStartTime, endTime))
            // 或者 任务结束时间落在区域范围内,即任务结束时间>=区域开始时间且任务结束时间<=区域结束时间
            .or(x -> x.ge(Task::getEndTime, startTime).le(Task::getEndTime, endTime))
            // 或者 任务起止时间包含区域范围,即任务开始时间<=区域开始时间且任务结束时间>=区域结束时间
            .or(x -> x.le(Task::getStartTime, startTime).ge(Task::getEndTime, endTime))
    ;
    return this.list(queryWrapper);

}

此外,任务的开始时间和结束时间并非必填项,有些业务场景会只设置其中一项:

  • 场景1:任务耗时极短,我们只需要标记开始时间即可,无需设置结束时间,相当于起到备忘功能,即开始时间有值,结束时间为空
  • 场景2:任务耗时不确定,开始时间可以安排,结束时间无法计划,即开始时间有值,结束时间为空
  • 场景3:任务有截止时间,但尚未安排什么时间开始做,即开始时间为空,结束时间有值

以上3个场景对应两类情况,上面的后端处理逻辑仍能获取期望范围内的数据。

但是,FullCalendar组件这边发现存在问题。
对于场景1和2,开始时间有值,结束时间为空,会在视图中显示一条只有开始时间,没有结束时间的事件,正常。
对于场景3,开始时间为空,结束时间有值,该事件不会在视图中显示,就跟不存在一样。

思考了下,对于场景3,不适合由系统自动补全一个开始时间,例如参照结束时间,会跟初衷相违背。此时,由用户注意这点,设置一个相对宽泛的开始时间,比如周五截止的任务,设置周一为开始时间,或者干脆就设置截止时间同一天,但作为日程安排的制定人,对任务的情况是清楚的,也没影响到用户体验。

注:至于起止时间都为空,往往是一项尚未拆解的大任务大目标,或者作为任务的归类,没有显示在日历中的必要。

高级配置

调整单元格高度

默认情况下,单元格高度会自动扩展,如下图所示,留下不小的空白,既浪费空间又不美观。
image.png
解决方式就是为高度height属性指定值auto,如下:

// 高度自动调整
height: 'auto'

效果如下:
image.png

限制事件最大数量

默认情况下,日历组件不限制单个日历单元格中事件数量,多了会自动扩展高度,如下图所示:
image.png
这种方式个人认为挺不错的,一般情况下,不会有那么多事件把整体表格撑得很大。
为了防止极端情况,仍可以设置一个上限,比如6条或10条,超出的以更多显示。
插一句,日历组件自身的设计确实优秀,各种情况都考虑到了。

经过资料查阅与验证,有两个参数可以达到目的,一是dayMaxEvents,二是dayMaxEventRows。
设置后,多余的以“+2more”格式显示,效果如下:
image.png
两个参数有细微差别,如都设置为6,前者是6条事件,后者是5条(+2more也算1条)。

注:网上的资料很杂,经验证很多都是错的,例如需要设置eventLimit为true,或者直接给eventLimit设置具体的值,实测都是谬传,很可能是对原生的日历组件配置,而不是针对vue封装后的组件配置。

此外,+2more是没有经过汉化的,这里再补充一个参数moreLinkContent设置,来将其转换为中文:

// 限制事件最大数量
dayMaxEvents: 6,
// 事件数量超出时更多显示链接汉化
moreLinkContent: '+ 更多',

调整后效果如下:
image.png
点击后会自动调用内置的popover,显示完整的事件清单,效果如下:
image.png
注意,以上参数配置仅适用于月视图。

对于周视图和日视图,因为自身区域就很大,正常情况下根本就用不完,因此也不需要设置上限。
image.png

设置事件最小高度

默认情况,事件的最小高度是15像素,当事件较多时,会自动缩小字体类适配。
image.png
这种方式可以按时间规整排列,但字体大小不同,特别是字太小影响查看。
可以通过设置eventMinHeight属性来调整最小高度,如下:

// 视图个性化配置设置
views: {
  dayGridMonth: {},
  timeGridWeek: {},
  timeGridDay: {
    // 列表视图中事件最小高度
    eventMinHeight: 50
  },
  listMonth: {}
},

调整后效果如下:
image.png
字体大小一致了,不会过小,不过事件就不再规整了,需要调整下事件查看习惯,从上到下,从左到右,这种方式充分利用了屏幕,个人推荐该方式。

显示事件起止时间

在日历视图中,可以通过配置来调整事件是否显示开始时间和结束时间。
首先需要说明一个概念,FullCalendar有两种类型的事件,全天与非全天,通过allDay属性来控制。
对于全天事件,会统一放到视图(周视图与日视图)顶部区域,非全天事件,才会显示具体的起止时间。
image.png
对于月视图,全天事件,会在日单元格中顶部显示,且以蓝色背景标识,与非全天事件区分。
image.png
默认情况下,月视图会显示开始时间,周视图和日视图会显示起止时间,各个视图支持单独设置属性,如果把属性设置在根路径下,则会对所有视图生效。

  // 显示事件开始时间
  displayEventTime: true,
  // 显示事件结束时间
  displayEventEnd: true

效果如下,月视图也显示非全天事件的起止时间了。
image.png

通过全天属性优化显示

有些任务,我们需要安排一整天,或者好几天,亦或者不想具体安排某天的哪个时间点来做。
这时候起止时间就设置到天,没有到小时的粒度,默认情况下如下显示,占满整天,影响其他任务的展示和查看的直观性。
image.png
这种情况,我们可以使用allDay属性来做优化, 若起止时间均为00:00:00,则设置为allDay属性为true,这样任务就会显示在顶部“全天”区域。
代码如下:

 // 计算全天事件属性值
calculateAllDay(startTime, endTime) {
  let allDay = false
  // 若起止时间不为空且均为00:00:00,则设置为allDay属性为true
  if (
    startTime &&
    endTime &&
    startTime.substr(11, 8) === '00:00:00' &&
    endTime.substr(11, 8) === '00:00:00'
  ) {
    allDay = true
  }
  return allDay
}

调整后效果如下:
image.png

隐藏全天区域

在做日程管理时,全天区域位于视图顶部,是个非常不错的功能。
如果我们基于FullCalendar用于其他用途,例如显示本周的工时情况,则根本不需要显示全天区域。
image.png
该区域是默认显示的,可以设置allDaySlot为false关闭。

// 关闭全天区域显示
allDaySlot: false,

效果如下:
image.png
注:曾经尝试隐藏全天区域显示,推测属性是allDay,结果不生效……所以推测不一定适用。

开启视图间导航功能

我们在查看月视图时,如果对某一周或某一天的具体日程感兴趣,想进入查看,可以打开官方预置的一个开关,这样周次和日都会启用链接导航功能,点击可进入周视图和日视图。

// 开启视图间导航功能
navLinks: true

image.png
image.png
同理,周视图、日视图和列表视图,都会开启超链接,进行视图间的切换。
image.png
image.png
image.png
官方提供了预置操作,如果想改变默认的行为,跳转到自定义视图,则可以设置navLinkDayClick和navLinkWeekClick这两个事件。
官网文档:https://fullcalendar.io/docs/date-nav-links

开启当前时间线标识

一个小功能,配置nowIndicator为true,默认未开启。

// 显示当前时间线
nowIndicator: true

在周视图和日视图中会用红线标识当前时间,是一个挺实用的功能。
image.png
image.png

设置工作时间

FullCalendar预置了设置工作时间功能,对应属性是businessHours,默认未开启。这个功能与我当前想实现的需求关系不大,但在某些应用场景下还是很有用的,因此也了解一下,放在这作为备忘。功能可以不用,但是得知道有。
该属性比较灵活,设置为true会开启,但默认是周一到周五,9:00-17:00,即标准的朝九晚五。

businessHours:true

也可以设置为对象,以下是设置周一到周五,8:00-17:30为工作时间。

businessHours: {
  daysOfWeek: [1, 2, 3, 4, 5],
  startTime: '8:00',
  endTime: '17:30'
}

image.png
还可以设置为数组,进行任意组合,例如常见的将工作日(周一到周五)和节假日(周六周日)分别设置不同的时间段。

需要注意的是,经测试,工作时间只是用白色背景标识,非工作时间用灰色背景标识,非工作时间依旧可以选中,添加事件等操作,即只影响显示,不影响功能。

FullCalendar提供了设置可用时间段的配置,不是businessHours,而是接下来要说的。

设置可用时间段

默认情况下,日历组件在周视图和日视图中,会显示从0点到23点全天时间段,如下所示:
image.png
但对于大多数人而言,夜里休息时间段并不会安排任务或日程,特别是早晨七八点以前,这部分区域没用,但会占用大块的屏幕,从而操作时需要通过拖动垂直滚动条来查看全天的日程情况。
如何控制有效时间段区域呢?
前面我们介绍过一个属性,businessHours,设置工作时间,但测试发现该属性只会影响显示(灰色背景),不影响操作(仍可新增或拖动事件)。

实际应该使用的属性是如下两个:

// 开始时间段
slotMinTime: '08:00:00',
// 结束时间段
slotMaxTime: '22:00:00',

设置后效果如下:
image.png
从应用角度考虑,每个人的工作和作息情况不一样,有早八点上班的,也有早九点上班的,甚至还有夜班人员,统一设置可用时间段并不合适,因此可以作为配置项,由用户自行设定,然后在页面初始化时,读取用户自定义的设置值,赋值给FullCalendar的calendarOptions选项即可:

// 初始化
init() {
  this.applyCustomSetting()
},
// 应用自定义配置
applyCustomSetting() {
  this.$api.mconfig.meetConfig.getMyConfig().then((res) => {
    const config = res.data
    // 使用自定义配置
    if (!config.startTime) {
      this.calendarOptions.slotMinTime = '00:00'
    } else {
      this.calendarOptions.slotMinTime = config.startTime
    }
    if (!config.endTime) {
      this.calendarOptions.slotMaxTime = '24:00'
    } else {
      this.calendarOptions.slotMaxTime = config.endTime
    }
   
  })
}

设置事件颜色

应用通常需要根据事件属性进一步来设置不同颜色,区分事件的类型、状态、重要程度或优先级。
例如,高优先级使用红色、中优先级使用黄色、低优先级使用绿色 。

有三个属性来细粒度控制:
backgroundColor:背景颜色
borderColor:边框颜色
textColor:文本颜色

在加载数据时,根据属性设置颜色,源码如下:

 // 加载数据
    loadData() {
      this.$api.personaltask.task.listWithChildren().then((res) => {
        if (res.data) {
          const eventArray = res.data.map((item) => {
            // 若起止时间均为00:00:00,则设置为allDay属性为true
            let allDay = false
            if (
              item.startTime &&
              item.endTime &&
              item.startTime.substr(11, 8) === '00:00:00' &&
              item.endTime.substr(11, 8) === '00:00:00'
            ) {
              allDay = true
            }
            // 根据优先级设置不同的颜色
            let color = '#000000'
            switch (item.priority) {
              case 'HIGH':
                color = '#FF0000'
                break
              case 'MEDIUM':
                color = '#FFFF00'
                break
              case 'LOW':
                color = '#00FF00'
                break
            }
            return {
              id: item.id,
              title: item.name,
              start: item.startTime,
              end: item.endTime,
              allDay: allDay,
              status: item.status,
              textColor: color
            }
          })
          this.eventData = eventArray
          this.filteData()
        }
      })
    }

效果如下:
image.png
这里发现一个问题:颜色设置对全天事件所有视图生效;对非全天事件,周视图和日视图生效,月视图无效,怀疑是组件bug或者设计时没有考虑到这方面。

尝试了另一种方式,覆写事件内容展示的回调方法eventContent。

 eventContent(arg) {
    let event = arg.event
    // 根据优先级设置不同的颜色
    let color = '#000000'
    switch (event.extendedProps.priority) {
      case 'HIGH':
        color = '#FF0000'
        break
      case 'MEDIUM':
        color = '#FFFF00'
        break
      case 'LOW':
        color = '#00FF00'
        break
    }
    this.eventData.forEach((item) => {
      if (item.id === event.id) {
        item.textColor = color
        return
      }
    })
    return '标题'
  }

测试发现,还是仅对全天事件生效,进一步印证了前面推测,对于非全天事件,不受textColor属性控制。
image.png
该方式不仅没达到目的,还得完全自己输出事件的内容展示(上面统一固化为“标题”仅为了测试颜色效果),此路不通,放弃。

设置事件排序

同一单元格内多个事件,显示时谁先谁后,如何控制?
组件内置了事件排序控制,其属性为eventOrder,可接受多类型的值(String / Array / Function),默认为"start,-duration,allDay,title",代表优先按开始时间、持续时长、全天事件和标题排序。
其中duration前的减号,代表降序,无减号则代表升序。

官方预置的默认排序规则相对是最合理的选择了,此处不做调整,仅做备忘。
若日后需要按照优先级排序,可从该处着手探索解决方案。

设置时间片

这里的时间片说的是一个时间段,怎么理解呢?
默认情况下,FullCalendar组件左侧的纵轴标记了整点,每个小时内部又拆分为两段,即最小的时间片是半小时。
image.png
在新建事件或拖动事件时,会自动以时间片作为规划单位处理。
可以通过配置项slotDuration去调整,例如从30分钟调整为15分钟:

// 时间片
slotDuration: '00:15:00'

效果如下:
image.png可以看到,一个小时被按15分钟的颗粒度拆成了四份,并且时间坐标轴增加了半小时显示。
当然,也可以进一步细拆,将时间片设置为10分钟甚至5分钟。
以下是将其设置为5分钟的效果:
image.png
具体设置为多大,看需求,一般情况下30分钟、20分钟、15分钟应该够了,只有需要精细化管理时,才需要设置为10分钟、5分钟甚至1分钟。

设置时间坐标显示

该控制与上面的时间片设置密切相关,可以通过slotLabelInterval属性来控制时间坐标轴的坐标显示,默认情况下FullCalendar内部会自动计算,上一章节中的截图就是自动处理的结果。
如果我们将时间片设置为5分钟,坐标轴的标识会每15分钟显示一次,如下图:

进行如下设置:

// 时间片
slotDuration: '00:05:00',
// 时间坐标轴的坐标密度
slotLabelInterval: '00:30:00'

效果如下:
image.png
可以看到坐标以半小时为单位标记,而不再是自动处理后的15分钟了。

此外,还有个相关的属性,时间坐标轴的显示格式化slotLabelFormat,一块说下,详见注释:

// 时间坐标轴的显示格式化
slotLabelFormat: {
  // numeric直接显示数字,个位数前面不补零,如上午9点显示为9,2-digit个位数前面补零,如上午9点显示为09
  hour: '2-digit',
  // 规则同上
  minute: '2-digit',
  // 是否忽略分钟数为0,为true,9点会显示9时,为false,会显示9:00
  omitZeroMinute: false,
  // 为true,12小时,为false,24小时
  hour12: false,
  // 显示上午、下午,英文语种下会附加显示AM、PM,中文语种下不显示,推测需要附加对应翻译
  meridiem: 'short'
}

效果如下:
image.png

实现右键菜单

功能需求

在前面的基础上,我们进一步增加业务功能,使其用起来更方便。
具体来说,就是为事件(对应任务)增加右键菜单,能便捷的进行操作,如复制任务、删除任务、添加工时等。
先翻找官方文档,日历组件并没有预置右键菜单扩展,也没有相关说明,需要自行摸索实现。

实现思路1——使用插槽

使用eventContent插槽,自行用div包起来,然后在div上添加@contextmenu事件,如下所示:

  <div> 
    <FullCalendar :options="calendarOptions" ref="fullCalendar">
      <template #eventContent="arg">
        <div @contextmenu="contextmenu"> {{ arg.event.title }} </div>
      </template>
    </FullCalendar>
  </div>

 contextmenu(e) {
    e.preventDefault()
    console.log(e)
 }

上述方式可以显示事件的名称,但右键菜单的处理无效,浏览器的右键菜单还是会弹出来,推测组件又做了啥内部处理和封装导致的,contextmenu根本不会被调用。

在外侧放了一个div标签挂载了contextmenu做对比验证,发现能正常运行,基本确定是日历组件内部处理导致的。

  <div>
    <div @contextmenu="contextmenu"> 测试 </div>
    <FullCalendar :options="calendarOptions" ref="fullCalendar">
      <template #eventContent="arg">
        <div @contextmenu="contextmenu"> {{ arg.event.title }} </div>
      </template>
    </FullCalendar>
  </div>

因此,此路走不通。
此外,即使右键菜单能生效,该方案还存在一个问题,就是需要自己覆写日历组件的内容展现部分,意味着官方原先一些预置的属性和功能,如是否显示事件的起止时间、颜色和背景的控制等,将失去作用,因此,强烈不建议采用此思路。

实现思路2——使用回调

组件预置了一个事件加载完成后的回调事件,我们可以在这个环节添加一个右键监听,然后阻止浏览器默认右键菜单,并调用自己的右键菜单。

// 事件加载完成
eventDidMount(arg) {
  //添加右键菜单
  arg.el.addEventListener('contextmenu', (e) => {
    //阻止浏览器的默认右键菜单
    e.preventDefault()
    this.showEventContextMenu(e, arg.event.id)
  })
}

调用右键菜单如下:

// 显示事件右键菜单
showEventContextMenu(mouseEvent, eventId) {
  // 保存当前事件标识
  this.contextMenuEventId = eventId
  // 显示右键菜单
  this.$nextTick(() => {
    this.eventContextMenu.left = mouseEvent.clientX - 10
    const menuHeight = this.$refs.eventContextMenu.$el.clientHeight
    const areaHeight = document.documentElement.clientHeight

    if (mouseEvent.clientY + menuHeight > areaHeight) {
      // 当鼠标点击的y坐标加上菜单高度超出区域高度时
      this.eventContextMenu.top = mouseEvent.clientY - menuHeight + 25
    } else {
      this.eventContextMenu.top = mouseEvent.clientY - 25
    }
    // 显示菜单
    this.eventContextMenu.visible = true
  })
}

相应的右键菜单使用element-plus的菜单控件,如下:

<el-menu
      v-show="eventContextMenu.visible"
      ref="eventContextMenu"
      :style="{
        width: '120px',
        left: eventContextMenu.left + 'px',
        top: eventContextMenu.top + 'px',
        position: 'fixed',
        cursor: 'pointer',
        'z-index': 9999
      }"
      popper-append-to-body
      @mouseleave="eventContextMenu.visible = false"
      @select="eventContextMenuSelect"
    >
      <el-menu-item index="addLog">
        <el-icon>
          <CloseBold />
        </el-icon>
        <span>记录用时</span></el-menu-item
      >
      <el-menu-item index="copy">
        <el-icon>
          <CopyDocument />
        </el-icon>
        <span>复制任务</span></el-menu-item
      >
      <el-menu-item index="remove">
        <el-icon>
          <FolderRemove />
        </el-icon>
        <span>删除任务</span></el-menu-item
      >
    </el-menu>

这个过程中需要一些属性来保存和传递数据,如下:

// 当前右键菜单事件标识
contextMenuEventId: '',
// 事件右键菜单属性
eventContextMenu: {
  // 是否可见
  visible: false,
  // 左边距
  left: 0,
  // 上边距
  top: 0
}

效果如下:
image.png

事件处理

新增事件

功能需求:日历组件的各视图中点击单元格区域或拖放单元格区域,新增事件,根据点击或拖放区域自动设置任务的开始时间和结束时间。
默认单元格都是只读的,需要首先配置属性selectable,让其可选中,然后配置选中事件,如下:

// 是否可以选中日历格
selectable: true,
//选中日历格事件
select: this.selectCell

如官方所说,对于封装的vue组件,不再区分vue的属性props和事件event,都以键值对方式放在配置选项options中。
事件回调参数是1个对象,输出log看下大致的数据结构如下:
image.png
从需求出发,通过日历的方式来新增任务,主要是想拿到起止时间,从start和end两个属性里就可以获取到。

引入新增任务的页面,如下:

import AddPage from '../task/add.vue'

然后在日历组件的select事件中调用,传入起止时间。

//选中日历格事件
selectCell(arg) {
  // 转换时间格式
  const startTime = this.$dateFormatter.formatUTCTime(arg.start)
  const endTime = this.$dateFormatter.formatUTCTime(arg.end)
  // 调用新增任务
  this.$refs.addPage.init({ startTime, endTime })
}

拖放结束后,弹出对话框,新增任务,自动填充时间,效果如下:
image.png
保存后,调用addTask方法,如下所示:

<AddPage ref="addPage" @refresh="addTask" />

// 新增任务
addTask(task) {
  // 获取日历对象
  const fullCalendar = this.$refs.fullCalendar.calendar
  // 将任务数据转换为日历事件
  const event = this.convertTaskToEvent(task)
  // 调用api添加任务
  fullCalendar.addEvent(event)
}

// 任务数据转换为事件对象
convertTaskToEvent(task) {
  // 计算全天事件属性值
  const allDay = this.calculateAllDay(task.startTime, task.endTime)
  // 数据转换
  return {
    id: task.id,
    title: task.name,
    start: task.startTime,
    end: task.endTime,
    allDay: allDay,
    extendedProps: {
      status: task.status,
      plannedDuration: task.plannedDuration
    }
  }
},
 // 计算全天事件属性值
calculateAllDay(startTime, endTime) {     
  let allDay = false
   // 若起止时间不为空且均为00:00:00,则设置为allDay属性为true
  if (
    startTime &&
    endTime &&
    startTime.substr(11, 8) === '00:00:00' &&
    endTime.substr(11, 8) === '00:00:00'
  ) {
    allDay = true
  }
  return allDay
}  

其核心逻辑就是调用FullCalendar官方提供的api方法addEvent来实现事件的添加,效果如下:
image.png

修改事件

点击事件时,希望打开事件查看界面,如需调整也可以直接修改,这时候调用的就是任务修改页面了。
引入修改任务的页面,如下:

import ModifyPage from '../task/modify.vue'

设置事件点击的回调,在回调中调用修改任务的页面,如下:

// 事件点击
eventClick: this.showModifyForm


// 显示修改表单
showModifyForm(arg) {
  this.$refs.modifyPage.init(arg.event.id)
}

效果如下:
image.png
点击保存按钮后,调用修改任务方法。

对于修改事件,FullCalendar并未提供一个像新增事件addEvent类似的事件,而是提供了一组事件。

需要通过日历对象的getEventById方法,通过事件id拿到事件对象。
然后调用事件对象的以下方法:
设置非时间相关的属性,使用event.setProp( name, value ),比如事件的标题。
设置时间相关的属性,使用以下方法:
setStart
setEnd
setAllDay
设置扩展属性,使用event.setExtendedProp( name, value ),比如我们自定义的任务状态。

对于修改任务,综合运用上述方法,实现如下:

// 修改任务
modifyTask(task) {
  const fullCalendar = this.$refs.fullCalendar.calendar
  const event = fullCalendar.getEventById(task.id)
  event.setProp('title', task.name)
  event.setStart(task.startTime)
  event.setEnd(task.endTime)
  let allDay = this.calculateAllDay(task.startTime, task.endTime)
  event.setAllDay(allDay)
  event.setExtendedProp('status', task.status)
  event.setExtendedProp('plannedDuration', task.plannedDuration)
}

通过拖动调整起止时间

在各日历视图中,可以通过拖拽的方式,来快速调整起止时间。例如,在日视图中,某个会议延期,由8:30开始顺延到9:30。
日历组件的事件默认不能拖动,需要配置editable为true,如下:

// 是否可以编辑,影响拖动
editable: true

前端显示起止时间在变,但是刷新页面又会恢复原状,这是因为该拖动只是做了前端的工作,需要调用后端服务,来把数据更新入库。
查找日历组件的触发事件,对应着eventDrop。
配置事件及处理,如下:

//事件拖动结束
eventDrop: this.eventDrop
 // 拖动结束
  eventDrop(arg) {
    const task = arg.event
    // 转换时间格式
    const startTime = this.$dateFormatter.formatUTCTime(task.start)
    const endTime = this.$dateFormatter.formatUTCTime(task.end)
    this.$api.task.task.changeTime(task.id, startTime, endTime)
  }

通过缩放调整起止时间

除了可以通过拖动来变更起止时间外,FullCalendar还支持将将鼠标悬停边界进行缩放,也就是对应着起止时间范围的放大或缩小,可以将单日的事件横向拖动变成跨越多天,也可以将5小时的事件纵向拖动缩短为2小时。
查找事件,结果有三个:

  • eventResizeStart
  • eventResizeStop
  • eventResize

如业务需求上不需要细分是起始时间变化还是结束时间变化,因此只需要使用最后一个eventRize就行了。
配置事件如下:

 //缩放事件
 eventResize: this.eventResize

添加处理,由于缩放和拖动,都是调整起止时间,因此可以完全复用后端处理,做了重构,调用同一个方法,如下:

  // 拖动结束
  eventDrop(arg) {
    this.changeTime(arg)
  },
  // 缩放结束
  eventResize(arg) {
    this.changeTime(arg)
  },
  // 变更时间
  changeTime(arg) {
    const task = arg.event
    // 转换时间格式
    const startTime = this.$dateFormatter.formatUTCTime(task.start)
    const endTime = this.$dateFormatter.formatUTCTime(task.end)
    this.$api.task.task.changeTime(task.id, startTime, endTime)
  }

注意,进行了上述设置后,只有时间块的下边缘可以缩放,即只支持结束时间的缩放。
如需要开始时间也支持缩放,需要再设置一个开关eventResizableFromStart为true,如下:

// 允许开始时间缩放
eventResizableFromStart: true

删除事件

在日历视图中右键一个现有任务后,弹出菜单中可以选择“删除”,如下所示:
image.png
原处理逻辑如下:

// 事件右键菜单命令
eventContextMenuSelect(command) {
  const id = this.contextMenuEventId
  if (command === 'copy') {
    this.$api.personaltask.task.addSingleByCopy(id).then((res) => {
      this.$refs.modifyPage.init(res.data.id)
    })
  } else if (command === 'remove') {
    this.$confirm('此操作将移除任务, 是否继续?', '确认', {
      type: 'warning'
    })
      .then(() => {
        this.$api.personaltask.task.remove(id).then(() => {
          this.refresh()
        })
      })
      .catch(() => {
        this.$message.info('已取消')
      })
  } else if (command === 'addLog') {
    this.addLog(id)
  } else if (command === 'setCompleted') {
    this.setCompleted(id)
  } else if (command === 'setPending') {
    this.setPending(id)
  }
  // 隐藏右键菜单
  this.eventContextMenu.visible = false
}

若实现无刷新,则需要在调用后端删除操作完成后,调用FullCalendar的删除事件api,removeEvent,我们封装一个删除任务的方法如下:

// 删除任务
revmoveTask(taskId) {
  const fullCalendar = this.$refs.fullCalendar.calendar
  const event = fullCalendar.getEventById(taskId)
  event.remove()
}

复制事件

通过复制一个现有事件来快速新增事件,先调用的后端的新增操作,完成拷贝属性,返回给前端id,然后调用修改任务的表单,保存后最终调用的是FullCalendar的addEvent,来实现将通过复制新建的事件添加到日历中显示,实现如下:

引入modifyPage,将其组件命名修改为CopyPage,如下:

import CopyPage from '../task/modify.vue'

然后设定回调方法,如下:

<CopyPage ref="copyPage" @refresh="addTask" />

回调的依旧是新增任务的方法,跟前面新增页面保存的回调是一致的,如下:

 // 新增任务
addTask(task) {
  // 获取日历对象
  const fullCalendar = this.$refs.fullCalendar.calendar
  // 将任务数据转换为日历事件
  const event = this.convertTaskToEvent(task)
  // 调用api添加任务
  fullCalendar.addEvent(event)
}

解决拖动引发的结束时间清空问题

需求背景

在本系列的前面文章里,我们实现了拖放事件来实现调用后端服务变更任务的起止时间功能,例如某个会议原本起止时间是8:00-9:00,可以通过拖放操作将其变更为9:30-10:30。对于只有开始时间,无结束时间的任务,也可以正常拖放和更新起止时间。

在实际使用过程中,会产生将事件在全天区域与非全天区域之间拖动的需求,业务场景如下:

通常,我们会制定一个粗略一些的周计划,将一些任务作为本周要完成的事项,但往往不会严格限定要在哪一天的哪个时间段来做。这时候我们就可以使用FullCalendar组件的全天区域的功能来达到目的。将这些任务,暂存和显示在顶部的全天区域内,在当天早晨进行任务安排的时候,再将其拖放到具体的时间段。
此外,还有一种场景,因为变动,原本已经安排了起止时间的任务无法按计划执行,且没有明确的新的计划,需要放回到“全天”这个池子里以便将来另行安排。

以上两个业务场景,涉及到周视图和日视图中的两个操作,将顶部的全天区域事件拖放到下面正常区域,以及将下面区域中的事件,拖放到顶部全天区域。

经测试,将事件在全天区域与非全天区域之间拖动,会导致结束时间被清空,这是FullCalendar组件内置的处理逻辑。同时,区域内部拖动则表现正常,如周一的全天事件,拖放到周二,或周四的8点-9点的会议拖放到周五,结束时间不会清空。

解决方案

如何解决呢?
需要改造的是拖放结束事件回调,既然FullCalendar组件会将结束时间置为空,那我们就自行来设置一个结束时间。
添加个输出,确认下回调事件的参数数据:

 // 拖动结束
  eventDrop(arg) {
    console.log('1111111', arg.event.start, arg.event.end, arg.event.allDay)
    this.changeTime(arg)
  }

在两个区域内拖放,打印输出如下:
image.png
梳理逻辑如下:
通过allDay属性,可以确定拖放方向,若allDay为false,从全天到非全天,反之为从非全天到全天。
从全天到非全天,结束时间=开始时间+任务的计划时长。如计划时长也为空,计划时长默认设置为半小时。
从非全天到全天,结束时间=开始时间+1天。

上面逻辑看上去不复杂,但是,混合以下两个因素就麻烦了。
一是任务的结束时间可能本来就是空的。
二是拖动可能未发生跨区域的情况。

例如,一个周一的8点开始的会议,未设置结束时间,拖放到了周二8点,这时候拿到的allDay属性为false,按照上面梳理逻辑,会自动计算结束时间后赋值,这么做,系统的自动化处理一定程度相当于“改变”了用户原本的设置。

为了规避这点,调整实现逻辑,当计划时长为空时,不设置其默认值,将结束时间依然留空。

实现方案

最终实现逻辑代码如下:

// 拖动结束
eventDrop(arg) {     
  const allDay = arg.event.allDay
  const plannedDuration = arg.event.extendedProps.plannedDuration
  const start = arg.event.start
  let end = arg.event.end
  console.log('before', end)
  if (allDay) {
    // 拖动结束位于全天事件区域
    if (end == null) {
      // 拖动结束时间为空,则设置为开始时间+1天
      end = new Date(start.getTime() + 24 * 60 * 60 * 1000)
    }
  } else {
    // 拖动结束位于非全天事件区域
    if (end == null && plannedDuration != null) {
       // 拖动结束时间为空且计划时长不为空,则设置为开始时间+计划时长
      end = new Date(start.getTime() + plannedDuration * 60 * 60 * 1000)
    }
  }
  console.log('after', end)
  arg.event.setEnd(end)
  this.changeTime(arg)
}

以上增加了判断结束时间是否为空的逻辑,是过滤掉区域内移动的情况。

上面处理过程中需要用到任务的计划时长属性,这不是一个FullCalendar组件的事件对象自身属性,在加载数据环节,需要将该属性放到事件对象的扩展属性extendedProps中。
测试各种场景下的移动,区域内移动、区域间移动、结束时间有值、结束时间无值,均能正常处理。

并且当任务设置了计划时长的时候,从全天区域拖放到具体的开始时间,系统会自动将结束时间设置为开始时长和计划时长的和,更符合用户的期望。

可选方案

在摸索解决的过程中,发现了FullCalendar自带的一个属性allDayMaintainDuration。
其作用是确定一个事件的持续时间全天与非全天两个区域间拖动应该如何变化,该值默认为false,未开启状态。看说明这个参数跟我们期望实现的需求密切相关,于是动手验证了下。

当设置为false时(这是默认值),事件的持续时间将根据它被拖放到的部分进行重置。如果事件被拖放到全天区域,它的持续时间将重置为defaultAllDayEventDuration(可能是一天)。如果事件被拖放到非全天区域,它的持续时间将重置为defaultTimedEventDuration(可能是一个小时)。

官方说明是这样,但所谓的重置,实际只是前端显示看上去时一整体或一小时,仍会将事件对象中的结束时间属性清空掉,在进行调用后端服务持久化时导致结束时间丢失。

当设置为true时,事件在被拖放到全天区域或从全天区域拖出后,其持续时间将大致保持不变。这里所说的“大致”是因为如果一个事件的持续时间具有小时级的精度,它将被向下舍入到最近的整天。这意味着,如果一个事件原本跨越了数个小时但不是一整天,当它被拖放到全天区域时,它的持续时间将被调整为整个日历日。

测试了下效果,非全天事件拖放到全天区域,结束时间会自动变更为当天结束,不会置空;全天事件拖放到非全天区域,起止时间自动变成0点到24天,结束时间不会置空,但是一下占满当天所有时段,用户体验是比较差的,需要通过缩放操作调整起止时间。
image.png
该方式最大的优点就是简便,不需要进行复杂的二次开发,开启一个参数配置即可;最大的缺点就是用户体验较差,明显不如我们前面进行的二次扩展,结合了任务计划时长进行了自动化处理工作。

拖放外部元素至FullCalendar组件内

业务场景

先来说下需求。
我们通过日历组件来安排日程,主要是安排那些有相对明确的时间的事项。但实际还存在一些事项,比如临时的,刚想起或刚产生的,不紧急,甚至需要完成一些前置依赖。安排到哪个时间点尚不确定。
这种场景下,需要一个收集箱来暂存这些任务,用于提醒,防止遗忘而遗漏。
如上所述,将任务状态标记为“待安排”,并将其起止时间值为空。
安排计划时再根据实际情况把这些事项放到具体的计划中。

功能设计

对于上述需求,我们在当前日历组件之外,增加收集箱的功能,用于存放和显示“待安排”的任务,如下图所示:
image.png
这样用户不需要切换到别的菜单来新建待安排的事项,并且在安排计划时候也可以将待安排的事项一并考虑。
虽然用户可以在收集箱中的任务清单中点击某项任务,设置起止时间,通过刷新日历组件来显示,但是操作较繁琐。为了便于操作,我们来实现“拖放”操作,即将左侧收集箱中的任务,直接拖放到日历组件中,根据放置的位置自动设置起止时间,这样做更方便与直观。
即我们需要将一个FullCalendar日历组件外的元素,拖放到日历组件内部。

尝试方案

官网说明文档有相关描述,但是过于简略,需要摸索。
首先,需要配置属性,将拖放功能droppable开关打开:

// 启用拖动外部元素放置到日历
droppable: true

然后,定义拖放结束的回调事件drop,注意不是前面用过的eventDrop,如下:

//外部元素拖放到日历中
drop: this.drop

接下来,最关键的操作就是外部元素拖拽到日历组件内部了。
官网描述的使用draggable,说的很含糊,这个draggable到底指啥并不明确。
一开始,尝试使用自己熟悉的vuedraggable组件来实现拖拽功能,然后测试失败,drop回调事件触发不了,怀疑被vuedraggable自己截获和处理了。
然后,使用html5自身的draggable,同样发现无法触发回调事件。

解决方案

回过头细看官方说明,原来导入的是FullCalendar自身的Draggable组件,但是写法看上去又很奇怪,是JQuery模式的操作,如下:

document.addEventListener('DOMContentLoaded', function() {
  var Calendar = FullCalendar.Calendar;
  var Draggable = FullCalendar.Draggable;

  var containerEl = document.getElementById('external-events');
  var calendarEl = document.getElementById('calendar');
  var checkbox = document.getElementById('drop-remove');

  // initialize the external events
  // -----------------------------------------------------------------

  new Draggable(containerEl, {
    itemSelector: '.fc-event'
  });

  // initialize the calendar
  // -----------------------------------------------------------------

  var calendar = new Calendar(calendarEl, {
    headerToolbar: {
      left: 'prev,next today',
      center: 'title',
      right: 'dayGridMonth,timeGridWeek,timeGridDay'
    },
    editable: true,
    droppable: true, // this allows things to be dropped onto the calendar
    drop: function(info) {
      console.log(info)
      // is the "remove after drop" checkbox checked?
      if (checkbox.checked) {
        // if so, remove the element from the "Draggable Events" list
        info.draggedEl.parentNode.removeChild(info.draggedEl);
      }
    }
  });

  calendar.render();
});

尝试将其转化为vue的写法。
引入draggable组件:

import { Draggable } from '@fullcalendar/interaction'

使用div包裹,指定id为external-events,任务列表通过el-row附加v-for,指定class为dragElement,如下:

 <div id="external-events">
    <el-row
      v-for="element in taskList"
      :key="element.id"
      class="dragElement"           
    >
      <el-tag
        closable
        @close="remove(element.id)"
        @click="modify(element.id)"
        :title="element.name"
      >
        {{ element.name }}</el-tag
      >
    </el-row>
  </div>

初始化加载数据的操作里,调用Draggable,这时候,需要通过 document.getElementById(‘external-events’)获取外层的div元素,并且通过itemSelector: '.dragElement’来获取可拖拽的元素,如下:

 // 初始化
init() {
  this.loadData()
},
loadData() {
  this.$api.personaltask.task.listWithStatus('PENDING').then((res) => {
    if (res.data) {
      this.taskList = res.data

      let containerEl = document.getElementById('external-events')
      new Draggable(containerEl, {
        itemSelector: '.dragElement',
        eventData: function (eventEl) {             
          return {
            title: eventEl.innerText,
            duration: '00:30'
          }
        }
      })
    }
  })
}

完成上述操作后,可以实现任务从外部拖放到日历组件内部了,当然,仅限于前端。

接下来,就是调用后端服务,将被拖动的任务,根据放到日历组件中的位置,设置其起止时间,并变更其状态为“未开始”。

drop回调事件中,能拿到几个重要属性如下:
allDay:是否全天事件
date:拖放结束放置的位置所在的时间
draggedEl:被拖放的html元素
我们可以通过date获取开始时间,然后结合allDay来计算结束时间,如果是全天,开始时间+1天为结束时间;如果是非全天,开始时间+半小时(我们自行设置的默认持续时长)。
还有最关键的一点,是如何获取到被拖拽的任务的标识id,通过打印输出的方式,发现draggedEl元素里仅传递了任务标题,并没有传递id。
尝试了很多方式,最终是使用date-id这种定义属性,来放入任务标识:

 <div id="external-events">
  <el-row
    v-for="element in taskList"
    :key="element.id"
    class="dragElement"
    :data-id="element.id"
  >
    <el-tag
      closable
      @close="remove(element.id)"
      @click="modify(element.id)"
      :title="element.name"
    >
      {{ element.name }}</el-tag
    >
  </el-row>
</div>

然后在drop的回调方法中,通过arg.draggedEl.dataset.id的方式取出来,整个回调方法如下:

 drop(arg) {
  // 获取是否全天
  const allDay = arg.allDay
  // 获取开始时间
  const start = arg.date
  // 获取任务标识
  const id = arg.draggedEl.dataset.id
  let endTime = new Date(start)
  if (allDay) {
    //若为全天,结束时间为开始时间加1天
    endTime = this.$dateFormatter.formatUTCTime(new Date(start.getTime() + 24 * 60 * 60 * 1000))
  } else {
    // 非全天,结束时间在开始时间基础上加半小时
    endTime = this.$dateFormatter.formatUTCTime(new Date(start.getTime() + 30 * 60 * 1000))
  }
  const startTime = this.$dateFormatter.formatUTCTime(new Date(start.getTime()))
  // 调用安排工作接口
  this.$api.personaltask.task.assign(id, startTime, endTime).then(() => {
    this.refresh()
  })
}

最终,实现了将外部元素拖放到日历组件内部的功能。

重构为无刷新模式

前面我们增加了收集箱功能,用于存放待安排的任务,并实现了从收集箱拖放任务到日历的功能,如下图所示:image.png
使用的是FullCalendar的drop事件,最后调用的是刷新操作,如下:

退回收集

通过右键菜单,可以将某个暂不具备的执行条件的任务退回了收集箱,通过调用FullCalendar的删除任务的api,并调用收集箱的加载数据方法,来实现页面无刷新,如下:

// 设置待安排
setPending(id) {
  this.$api.personaltask.task.changeStatus(id, 'PENDING').then(() => {
    this.revmoveTask(id)
    this.reloadCollectionBox()
  })
}

// 删除任务
revmoveTask(taskId) {
  const fullCalendar = this.$refs.fullCalendar.calendar
  const event = fullCalendar.getEventById(taskId)
  event.remove()
}

// 刷新收集箱
reloadCollectionBox() {
  this.$refs.collectionBox.loadData()
}

通过颜色区分任务完成状态

对于日历中的事件,通过不同颜色来区分任务是否完成。

先前尝试过使用任务的优先级来控制事件显示不同的颜色,效果并不好,颜色多了比较花哨,既影响信息有效展现,也影响美观,如下图所示:

最终采用只使用背景色来区分,任务完成(包括已完成和已取消)两种状态显示为灰色背景,其他状态显示为醒目的蓝色背景,边框色与背景色一致,避免出现一个框影响美观,文字使用白色,最终效果图如下:
image.png
需要调整设计到两个地方,一是任务数据加载的时候,二是动态修改单条任务的时候。

首先实现一个公共方法,通过任务状态计算颜色,如下:

 // 计算事件颜色
calculateEventColor(status) {
  // 根据状态设置不同的颜色,默认蓝底白字
  let textColor = 'white'
  let backgroundColor = '#0d6efd'
  let borderColor = '#0d6efd'
  switch (status) {
    //已完成和已取消两种任务状态为灰底白字
    case 'COMPLETED':
    case 'CANCELED':
      backgroundColor = 'gray'
      borderColor = 'gray'
      break
  }
  return {
    backgroundColor: backgroundColor,
    borderColor: borderColor,
    textColor: textColor
  }
}

然后在任务数据转换为事件时调用,如下:

// 任务数据转换为事件对象
convertTaskToEvent(task) {
  // 计算全天事件属性值
  const allDay = this.calculateAllDay(task.startTime, task.endTime)

  // 根据状态设置不同的颜色
  const color = this.calculateEventColor(task.status)

  // 数据转换
  return {
    id: task.id,
    title: task.name,
    start: task.startTime,
    end: task.endTime,
    allDay: allDay,
    textColor: color.textColor,
    backgroundColor: color.backgroundColor,
    borderColor: color.borderColor,
    extendedProps: {
      status: task.status,
      plannedDuration: task.plannedDuration
    }
  }
}

还有就是动态修改单个任务的时候,需要通过setPro的API来实现,如下:

 // 修改任务
modifyTask(task) {
  const fullCalendar = this.$refs.fullCalendar.calendar
  const event = fullCalendar.getEventById(task.id)
  if (task.startTime && task.status != 'PENDING') {
    // 开始时间有值,且状态不是待安排,更新任务信息
    event.setProp('title', task.name)
    event.setStart(task.startTime)
    event.setEnd(task.endTime)
    let allDay = this.calculateAllDay(task.startTime, task.endTime)
    event.setAllDay(allDay)
    event.setExtendedProp('status', task.status)
    event.setExtendedProp('plannedDuration', task.plannedDuration)
    // 根据状态设置不同的颜色
    const color = this.calculateEventColor(task.status)
    event.setProp('textColor', color.textColor)
    event.setProp('backgroundColor', color.backgroundColor)
    event.setProp('borderColor', color.borderColor)
  } else {
    // 开始时间无值或状态为待安排
    // 从日历视图中移除任务
    event.remove(task.id)
    // 刷新收集箱列表
    this.reloadCollectionBox()
  }
}

解决新增事件重复问题

问题描述

使用过程中发现一个新问题,具体如下:
在周日历视图中,新增一个任务,保存,正常显示;
image.png
切换到月日历视图中,该任务会显示两次,如下所示:
image.png
并且再切换到周视图或日视图,该任务都会重复显示:
image.png

原因分析

依据前面的经验,切换视图时,若时间范围变大,FullCalendar将会自动调用后端服务,获取相应范围内的数据加载和显示,问题在于,该加载并没有处理同一事件的合并问题,

解决方案

尝试调用FullCalendar的render事件来刷新,无效。
查找官方文档,没找到清空事件的api。
然后查看addEvent方法时,有了新发现,该方法第二个参数可以指定数据源,相关描述如下:

source represents the Event Source you want to associate this event with. When the source is refetched, it will clear the dynamically added event from the internal cache before fetching. This optional parameter can be specified as any of the following:

  • an Event Source ID string
  • an Event Source Object
  • true, which signifies the first event source

大意是指定一个数据源,当重新获取(refetched)时,会将动态添加的事件从内部缓存中清空。

这也是为什么通过addEvent方法动态添加的事件,在切换视图的时候为什么会重复显示了。
但是我们给日历组件FullCalendar设置事件是指定event属性为方法,当切换视图时会自动调用,并没有使用事件源,也没有调用refetch方法,这种方式下能有用吗?

尝试调整新增任务方法,将addEvent方法多传一个参数,设置为true,如下:

 // 新增任务
addTask(task) {
  // 获取日历对象
  const fullCalendar = this.$refs.fullCalendar.calendar
  // 将任务数据转换为日历事件
  const event = this.convertTaskToEvent(task)
  // 调用api添加任务
  fullCalendar.addEvent(event, true)
}

测试发现,功能正常了,在周视图动态添加的任务,切换到月视图后不会重复显示两遍,问题解决。

应用系统

名称:遇见
地址:https://meet.popsoft.tech
说明:基于一二三应用开发平台和FullCalendar日历组件实现的面向个人的时间管理、任务管理系统,1分钟注册,完整功能,欢迎使用~

相关推荐

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-07-22 08:50:02       51 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-07-22 08:50:02       54 阅读
  3. 在Django里面运行非项目文件

    2024-07-22 08:50:02       44 阅读
  4. Python语言-面向对象

    2024-07-22 08:50:02       55 阅读

热门阅读

  1. 常用传感器误差补偿方法介绍

    2024-07-22 08:50:02       15 阅读
  2. ARM/Linux嵌入式面经(十七):美团校招面经

    2024-07-22 08:50:02       14 阅读
  3. 深度学习简介(框架)

    2024-07-22 08:50:02       15 阅读
  4. ChatGPT的工作记忆容量:一项实证研究

    2024-07-22 08:50:02       14 阅读
  5. AI学习指南机器学习篇-SOM的拓扑结构与参数调优

    2024-07-22 08:50:02       16 阅读
  6. 如何调整图像的窗宽窗位

    2024-07-22 08:50:02       15 阅读
  7. linux字符设备驱动+fops应用测试程序

    2024-07-22 08:50:02       14 阅读
  8. opencv—常用函数学习_“干货“_14

    2024-07-22 08:50:02       16 阅读
  9. 网络安全防线:黑龙江等级保护测评标准详解

    2024-07-22 08:50:02       15 阅读
  10. thinkphp8结合layui2.9 图片上传验证

    2024-07-22 08:50:02       14 阅读
  11. grub之loongarch架构调试

    2024-07-22 08:50:02       11 阅读
  12. 任务3 git基础知识(主要是pr的笔记)

    2024-07-22 08:50:02       18 阅读
  13. CUDA 在机器学习中的应用 - 直观而全面的解释

    2024-07-22 08:50:02       18 阅读
  14. ChatGPT:Spring Boot 怎么配置上下文路径?

    2024-07-22 08:50:02       15 阅读