首页 > 其他分享 >FullCalendar日历组件集成过程合订版

FullCalendar日历组件集成过程合订版

时间:2024-07-19 18:54:00浏览次数:15  
标签:00 task FullCalendar 日历 视图 item 时间 事件 合订

背景

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

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

问题清单

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

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

整体预览

月视图

支持中文,并且把周一放在第一天。
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
界面不怎么美观,默认显示语种是英文,右上角切换上一个月和下一个月按钮就是两个黑块。

注意:
Vue 中“属性”(通过 v-bind 或 :)与“事件”(通过 v-on 或 @)是不同的概念。 对于 FullCalendar 二次封装出来的Vue组件,属性和事件之间没有区别, 都是作为键值对传递到主选项对象options中。
接下来就是通过配置选项进行调整。

设置中文语种

第一件要做的事情,就是把界面调整为中文。
关于vue版本的说明,只有一页简要介绍https://fullcalendar.io/docs/vue,并没有各属性的详细说明,而其他部分说明是针对日历组件主体的,需要自己去摸索对应。
例如,语种的变更,官方文档是针对ES6的,跟vue 组件使用方式并不一样。

import { Calendar } from '@fullcalendar/core';
import esLocale from '@fullcalendar/core/locales/es';
...
let calendar = new Calendar(calendarEl, {
  locale: esLocale
});

参照上面,推测option中传入locale: 'zh-cn’的键值对就行了,但是是否需要类似引入
import esLocale from ‘@fullcalendar/core/locales/es’;并不确定。

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

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

设置周起始日

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

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

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

设置头部工具栏

接下来就来处理下右上角显示英文的问题。
通过查询资料,此部分归属于为工具条Toolbar,且属于头部工具条headerToolBar。
image.png
官方说明地址:https://fullcalendar.io/docs/headerToolbar

默认的布局就是左侧为当前年月,右侧为今天和上一个、下一个按钮,实际是可配置的,如下所示:

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

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

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

image.png
布局按照预期变化了,但是有个地方不正常,就是上一个、下一个、上一年、下一年的图标貌似没加载出来,并且还产生了换行,先一放,先设置按钮上的文字,如下所示:

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

显示符合预期,效果如下:
image.png

引入新插件

这里右侧区域,不仅用到了月视图,还用到周视图和日视图,这两个视图是同一个插件timegrid插件提供,需要安装。

pnpm install @fullcalendar/timegrid

并按照前面dayGird插件的方式引入和配置,如下所示:

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],

切换到周视图和日视图,显示正常,如下:
image.png
image.png

解决上一个、下一个按钮不显示问题

上一个、下一个没有显示的悬案破了,通过设置按钮解决,官方文档里真没有,靠摸索。

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

增加周次显示

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

// 周次
weekNumbers: true,

image.png

增加列表插件

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

pnpm install @fullcalendar/list

引用,配置插件,然后头部工具栏增加list视图,同时按钮文本增加列表,配置如下:
image.png
效果如下:
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'

image.png
刷新页面,效果终于出来了:
image.png

更改all-day 显示文本

在周视图和日视图顶部有个全天区域,显示为英文 all-day
image.png
通过以下属性调整:

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

新增事件

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

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

如官方所说,对于封装的vue组件,不再区分vue的属性props和事件event,都以键值对方式放在配置选项options中。
事件回调参数是1个对象,输出log看下大致的数据结构如下:
image.png
注:这里我们集成日历组件,是为了做日程、任务管理,因此任务与日历组件中的事件概念等同。
从需求出发,通过日历的方式来新增任务,主要是想拿到起止时间,从start和end两个属性里就可以获取到,另外,allDay属性和view对象中的type可能会用到。

引入新增和修改任务的页面(修改任务后面会用到,一块引入进来),然后在日历组件的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
搞定了事件添加,接下来的重点就是事件的展示以及修改了。

加载事件

调用后端服务获取任务清单简单,拿到数据后,需要转换为日历组件需要的数据结构。
日历组件称之为事件event,属性参见官网介绍https://fullcalendar.io/docs/event-parsing
经分析,核心属性是三个,标题、开始时间和结束时间,此外,数据标识id组件也很贴心的预置了,在点击事件时通过该属性调用后端服务查询数据非常方便。
最后,需要将转换后的数据赋值给日历组件配置选项的events属性。

通过下面方法来完成数据加载、数据转换和属性赋值。

 // 加载数据
loadData() {
  this.$api.task.task.listWithChildren().then((res) => {
    this.taskList = res.data
    if (res.data) {
      const eventArray = res.data.map((item) => {
        return {
          id: item.id,
          title: item.name,
          start: item.startTime,
          end: item.endTime
        }
      })
      this.calendarOptions.events = eventArray
    }
  })
}

刷新页面,效果出来了
image.png

修改事件

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

// 事件点击
eventClick: this.showModifyForm


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

效果如下:
image.png

通过拖动调整起止时间

在各日历视图中,希望通过拖拽的方式,来快速调整起止时间。例如,在日视图中,某个会议延期,由8:30开始顺延到9:30。
日历组件默认事件不可编辑,间接影响到不能拖动,首先需要变更配置。

// 是否可以编辑,影响拖动
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)
  }

通过缩放调整起止时间

上面测试通过拖动来变更起止时间的过程中,发现将鼠标悬停边界,还支持缩放,也就是对应着起止时间范围的放大或缩小,可以将单日的事件横向拖动变成跨越多天,也可以将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)
  }

限制事件最大数量

默认情况下,日历组件不限制单个日历单元格中事件数量,多了会自动扩展高度,如下图所示:
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

调整单元格高度

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

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

效果如下:
image.png

月视图中显示事件时间开关

默认情况下,会在事件标题前方显示该事件的开始时间
image.png
如不想要,可以用下面这种方式来取消显示。

 // 视图的一些基本设置
views: {  
  dayGridMonth: {
    //是否显示时间
    displayEventTime: false
  },
  timeGridWeek: {},
  timeGridDay: {},
  listMonth: {}
},

个人认为显示时间还是不错的功能,所以暂时保留,该配置放这里做个备忘。

设置全天属性优化显示

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

 // 加载数据
    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
            }
            return {
              id: item.id,
              title: item.name,
              start: item.startTime,
              end: item.endTime,
              allDay: allDay
            }
          })
          this.calendarOptions.events = eventArray
        }
      })
    },

调整后效果如下:
image.png

切换显示全部与未完成

任务的状态有多个,待安排、未开始、进行中、已完成、已超期、已取消、已挂起,对于已完成、已取消这两类,通常会视为已结束,并不需要过多关注,如一直显示在列表中,则会一定程度上影响未结束的任务展现。
我们在头部工具栏添加一个自定义按钮,来控制是否只显示未结束的任务。
添加自定义按钮:

customButtons: {
  changeShowScopeButton: {
      text: '显示全部',
      click: this.changeShowScope
    }
}

在头部工具栏配置自定义按钮

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

定义变量和方法

   // 显示所有事件
  showAllFlag: false,
  // 事件数据
  eventData: []



  // 变更显示范围
  changeShowScope() {
    this.showAllFlag = !this.showAllFlag
    this.filteData()
  },
  // 筛选数据
  filteData() {
    if (this.showAllFlag) {
      this.calendarOptions.customButtons.changeShowScopeButton.text = '显示未结束'
      this.calendarOptions.events = this.eventData
    } else {
      this.calendarOptions.customButtons.changeShowScopeButton.text = '显示全部'
      this.calendarOptions.events = this.eventData.filter((item) => {
        return (
          item.status === 'IN_PROGRESS' ||
          item.status === 'TO_DO' ||
          item.status === 'EXPIRED' ||
          item.status === 'PENDING' ||
          item.status === 'PAUSED'
        )
      })
    }
  },
  // 加载数据
  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
          }
          return {
            id: item.id,
            title: item.name,
            start: item.startTime,
            end: item.endTime,
            allDay: allDay,
            status: item.status
          }
        })
        this.eventData = eventArray
        this.filteData()
      }
    })
  }     

默认仅显示未结束的任务,点击按钮可切换至显示全部任务
image.png

去除上一年与下一年按钮

从当前需求出发,任务查看都是当前附近,并不需要上一年与下一年这个大跨度,因此配置头部工具栏,去除上一年与下一年这两个按钮的显示,保留上一个与下一个两个按钮。

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

image.png

开启视图间导航功能

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

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

image.png
image.png
同理,周视图、日视图和列表视图,都会开启超链接,进行视图间的切换。
image.png
image.png
image.png

官方提供了预置操作,如果想改变默认的行为,跳转到自定义视图,则可以设置navLinkDayClick和navLinkWeekClick这两个事件。
官网文档:https://fullcalendar.io/docs/date-nav-links

配置周次

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

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

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

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

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

效果如下:
image.png

开启当前时间线标识

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

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

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

设置工作时间

组件预置了设置工作时间功能,对应属性是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
还可以设置为数组,进行任意组合,例如常见的将工作日(周一到周五)和节假日(周六周日)分别设置不同的时间段。

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

显示事件起止时间

前面我们提过设置属性displayEventTime,可以控制月视图中是否显示事件开始时间,今天来详细说说关于事件起止时间那些配置。
首先得明确,对于事件,组件有个概念是全天与非全天,通过allDay属性来控制。
对于全天事件,会统一放到视图(周视图与日视图)顶部区域,非全天事件,才会显示具体的起止时间。
image.png
对于月视图,全天事件,会以在日单元格中顶部显示,且以蓝色背景标识,与非全天事件区分。
image.png
默认情况下,月视图会显示开始时间,周视图和日视图会显示起止时间,各个视图支持单独设置属性,如果把属性设置在根路径下,则会对所有视图生效。

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

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

设置事件颜色

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

有三个属性来细粒度控制:
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前的减号,代表降序,无减号则代表升序。

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

设置事件最小高度

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

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

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

按需加载事件数据

前面我们调用后端服务,一次性把所有数据都传回了前端。系统刚开始使用,数据量比较小,系统会流畅运行。但随着时间的推移,数据越来越多,性能上可能存在问题,因此需要按需加载数据。
因官方文档过于简略,这地方花了很长时间摸索,分成三篇详细说说。

功能需求

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

方案实现

基于该方案,进行调整如下:
首先,为了避免在方法调用时传输参数,在vue的data段中新增几个变量用于保存起止时间和回调方法(注意:不在日历组件的选项option对象内)。

// 开始时间
startTime: '',
// 结束时间
endTime: '',
// 回调方法
successCallback: null

其次,当回调时,将起止时间和回调方法缓存到上面新加的变量,然后调用后端服务来获取数据。

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

再次,获取数据的服务改造,传入起止时间:

// 加载数据
loadData() {
  this.$api.personaltask.task.listWithScope(this.startTime, this.endTime).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
        }
        return {
          id: item.id,
          title: item.name,
          start: item.startTime,
          end: item.endTime,
          allDay: allDay,
          status: item.status,
          extendedProps: {
            priority: item.priority
          }
        }
      })
      this.eventData = eventArray
      this.filteData()
    }
  })
}

最后,过滤数据(用于显示全部和仅显示未完成)方法中使用回调方法处理数据。

// 筛选数据
filteData() {
  if (this.showAllFlag) {
    this.calendarOptions.customButtons.changeShowScopeButton.text = '显示未结束'
    this.successCallback(this.eventData)
  } else {
    this.calendarOptions.customButtons.changeShowScopeButton.text = '显示全部'
    const filtedData = this.eventData.filter((item) => {
      return (
        item.status === 'IN_PROGRESS' ||
        item.status === 'TO_DO' ||
        item.status === 'EXPIRED' ||
        item.status === 'PENDING' ||
        item.status === 'PAUSED'
      )
    })
    this.successCallback(filtedData)
  }
}

完成上述所有操作后,进行功能测试,数据可以正常加载和显示,点击头部内置按钮可以按需调用后端服务,获取数据并显示。

新的问题

上面我们实现了按需加载,详细测试时发现,自己添加的自定义按钮,控制隐藏或显示已完成的任务,不再有效……经分析和推测,问题很可能出在回调方法中的第二个参数successCallback,虽然我们做了缓存,但是高度怀疑这个方法只能使用一次,在已有数据的情况下,调用第二次,存在内部逻辑处理……
针对推测做了一个验证,源码如下:

//代码段1:传入未完成的任务
const filtedData = this.eventData.filter((item) => {
  return (
    item.status === 'IN_PROGRESS' ||
    item.status === 'TO_DO' ||
    item.status === 'EXPIRED' ||
    item.status === 'PENDING' ||
    item.status === 'PAUSED'
  )
})
this.successCallback(filtedData)

//代码段2:传入所有任务    
this.successCallback(this.eventData)

以上两段代码,次序对调,都是仅最前面的生效,并不是数据合并,实锤了回调方法successCallback只生效一次的推测。

把successCallback控制台打印了下,如下:

ƒ (res2) {
    if (!isResolved) {
      isResolved = true;
      normalizedSuccessCallback(res2);
    }
  }

其实不是successCallback自身变了,而是内部处理逻辑,有个isResolved状态位,一旦调用过一次后,该状态位就变了,第二次执行时状态位判断失败直接跳过处理了。

这么个机制,即使把数据过滤功能挪到后端进行,也照样无法解决问题。

尝试方案

日历组件内部是个黑盒,根据当前的现象和推测,目前最好的方式,就是重新触发下回调。
验证过一下几种解决方式,最终都没走通。
方式1:通过调用api来切换视图,实测无效,events属性对应的回调方法不会触发。

// 变更显示范围
changeShowScope() {
  this.showAllFlag = !this.showAllFlag
  // 触发回调
  const fullCalendar = this.$refs.fullCalendar.calendar
  const view = fullCalendar.view
  fullCalendar.changeView(view.type)
}

方式2:查阅文档发现有个做数据转换的方法,添加eventDataTransform事件回调,测试发现只触发一次。
方式3:官方还有个viewDidMount 回调方法,看文档说明也是在dom元素加载后触发一次,数据变化不会再次触发,也没戏。

以上方式统统无效。

解决方案

对于该问题,官方组件内置或扩展不太给力。考虑到我们自己添加的显示全部与未完成的数据过滤功能,实际使用过程中不会频繁操作,因此在点击切换按钮时,通过刷新当前tab页来实现数据刷新功能。
思路简单,实现起来有比较多的细节需要考虑。
当前用户的两个选择项,是显示全部任务还是仅显示未完成任务,以及浏览的视图类型,需要通过查询参数来缓存。

在页面mounted的时候,执行初始化,从query参数中获取和赋值,并调用日历组件的api来切换视图。

 mounted() {
    this.init()
  },
  methods: {
    // 初始化
    init() {
      this.calendarApi = this.$refs.fullCalendar.getApi()
      // 处理是否显示全部
      if (this.$route.query.showAllFlag != undefined) {
        //此处注意,query参数是字符串类型,直接赋值给showAllFlag会令其类型变化,使用非运算符!会一直为true
        this.showAllFlag = this.$route.query.showAllFlag == 'true' ? true : false
      }
      // 默认设置为日视图
      let viewType = this.calendarOptions.initialView
      // query参数中取值
      if (this.$route.query.viewType) {
        viewType = this.$route.query.viewType
      }
      // 调用日历组件api实现视图切换
      const fullCalendar = this.$refs.fullCalendar.calendar
      fullCalendar.changeView(viewType)
    },

配合前面说过的,将日历组件的事件源设置为函数方法,在该方法中监听视图变化获取数据范围变化。

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

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

变更显示范围时,调用刷新,这里的刷新是调用了前端框架的tab页刷新,传入了query参数

// 变更显示范围
changeShowScope() {
  this.showAllFlag = !this.showAllFlag
  this.refresh()
},
// 刷新
refresh() {
  const fullCalendar = this.$refs.fullCalendar.calendar
  let query = this.$route.query

  query = Object.assign(query, {
    viewType: fullCalendar.view.type,
    showAllFlag: this.showAllFlag
  })
  refreshSelectedTagWithQuery(query)
}

进行功能测试,切换视图和变更数据显示全部还是未完成,前端显示正常。

查看后端情况情况,发现还有个小瑕疵,就是页面加载的时候,实际会触发两次调用后端服务请求,推测一次是来源于组件自身初始化加载,另一次是我们手工调用的api方法来切换视图。只有在切换显示范围时才会触发,影响很小,先搁置下,看看后面是否有更好的优化方式。

后端处理

前面实现了功能,不过说的主要的前端的事。
后端取数也比较重要,在这补充说明下。
后端取数逻辑需要考虑周全,涉及到四个时间的比较。
任务开始时间
任务结束时间
查询范围开始时间(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,不适合由系统自动补全一个开始时间,例如参照结束时间,会跟初衷相违背。此时,由用户注意这点,设置一个相对宽泛的开始时间,比如周五截止的任务,设置周一为开始时间,或者干脆就设置截止时间同一天,但作为日程安排的制定人,对任务的情况是清楚的,也没影响到用户体验。

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

BUG解决

回顾下前文,我们自定义扩展了显示全部任务还是只显示未结束的任务,因FullCalendar自身组件功能限制,切换时,通过刷新当前页面来实现。在刷新操作处理中,调用了tab页刷新,将当前用户操作的视图类型和是否显示全部的标识位以query参数的方式进行传递。

// 刷新
refresh() {
  const fullCalendar = this.$refs.fullCalendar.calendar
  let query = this.$route.query
  query = Object.assign(query, {
    viewType: fullCalendar.view.type,
    showAllFlag: this.showAllFlag
  })
  refreshSelectedTagWithQuery(query)
}

在页面初始化时,从query参数中获取到视图类型,调用FullCalendar组件的内置changeView方法来切换视图。

    // 初始化
    init() {
      this.calendarApi = this.$refs.fullCalendar.getApi()
      // 处理是否显示全部
      if (this.$route.query.showAllFlag != undefined) {
        //此处注意,query参数是字符串类型,直接赋值给showAllFlag会令其类型变化,使用非运算符!会一直为true
        this.showAllFlag = this.$route.query.showAllFlag == 'true' ? true : false
      }
      // 默认设置视图类型
      let viewType = this.calendarOptions.initialView
      // query参数中取值
      if (this.$route.query.viewType) {
        viewType = this.$route.query.viewType
      }
      // 调用日历组件api实现视图切换
      const fullCalendar = this.$refs.fullCalendar.calendar
      fullCalendar.changeView(viewType)
    }

当时测试时查看后端情况,发现还有个小瑕疵,就是页面加载的时候,实际会触发两次调用后端服务请求,推测一次是来源于组件自身初始化加载,另一次是我们手工调用的api方法来切换视图。当时觉得只有在切换显示范围时才会触发,影响很小,先搁置了。
在使用过程中,这两次加载产生了严重的问题,表现为加载或刷新页面时,会出现事件不显示的情况。经深入分析和排查,问题就出在两次加载上。

举例说明,假设FullCalendar的默认视图initialView属性设置为日视图timeGridDay。
用户第一次访问页面,FullCalendar组件加载时会触发一次视图,然后我们又调用了一次切换视图,在加载事件数据的方法中打印,查看控制台和后端请求,确实调用了两次。

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

这一点我们可以优化,即判断当前视图与要切换的目标视图是否同一个,相同则不再调用。

 // 初始化
init() {
  this.calendarApi = this.$refs.fullCalendar.getApi()
  // 处理是否显示全部
  if (this.$route.query.showAllFlag != undefined) {
    //此处注意,query参数是字符串类型,直接赋值给showAllFlag会令其类型变化,使用非运算符!会一直为true
    this.showAllFlag = this.$route.query.showAllFlag == 'true' ? true : false
  }
  // 默认设置视图类型
  let viewType = this.calendarOptions.initialView
  // query参数中取值
  if (this.$route.query.viewType) {
    viewType = this.$route.query.viewType
  }
  // 当前视图与要切换的目标视图不是同一个时,调用日历组件api实现视图切换
  if (viewType != this.calendarOptions.initialView) {
    const fullCalendar = this.$refs.fullCalendar.calendar
    fullCalendar.changeView(viewType)
  }
}

调整后查看控制台,只输出一次了。
在完成页面首次访问后,用户点击顶部右侧按钮“月”、“周”、“列表”切换视图,都能正常显示。
当用户切换到周视图后,点击查看范围切换按钮时,这时候实际进行了页面重新加载,查看后端调用了两次数据查询服务。因为初始化视图是日视图,最后要展现的是周视图。一次来源于FullCalendar的初始化,一次来源于我们手工调用api方法。这时候发生的问题就在于,这两次数据绑定,看上去仍会只会生效一次,有时候绑定的是单天的数据,有时候绑定的是一周的数据,这样会导致在周视图下,因为加载的数据是单天的,周视图中其余六天的数据没有显示,就跟“丢失”一样。
给调用切换视图的api设置了延迟3秒执行,如下所示:

setTimeout(() => {
  const fullCalendar = this.$refs.fullCalendar.calendar
  fullCalendar.changeView(viewType)
}, 3000)

然后测试,发现数据绑定和加载正常了,只是3秒的延迟对用户体验很大。
基于上述验证思考,推测问题的根源我们使用了“全局”变量successCallback缓存了回调方法,两次视图加载执行时间非常接近,该变量被错误的赋值。

动手尝试,去除全局变量successCallback(data根目录下变量),将加载事件数据中的successCallback的值通过方法参数传递,如下:

    // 加载事件数据
    loadEvent(fetchInfo, successCallback, failureCallback) {
      console.log('loadEvent')
      this.startTime = this.$dateFormatter.formatUTCTime(fetchInfo.start)
      this.endTime = this.$dateFormatter.formatUTCTime(fetchInfo.end)
      this.loadData(successCallback)
    },
    // 加载数据
    loadData(successCallback) {
      this.$api.personaltask.task.listWithScope(this.startTime, this.endTime).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
            }
            return {
              id: item.id,
              title: item.name,
              start: item.startTime,
              end: item.endTime,
              allDay: allDay,
              status: item.status,
              extendedProps: {
                priority: item.priority,
                plannedDuration: item.plannedDuration
              }
            }
          })
          this.eventData = eventArray
          this.filteData(successCallback)
        }
      })
    },
    // 筛选数据
    filteData(successCallback) {
      if (this.showAllFlag == true) {
        this.calendarOptions.customButtons.changeShowScopeButton.text = '显示未结束'
        successCallback(this.eventData)
      } else {
        this.calendarOptions.customButtons.changeShowScopeButton.text = '显示全部'
        const filtedData = this.eventData.filter((item) => {
          return (
            item.status === 'IN_PROGRESS' ||
            item.status === 'TO_DO' ||
            item.status === 'EXPIRED' ||
            item.status === 'PENDING' ||
            item.status === 'PAUSED'
          )
        })
        successCallback(filtedData)
      }
    },

测试结果终于正常了。

增加右键菜单

功能需求

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

实现思路——使用插槽

使用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>

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

实现思路——使用回调

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

// 事件加载完成
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

全天与非全天区域拖动引发的结束时间清空问题

需求背景

在本系列的前面文章里,我们实现了拖放事件来实现调用后端服务变更任务的起止时间功能,例如某个会议原本起止时间是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中。

// 加载数据
loadData() {
  this.$api.personaltask.task.listWithScope(this.startTime, this.endTime).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
        }
        return {
          id: item.id,
          title: item.name,
          start: item.startTime,
          end: item.endTime,
          allDay: allDay,
          status: item.status,
          extendedProps: {
            priority: item.priority,
            plannedDuration: item.plannedDuration
          }
        }
      })
      this.eventData = eventArray
      this.filteData()
    }
  })
}

测试各种场景下的移动,区域内移动、区域间移动、结束时间有值、结束时间无值,均能正常处理。

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

可选方案

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

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

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

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

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

保持当前视图范围不变

当我们在月底使用日历组件制定下个月计划和日程,或者安排下周的工作,新增或修改事件后,后端数据持久化后通过刷新页面的方式来让日历组件上的数据更新。这样存在的问题在于一旦刷新页面,则日历组件会“跳”回到当天日期。
例如,制定6月份的计划,刚添加了1条数据,然后保存,视图又跳回了5月份,还需要手工再切换,明显不合理。

FullCalendar组件的切换视图的方法changeView,可以传入第二个参数,单个时间或者起止范围。

在前面的实现方式中,我们通过query参数保持当前的视图类型以及自定义的显示范围(全部任务/进行中任务),采用该方式,把当期视图的显示时间范围也通过query参数来传递,在刷新方法中处理:

    // 刷新
    refresh() {
      const fullCalendar = this.$refs.fullCalendar.calendar
      // console.log(fullCalendar.view)
      let query = this.$route.query
      query = Object.assign(query, {
        viewType: fullCalendar.view.type,
        showAllFlag: this.showAllFlag,
        start: this.$dateFormatter.formatUTCDate(fullCalendar.view.activeStart),
        end: this.$dateFormatter.formatUTCDate(fullCalendar.view.activeEnd)
      })
      refreshSelectedTagWithQuery(query)
    }

然后在页面初始化时读取query参数,调用切换视图方法:

    // 初始化
    init() {
      this.calendarApi = this.$refs.fullCalendar.getApi()
      // 处理是否显示全部
      if (this.$route.query.showAllFlag != undefined) {
        //此处注意,query参数是字符串类型,直接赋值给showAllFlag会令其类型变化,使用非运算符!会一直为true
        this.showAllFlag = this.$route.query.showAllFlag == 'true' ? true : false
      }
      // 默认设置视图类型
      let viewType = this.calendarOptions.initialView
      // query参数中取值
      if (this.$route.query.viewType) {
        viewType = this.$route.query.viewType
      }
      const fullCalendar = this.$refs.fullCalendar.calendar
      fullCalendar.changeView(viewType, {
        start: this.$route.query.start,
        end: this.$route.query.end
      })
    }

然后测试发现无效,依然显示的是当前时间。

然后手工写死测试,视图类型为日视图,传入单天,有效。

fullCalendar.changeView('timeGridDay', '2024-06-10')

视图类型为周,传入时间范围,无效!

 fullCalendar.changeView('timeGridWeek', { start: '2024-06-10', end: '2024-06-17' })

这就很无语了……
翻看了FullCalendar源码,看上去也没有问题:

changeView(viewType, dateOrRange) {
    this.batchRendering(() => {
        this.unselect();
        if (dateOrRange) {
            if (dateOrRange.start && dateOrRange.end) { // a range
                this.dispatch({
                    type: 'CHANGE_VIEW_TYPE',
                    viewType,
                });
                this.dispatch({
                    type: 'SET_OPTION',
                    optionName: 'visibleRange',
                    rawOptionValue: dateOrRange,
                });
            }
            else {
                let { dateEnv } = this.getCurrentData();
                this.dispatch({
                    type: 'CHANGE_VIEW_TYPE',
                    viewType,
                    dateMarker: dateEnv.createMarker(dateOrRange),
                });
            }
        }
        else {
            this.dispatch({
                type: 'CHANGE_VIEW_TYPE',
                viewType,
            });
        }
    });
}

停下来思考,推测日历展示的范围,不仅仅跟时间范围有关系,还跟目标时间有关系。
尝试调用内置方法gotoDate,源码调整如下:

  // 初始化
  init() {
    this.calendarApi = this.$refs.fullCalendar.getApi()
    // 处理是否显示全部
    if (this.$route.query.showAllFlag != undefined) {
      //此处注意,query参数是字符串类型,直接赋值给showAllFlag会令其类型变化,使用非运算符!会一直为true
      this.showAllFlag = this.$route.query.showAllFlag == 'true' ? true : false
    }
    // 默认设置视图类型
    let viewType = this.calendarOptions.initialView
    // query参数中取值
    if (this.$route.query.viewType) {
      viewType = this.$route.query.viewType
    }
    const fullCalendar = this.$refs.fullCalendar.calendar
    if (this.$route.query.start && this.$route.query.end) {
      fullCalendar.changeView(viewType, {
        start: this.$route.query.start,
        end: this.$route.query.end
      })
      // 取起止范围相差天数
      const dayCount = this.getDaysDifference(this.$route.query.start, this.$route.query.end)
      // 开始时间加上时间范围差值的一半,即取时间中间位置
      const targetDay = new Date(
        new Date(this.$route.query.start).getTime() + (dayCount / 2) * 24 * 60 * 60 * 1000
      )
      // 导航到指定日期
      fullCalendar.gotoDate(this.$dateFormatter.formatUTCDate(targetDay))
    } else {
      fullCalendar.changeView(viewType)
    }
  }

实现的关键逻辑就是获取到页面刷新前视图显示的起止时间范围,然后取中间的时间值,调用api跳转到该时间。
对于日视图和周视图,跳转时间直接取开始时间也可以正常运行,但是月视图不行,因为默认显示范围大于一个月,当前月以及上个月底的几天和下个月的前几天都会显示。如果也直接取开始时间作为跳转时间,则还是会出现在月底制定下个月计划时,页面刷新跳回到本月的情况。

通过以上方法,前端功能正常了,监控后端服务,调用gotoDate时,又触发了一次加载数据,也就是刷新页面,需要加载三次数据……组件自己初始化触发一次,调用切换视图触发一次,调用跳转到时间再触发一次。
这么做确实不优雅,相当于曲线救国,不清楚是组件自身问题或限制,只能这么干,还是有更好的实现方案,后续解决了再更新下本文。

拖放日历组件外元素至日志组件内

业务场景

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

功能设计

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

尝试方案

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

// 启用拖动外部元素放置到日历
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>
      </el-card>

初始化加载数据的操作里,调用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()
  })
}

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

隐藏全天区域

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

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

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

设置可用时间段

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

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

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

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

// 初始化
init() {
  const startValidTime= '08:00:00'//此处为模拟,实际需读取用户自定义配置获取
  this.calendarOptions.slotMinTime =startValidTime
  ……
}

设置时间片

这里的时间片说的是一个时间段,怎么理解呢?
默认情况下,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

重构任务处理为无刷新模式

前面我们在日历视图上进行任务(事件)的增删改,后端持久化后,前端都调用了refresh刷新方法,重新从后端调用数据,从用户体验上,有一个肉眼可见的页面刷新和数据加载过程。
接下来,我们通过调用FullCalendar的api,来实现任务的无刷新模式。

新增任务

在日历视图中点击或选择一个时间范围后,自动弹出创建任务对话框,如下图所示:
image.png
点击保存按钮后,使用emit触发父页面,也就是FullCalendar所在页面的refresh方法,如下所示:

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

// 刷新
refresh() {
  const fullCalendar = this.$refs.fullCalendar.calendar

  let query = this.$route.query
  query = Object.assign(query, {
    viewType: fullCalendar.view.type,
    showAllFlag: this.showAllFlag,
    start: this.$dateFormatter.formatUTCDate(fullCalendar.view.activeStart),
    end: this.$dateFormatter.formatUTCDate(fullCalendar.view.activeEnd)
  })
  refreshSelectedTagWithQuery(query)
}

对于新增任务,FullCalendar提供了addEvent方法,重构如下:

<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
}  

任务数据转换为事件对象的方法,以及计算全天事件属性值的方法,因为多处使用,都是从原已实现的方法中通过重构提取出来的。

通过以上重构处理,实现了任务无刷新添加。

删除任务

在日历视图中右键一个现有任务后,弹出菜单中可以选择“删除”,如下所示:
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
}

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

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

修改任务

在日历视图中点击一个现有任务后,自动弹出修改任务对话框,如下图所示:
image.png
点击保存按钮后,使用emit触发父页面,也就是FullCalendar所在页面的refresh方法,跟上面新增原实现模式一致。

对于修改事件,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)
}

这里测试发现存在小问题,当修改任务时,把起止时间都清空的情况下,应当把该任务视为待安排,从日历视图中移除,放回到收集箱中,而FullCalendar提供的setStart方法,不接受空值,设置空值无效,仍会显示原时间。

针对上述问题,调整如下:

 // 修改任务
  modifyTask(task) {
    const fullCalendar = this.$refs.fullCalendar.calendar
    const event = fullCalendar.getEventById(task.id)
    if (task.startTime) {
      // 开始时间有值,更新任务信息
      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)
    } else {
      // 开始时间无值
      // 从日历视图中移除任务
      event.remove(task.id)
      // 添加到收集箱中 TODO
    }
  }

如何添加到收集箱涉及到收集箱功能的无刷新改造,暂放,标记为todo,后面再说。

同时,在保存任务时,检测开始时间是否为空,如为空弹出确认框,说明该任务会自动放入收集箱,避免用户产生明明执行保存操作了,但日历中不显示的疑惑。

beforeSaveData() {
    if (!this.entityData.startTime) {
      // 开始时间为空,需用户确认是否继续
      return this.$confirm(
        '开始时间为空,该任务将放入收集箱,不会显示在日历中, 是否继续?',
        '确认',
        {
          type: 'warning'
        }
      )
    } else {
      // 开始时间不为空,直接返回
      return new Promise((resolve) => {
        resolve()
      })
    }
}

复制任务

之前实现的复制任务,是调用后端服务的复制新增功能,将新增后的数据返回回来,调用修改页面,传入新增记录的id来实现的,如下:

 if (command === 'copy') {
        this.$api.personaltask.task.addSingleByCopy(id).then((res) => {
          this.$refs.modifyPage.init(res.data.id)
        })
} 

在上面的修改任务的回调中,我们调用的是FullCalendar的修改事件的一系列api,这里是存在冲突的,即在复制这个场景下,我们应该最终调用的是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)
}

退回收集

先前我们实现了通过右键菜单,将某个暂不具备的执行条件的任务退回了收集箱,采用的是整个页面刷新的模式。

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

// 刷新
refresh() {
  const fullCalendar = this.$refs.fullCalendar.calendar
  // console.log(fullCalendar.view)
  let query = this.$route.query
  query = Object.assign(query, {
    viewType: fullCalendar.view.type,
    showAllFlag: this.showAllFlag,
    start: this.$dateFormatter.formatUTCDate(fullCalendar.view.activeStart),
    end: this.$dateFormatter.formatUTCDate(fullCalendar.view.activeEnd)
  })
  refreshSelectedTagWithQuery(query)
}

现改造为无刷新模式,当执行任务退回收集箱操作时,调用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
使用的是FullCalendar的drop事件,最后调用的是刷新操作,如下:

 // 从收集箱拖放到日历
drop(arg) {
    console.log(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((res) => {
      this.refresh()
    })
}

先重构为无刷新模式,把this.refresh()去除,日历中会显示,但是,如果再点击该任务时,会报错,没有获取到任务id……这就尴尬了

重新翻看api,尝试使用eventReceive,同样没有拿到事件的id,那就怀疑被拖放的数据源头,没有设置id了。

回到收集箱的数据加载操作,在eventData中尝试加入id属性,如下所示:

 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 {
                id: eventEl.getAttribute('data-id'),
                title: eventEl.innerText,
                duration: '00:30'
              }
            }
          })
        }
      })
}

获取id的方式看上去比较奇怪,但没有更好的方式……

然后查看eventReceive事件收到的数据,event对象中的id有数据了。
image.png
drop事件收到的数据对象中没有event属性,draggedEl里还是需要通过dataset里才能拿到事件idimage.png

测试发现,在数据源中添加了id属性后,使用的依旧是原来的drop事件,拖放到非全天区域正常了。

但是,从收集箱拖放到全天区域,日历上并不会显示事件。
回看下drop的逻辑处理,实际主要还是构建起止时间传给后台做数据保存,前端的事件属性,比如是否全天,并没有设置,所以才会有问题。

原因定位了,要解决,就不能使用drop事件来处理了,而是更换为eventReceive,先查看参数情况。

拖放到非全天区域,如下图:
image.png
id能直接取到,不用像drop事件,需要从dataset里获取(const id = arg.draggedEl.dataset.id)
allDay属性正确,开始时间是拖放结束对应日历的位置,结束时间是自动加了半小时(收集箱中指定的)。

拖放到全天区域,如下图:
image.png
id、allDay和start都正常,end也是在start基础上加了半小时,这个不对,需要调整。

最终实现如下:

// 日历接收到外部元素拖放
eventReceive(arg) {
  // console.log(arg)
  let event = arg.event
  // 获取任务标识
  const id = event.id
  // 获取是否全天
  const allDay = event.allDay
  // 获取开始时间
  const start = event.start

  // 全天情况下重新设置结束时间
  if (allDay) {
    //若为全天,结束时间为开始时间加1天
    event.setEnd(new Date(start.getTime() + 24 * 60 * 60 * 1000))
  }

  // 刷新收集箱列表
  // this.reloadCollectionBox()

  // 转换起止时间格式
  const startTime = this.$dateFormatter.formatUTCTime(event.start)
  const endTime = this.$dateFormatter.formatUTCTime(event.end)
  // 调用安排工作接口
  this.$api.personaltask.task.assign(id, startTime, endTime)
}

可以看到,使用eventReceive事件,比原先使用的drop事件更方便,尤其了增加了调整事件属性的能力。

详细测试发现,从收集箱拖放到日历,有较大概率产生前端显示多个重复事件的严重问题,如下图所示:
image.png
且一旦某一次拖动发生了该问题,则下一次会100%发生,且最终产生的前端事件的个数是上次的两倍……

这问题很诡异,怀疑是不是拖动过程中,经过的单元格触发了多次,尝试简化代码,发现问题出在收集箱刷新操作上reloadCollectionBox。

在将收集箱里的任务拖动到日历,或者在日历上通过邮件菜单将某个任务退回到收集箱,需要调用刷新操作,来更新数据,如下:

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 {
            id: eventEl.getAttribute('data-id'),
            title: eventEl.innerText,
            duration: '00:30'
          }
        }
      })
    }
  })
}

在这个过程中,应该是产生了重复的事件绑定操作,从而导致拖动一次,会产生多个结果数据。

通过定义全局变量,在加载数据前先销毁后创建的方式来解决,调整如下:

loadData() {
  // 为避免多次绑定产生的拖动一个任务产生前端多个任务显示,先销毁
  if (this.draggable) {
    this.draggable.destroy()
  }

  this.$api.personaltask.task.listWithStatus('PENDING').then((res) => {
    if (res.data) {
      this.taskList = res.data

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

调整后测试正常,不会再出现拖动一次,产生多个前端事件的问题。

排查和解决该问题花了大量时间,主要原因是FullCalendar的文档太简要了,尤其是draggable内部封装了事件绑定比较隐蔽。

解决无刷新模式带来的事件重复问题

问题描述

前文我们将事件改造为了无刷新模式,使用过程中发现一个新问题,具体如下:
在周日历视图中,新增一个任务,保存,正常显示;
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:

大意是指定一个数据源,当重新获取(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)
}

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

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

为了清晰地展现哪些任务已完成,哪些任务待完成,前面采用的方案是通过增加自定义按钮的方式来切换显示,受FullCalendar自身加载数据机制的限制,实现起来相当复杂,最后不得不通过url参数方式来缓存当前视图类型和起止时间。最终效果实现了,但加载数据的时候仍存在瑕疵,需要调用2-3次后端服务。

随着系统重构为无刷新模式,该方案可以考虑优化,即去除自定义按钮,通过不同颜色来区分任务是否完成。

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

最终采用只使用背景色来区分,任务完成(包括已完成和已取消)两种状态显示为灰色背景,其他状态显示为醒目的蓝色背景,边框色与背景色一致,避免出现一个框影响美观,文字使用白色,最终效果图如下:
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()
  }
}

基于上述改造,加载数据的过程大大简化,直接读取数据源即可,如下所示:

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

应用系统

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

标签:00,task,FullCalendar,日历,视图,item,时间,事件,合订
From: https://blog.csdn.net/seawaving/article/details/140528220

相关文章

  • Java学习日历(String,StringBuilder,Stringjoiner)
     金额转换packageme.JavaStudy;importjava.util.Scanner;//币值转换publicclassCaptial{publicstaticvoidmain(String[]args){Scannersc=newScanner(System.in);System.out.println("请输入一个数字");intnumber=sc.ne......
  • [VUE3] 使用D3实现日历热力图
    开始最近我在写自己的网站,需要日历热度图来丰富点内容;所以在网上找了许多参考,如下:https://www.zzxworld.com/posts/draw-calendar-of-heatmap-chart-with-d3jshttps://github.com/DominikAngerer/vue-heatmap/blob/master/README.md将两个结合就是我想要的。现在是这样:代......
  • 一个专为Android平台设计的高度可定制的日历库
    大家好,今天给大家分享一个高度可定制的日历库kizitonwose/Calendar。Calendar专为Android平台设计,支持RecyclerView和Compose框架。它提供了丰富的功能,允许开发者根据需求定制日历的外观和功能。项目介绍此库是开发Android应用时,实现日历功能的一个强大工具,特别适合那些需要......
  • 群辉NAS同步Android手机日历日程
    目录一、安装套件二、手机导出日历日程三、NAS套件导入日历四、获得DAVx5登陆链接五、手机配置六、验证上一篇文章我们解决了Android手机与群辉NAS的通讯录的同步,这期我们说说如何同步Android手机的日历中的日程到群辉NAS。看过上篇文章的伙伴知道,Android需要通过第......
  • Qt:10.显示类控件(QLabel-显示文本或图像的控件、QLCDNumber -显示数字的特殊控件、QPr
    目录一、QLabel-显示文本或图像的控件:1.1QLabel介绍:1.2设置文本格式——textFormat属性:1.3设置图片——pixmap属性:1.4自动缩放——scaledContents属性:拓展:resizeEvent方法:1.5内容对齐方式——alignment属性:1.6自动换行——wordWrap属性:1.7 文本缩进——indent属性......
  • element plus 日历组件默认中文样式,配置日期周一为周起始日
    elementui或者plus其实都是西方的展示方式,日立组件的周日视为每一周的开始日期,我们则是周日为每周的最后一天。那咱们要改成周一为每周的开始日期,如下图:elementui是可以直接属性配置的,elementplus不得行,但是配置下面代码到main.ts就可以了~importElementPlusfrom'......
  • Python 潮流周刊#57:Python 该采用日历版本吗?
    本周刊由Python猫出品,精心筛选国内外的250+信息源,为你挑选最值得分享的文章、教程、开源项目、软件工具、播客和视频、热门话题等内容。愿景:帮助所有读者精进Python技术,并增长职业和副业的收入。本期周刊分享了12篇文章,12个开源项目,赠书5本,全文2200字。以下是本期......
  • 如何实现日历组件封装
    创建一个新的Vue组件,命名为CalendarPicker或者其他合适的名称。在组件中引入el-date-picker组件,并根据需求对其进行定制和封装。可以通过props接收传入的日期格式、日期范围等参数,以便灵活配置日历组件的显示方式。可以通过事件(event)向父组件传递用户选择的日期信息......
  • 使用自定义查询参数获取 fullcalendar api
    我正试图配置fullcalendar5从数据库中获取api。除了开始和结束之外,我还想向请求传递额外的查询参数。我已经尝试过这种方法,但发现请求总是忽略附加参数。events:{url:'http://localhost:4000/api/timesheet'、type:'GET'、......
  • 有这么一个桌面日历就够了 记事代办生日全都有了
    日历功能我相信很多伙伴都用过,而且很多人都会有经常看日历的习惯,所以一款实用又美观的桌面日历就是一个非常不错的选择了。而如果这个日历不仅好看,还可以方便记事,待办提醒,生日管理,这样是不是就很强大了啊?我们一起来看下,这个就是“芝麻日历”(https://rili.zhimasoft.cn/?cdn)的......