☰
Vue 2 + FullCalendar 排班日历实践:从初始化到拖拽调班
2026/10/15 12:42:55 网站建设 项目流程

1. 需求从哪来,为什么要用 FullCalendar

1.1 顶到脸上的需求

做后台管理系统的同学估计都遇到过这种需求:给一个班组排班、给老师排课、给医生排门诊,形式基本都一样——一个日历,上面标着每个人哪天值班、哪天休息、哪个时段有课。某次接到的需求更直接:某个内部业务系统要从原来的表格排班改成日历排班,表格里那种一行一个人、一列一天的模式虽然信息全,但看起来太累,领导要的是“一眼看过去就知道谁在哪天最忙”的视觉效果。

项目技术栈很明确:Vue 2 全家桶加 element-ui,后端接口现成。排班模块的数据结构不算复杂:人员、日期、班次类型、备注,再加一个每天最多排几个人的限制。真正麻烦的是前端怎么把这种数据变成一个能看、能点、能拖的日历。

我当时第一反应是想自己写一个日历组件,但冷静算了一下工作量:一个像样的日历组件要做到周视图和月视图切换、跨天事件的展示、拖拽改时间、点击新增、节假日标记,这些东西加起来没有两三个星期下不来,而且后面还有数不清的边界情况。排班功能只是整个系统里一个模块,不值得花这么大代价。

最后选了 FullCalendar。原因很简单:它是目前最成熟的开源日历库之一,功能覆盖排班场景的大部分需求,支持日视图、周视图、月视图、列表视图,支持事件拖拽和缩放,API 丰富,社区活跃。与 element-ui 搭配使用也很自然,弹窗用 el-dialog、表单用 el-form、选择人员用 el-select,日历负责展示时间轴和交互,element-ui 负责弹层和表单,各干各的活。

1.2 自研不香吗?为选型较真

很多团队在遇到日历类功能时都会纠结“到底自己写还是用第三方”。我的看法是:先看需求边界,再选方案。

如果只是做一个静态的打卡记录展示,没有拖拽,没有多视图切换,那确实可以手写,用 CSS Grid 布局七天,循环一下数据就出来了。但排班排课场景通常具备以下特征:

  • 需要同时展示多人一周或一个月的排班情况
  • 需要有“可视化空闲时间”的能力,方便新增排班
  • 需要拖拽调班、点击查看详情、双击快速修改等操作
  • 需要处理节假日的特殊班次
  • 需要人员维度统计每个周期的排班天数

这几个需求叠加起来,手写成本就急剧上升。比如拖拽这个功能,看着简单,实则涉及到拖拽半径、命中检测、目标时间计算、数据回写、视图刷新,要做得流畅自然并不容易。FullCalendar 把这些能力都做好了,我只需要关注数据格式和业务回调,省掉的开发时间非常可观。

另外 FullCalendar 对 Vue 的适配也不复杂。虽然它本身是原生 JS 库,但通过 npm 安装之后,在组件里直接 import 包即可,配合 Vue 的生命周期在 mounted 里初始化日历实例,在 beforeDestroy 里销毁实例,这套流程我用下来很稳定,不需要额外包一层 Vue 版的封装组件。

1.3 FullCalendar 版本选择,别掉进封装坑

FullCalendar 官网提供了多个版本的接入方式,包括普通 JS、Vue、React、Angular 的封装包。我在这提醒一句:如果你是 Vue 2 项目,优先使用原生@fullcalendar/core再加上@fullcalendar/daygrid、@fullcalendar/timegrid、@fullcalendar/interaction这几个官方插件包,而不是去依赖旧版的全家桶封装。

原因有两个:一是官方对 Vue 2 的支持已经进入维护期,封装包的更新节奏未必跟得上核心包;二是用原生包直接在组件的mounted里初始化 Calendar 实例,控制力更强。比如日历加载完成后,我可能需要拿到日历实例去调用next()、prev()等方法,用原生包直接this.calendar就拿得到,很顺手。

另外注意版本一致性,我当时踩过一个小坑:核心包和插件包的版本号必须一致,比如都用6.1.11,如果混用了6.x和5.x的包,构建时会出现抽象语法树相关的报错,或者事件渲染不出来。npm 安装时最好一套版本一起装,避免自动解析到不同版本。

2. 项目里的落地步骤与核心配置

2.1 依赖引入和初始化

直接看安装和初始化代码。项目是基于 Vue CLI 构建的,所以 npm 安装即可:

npm install @fullcalendar/core@6.1.11 @fullcalendar/daygrid@6.1.11 @fullcalendar/timegrid@6.1.11 @fullcalendar/interaction@6.1.11 @fullcalendar/moment@6.1.11 moment

@fullcalendar/moment这个包是为了让 FullCalendar 使用 Moment 的时间处理方式。其实原生 FullCalendar 自带时间处理能力,但在做日期格式化时用 Moment 会更顺手,特别是在拼接 API 请求参数的时候。所以这里装上,后面用得着。

组件里这样组织,就不再单独封装子组件了,直接在 Vue 文件里写,简单直接:

<template> <div class="schedule-container"> <div ref="calendar" class="fc-calendar"></div> <el-dialog title="新增排班" :visible.sync="dialogVisible" width="480px"> <!-- 表单内容 --> </el-dialog> </div> </template> <script> import { Calendar } from '@fullcalendar/core' import dayGridPlugin from '@fullcalendar/daygrid' import timeGridPlugin from '@fullcalendar/timegrid' import interactionPlugin from '@fullcalendar/interaction' import momentPlugin from '@fullcalendar/moment' import zhCn from '@fullcalendar/core/locales/zh-cn' export default { name: 'ScheduleCalendar', data() { return { calendar: null, dialogVisible: false, scheduleList: [], } }, mounted() { this.initCalendar() this.loadSchedules() }, beforeDestroy() { if (this.calendar) { this.calendar.destroy() } }, methods: { initCalendar() { this.calendar = new Calendar(this.$refs.calendar, { plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, momentPlugin], initialView: 'dayGridMonth', locale: zhCn, headerToolbar: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }, editable: true, selectable: true, selectMirror: true, dayMaxEvents: true, datesSet: this.handleDatesSet, select: this.handleDateSelect, eventClick: this.handleEventClick, eventDrop: this.handleEventDrop, eventResize: this.handleEventResize, eventContent: this.renderEventContent, }) this.calendar.render() }, } } </script>

几个关键点展开说明一下:

  • plugins数组里声明的插件决定了日历支持的能力。dayGridPlugin负责月视图和基本日视图,timeGridPlugin负责周视图和日视图的按时间排列,interactionPlugin提供 select 和 drag 交互能力。没有interactionPlugin,后面点击、拖拽全都不生效。
  • initialView我设为dayGridMonth,因为管理层最习惯先看整月排班,但工具栏里同时提供了周视图和日视图的切换入口。
  • locale: zhCn负责中文显示。不配这个的话,界面上的月份、星期、按钮文字全是英文,产品那边肯定直接打回。
  • headerToolbar就是个工具条布局,左边放切换按钮和今天按钮,中间显示标题,右边放视图切换按钮。这里没有自己加“导出”或“打印”按钮,因为按业务约定这些操作放在页面顶部工具栏里,不揉进日历组件自身。

初始化完成后,调一次loadSchedules()把后端数据拉回来。注意datesSet回调会在视图切换、翻页时触发,在这个回调里我们重新请求当前时间范围内的数据,这样切到哪个月就加载哪个月的数据,性能也更优。

2.2 把配置项掰开揉碎

很多初学者会在配置项这里懵掉,所以我把这次用到的核心配置项按参数用途、参数值、解决的问题三列展开:

配置项值作用
initialViewdayGridMonth日历默认展示视图,月、周、日分别对应dayGridMonth、timeGridWeek、timeGridDay
firstDay1每周从周一开始,符合国内排班习惯
localezhCn中文化,月份、星期、按钮全部改中文
selectabletrue允许鼠标框选日期,用于新增排班
selectMirrortrue框选时显示镜像效果,避免挡到下方真实事件
editabletrue允许拖拽已有事件来改时间,调班就靠这个
dayMaxEventstrue月视图里单格超过容量的内容折叠为“+N more”,避免格子被挤爆
eventContent函数:自定义渲染把事件改成班次名称 + 人员的可读格式

firstDay这个我单独提一下。默认的 FullCalendar 一周从周日开始,但国内排班习惯是周一到周日,不特意设firstDay: 1的话,管理层看了会觉得别扭。配置这个参数只需要一个数字,但涉及产品接受度,务必加上。

dayMaxEvents的折叠效果在排班表里非常关键。月视图一个格子的高度有限,如果一天排了五六个人,默认会把格子撑得很高,或者挤成一团。开启折叠后,多出来的事件合并为一个带+N more的链接式文本,点击后可以下拉查看当天全部排班。具体实现在后面事件渲染部分再讲。

还有一个容易忽略的配置是height。如果不设,默认高度是auto,也就是说日历会尽可能把所有内容撑开渲染,这对排班场景不友好——一天事件多的时候,页面会变得特别长,用户要滚半天才能看到月底。更合适的做法是固定高度或者用aspectRatio控制宽高比:

height: 720 // 或者 aspectRatio: 1.8

我这边用的是固定高度720px,配合页面的内部滚动,视觉上比较稳定。切到周视图和日视图时,这个高度依然适用,不会有跳跃感。

2.3 账号角色控制到底显示什么

排班功能还有一个不可忽视的点:不同角色看到的日历内容和可操作权限不一样。

普通员工进来,只能看自己所在班组的排班,不能拖拽修改,弹窗里的按钮只展示详情;管理员进来,可以查看所有人的排班,可以新增、拖拽调班、删除记录。FullCalendar 不感知业务权限,但我们可以通过 Vue 的路由守卫配合后端的接口权限来控制数据范围,同时在日历初始化时根据当前用户角色动态设置editable的值:

const isAdmin = this.userInfo.role === 'admin' this.calendar.setOption('editable', isAdmin)

这样非管理员即使看到日历,也无法拖拽已有事件。点击空白区域新增排班的操作,在后面select回调里再判断一次权限,双重保险,防止通过调用日历 API 绕过交互层面的权限控制。

3. 排班数据的加载、渲染和中文适配

3.1 后端接口与数据格式设计

后端接口设计可以按 FullCalendar 的事件模型来对接,这样前端几乎不用做太多的兼容转化。FullCalendar 的事件对象核心字段包括:

  • id:事件唯一标识,修改数据时靠它定位记录
  • title:日历上显示的主文本
  • start/end:开始时间、结束时间
  • allDay:是否全天事件
  • backgroundColor等:事件视觉样式
  • extendedProps:自定义扩展字段,可以放排班记录的其他属性

后端返回的数据结构大致是这样:

[ { "id": 1024, "title": "张三 - 早班", "start": "2024-06-10T08:00:00", "end": "2024-06-10T16:00:00", "allDay": false, "backgroundColor": "#409EFF", "extendedProps": { "userId": 12, "shiftType": "morning", "remark": "大厅值班" } } ]

这里注意一个设计选择:后端接口是在前端传了start和end参数后,返回时间范围内的排班记录。这个范围由日历的datesSet回调提供,调用接口时交给后端做日期区间过滤,避免一次拉全量数据。

接口的 URL 类似:

GET /api/schedule/list?start=2024-06-01&end=2024-06-30

后端根据区间过滤后返回。如果后端同学不太了解 FullCalendar 的日期格式,前端传参时统一转成YYYY-MM-DD格式的字符串即可。

3.2 将数据转成日历事件并保持日期正确

拿到后端数据后,直接设置到日历实例上:

async loadSchedules() { // 获取当前视图中时间范围 const start = this.calendar.view.currentStart.format('YYYY-MM-DD') const end = this.calendar.view.currentEnd.format('YYYY-MM-DD') const params = { start, end } const res = await getScheduleList(params) this.scheduleList = res.data // 清空旧事件,防止重复渲染 this.calendar.removeAllEvents() this.calendar.addEventSource({ events: this.scheduleList.map(item => ({ id: item.id, title: `${item.userName} - ${item.shiftName}`, start: item.start, end: item.end, allDay: item.allDay, backgroundColor: this.getShiftColor(item.shiftType), extendedProps: { scheduleId: item.id, userId: item.userId, shiftType: item.shiftType, remark: item.remark, } })) }) }

这里有一个很容易踩的坑:如果你在一次加载里直接对this.calendar.getEvents()做操作,或者不清旧事件就addEventSource,会出现翻页后事件重复展示、甚至旧数据残留的问题。我的习惯是每次加载成功后先removeAllEvents(),再挂新的数据源。

另一个坑是日期格式。后端如果返回的是带时区的 ISO 字符串,比如2024-06-10T00:00:00+08:00,FullCalendar 解析时会按浏览器的本地时区处理。国内项目大多数用户都在东八区,不会出问题。但如果服务器部署在海外,或者某个用户的电脑时区设置不对,就会出现日期偏移一天的情况。稳妥的方案是前端拿到数据后,用 Moment 重新格式化成不带时区偏移的本地时间字符串:

const localStart = moment(item.start).format('YYYY-MM-DDTHH:mm:ss')

这样后续拖拽、比较、展示就都以本地时间为准,避免时区干扰排班判断。

3.3 让日历讲人话:班次名称和颜色定制

排班日历里的视力痛点,是默认的事件格子里只有一段文字,分辨不出哪个是早班、哪个是晚班、哪个是夜班。现在通过eventContent配置项做优化,让每个事件长得像一个小卡片。

FullCalendar 的eventContent回调接收参数info,其中info.event携带当前事件的完整数据。自定义渲染的思路是在回调里返回一个类似虚拟 DOM 结构的对象:

renderEventContent(info) { const event = info.event const shiftName = event.extendedProps.shiftType || '' const userName = event.title.split(' - ')[0] || '未知' const container = document.createElement('div') container.classList.add('custom-event-content') container.innerHTML = ` <div class="event-main">${shiftName}</div> <div class="event-sub">${userName}</div> ` return { domNodes: [container] } }

对应的样式,我这里贴一下比较常用的一套:

.custom-event-content { padding: 2px 6px; border-radius: 4px; line-height: 1.4; overflow: hidden; } .event-main { font-size: 12px; font-weight: bold; white-space: nowrap; } .event-sub { font-size: 10px; opacity: 0.85; white-space: nowrap; }

班次颜色这边,我预先维护一个映射表:

const SHIFT_COLOR_MAP = { morning: '#409EFF', // 早班 蓝色 middle: '#67C23A', // 中班 绿色 night: '#E6A23C', // 晚班 橙色 off: '#C0C4CC', // 休班 灰色 }

通过getShiftColor(type)函数返回对应的十六进制色值,为每个事件设置backgroundColor。在月视图里,颜色成为第一视觉区分指标,管理人员看整体排班密度时很受用。

同时,为了让“休假”状态下的事件不影响视觉重点,休班的事件我取消了边框和文字颜色,用虚线背景标识,看起来更像一种占位标记而不是忙碌状态。

4. 常用交互的完整实现

4.1 点击空白新增排班

排班业务里最最常见的操作是:看到一个空白日期,想给某个人安排一个班次。FullCalendar 的select回调提供了鼠标框选后的日期信息,我们可以用它来打开一个 element-ui 的弹窗。

handleDateSelect(selectInfo) { // 非管理员直接拦截 if (!this.isAdmin) { this.$message.warning('无排班操作权限') return } const startDate = moment(selectInfo.startStr).format('YYYY-MM-DD') // 收集这一天已有排班,用于后面做冲突校验 const dayEvents = this.calendar.getEvents().filter(ev => { return moment(ev.start).format('YYYY-MM-DD') === startDate }) this.scheduleForm = { scheduleDate: startDate, userId: '', shiftType: '', remark: '' } this.dayFull = dayEvents.length >= this.maxSchedulePerDay // 如果是满员状态,直接拦截 if (this.dayFull) { this.$message.warning(`当天最多排 ${this.maxSchedulePerDay} 人,无法新增`) this.calendar.unselect() return } this.dialogVisible = true this.editingId = null this.calendar.unselect() }

弹窗里的表单,就是很常规的 element-ui 表单组件:

<el-dialog :title="editingId ? '编辑排班' : '新增排班'" :visible.sync="dialogVisible" width="480px"> <el-form :model="scheduleForm" :rules="formRules" ref="scheduleFormRef" label-width="80px"> <el-form-item label="排班日期" prop="scheduleDate"> <el-date-picker v-model="scheduleForm.scheduleDate" type="date" value-format="yyyy-MM-dd" placeholder="选择日期" disabled style="width: 100%;" /> </el-form-item> <el-form-item label="值班人员" prop="userId"> <el-select v-model="scheduleForm.userId" placeholder="请选择人员" style="width: 100%;"> <el-option v-for="user in userOptions" :key="user.id" :label="user.realName" :value="user.id" /> </el-select> </el-form-item> <el-form-item label="班次类型" prop="shiftType"> <el-select v-model="scheduleForm.shiftType" placeholder="请选择班次" style="width: 100%;"> <el-option label="早班(08:00-16:00)" value="morning" /> <el-option label="中班(16:00-00:00)" value="middle" /> <el-option label="晚班(00:00-08:00)" value="night" /> </el-select> </el-form-item> <el-form-item label="备注"> <el-input v-model="scheduleForm.remark" type="textarea" :rows="2" placeholder="选填" /> </el-form-item> </el-form> <template #footer> <el-button @click="dialogVisible = false">取消</el-button> <el-button type="primary" :loading="submitLoading" @click="submitSchedule">保存</el-button> </template> </el-dialog>

提交的时候,前端做一层基础校验,比如同一人在同一天不能重复排班。这个校验可以放在弹窗选择人员后就执行,也可以放在提交时统一处理。我倾向于在提交时做,因为校验依赖的已有排班数据在打开弹窗时可能已经变化,提交时再拿一份最新的更可靠:

async submitSchedule() { const repeated = this.scheduleList.some(item => { return item.userId === this.scheduleForm.userId && moment(item.date).format('YYYY-MM-DD') === this.scheduleForm.scheduleDate }) if (repeated) { this.$message.warning('该人员当天已存在排班,请勿重复安排') return } // 后端保存成功后刷新日历事件 await saveSchedule(this.scheduleForm) this.dialogVisible = false this.loadSchedules() }

这里有个小细节:loadSchedules里调用了removeAllEvents再重新挂载数据源,所以保存完直接刷新即可,不需要再去手动拼接一条新事件塞进日历,既不过度到前端,也不依赖后端返回事件对象,保持事件状态完全由接口数据驱动,是最不容易出错的模式。

4.2 拖拽改变排班时间

排班管理中一个高频操作是“调班”。Person A 本来排了 6 月 10 日的早班,临时有事,要把这个班次挪到 6 月 12 日。如果走表单修改,得先找到这条记录,改日期,保存,再刷新,操作路径长。FullCalendar 的拖拽能力把它压缩成“直接把格子拖到目标日期”两步。

拖拽的关键是editable: true和eventDrop回调:

async handleEventDrop(info) { const changedEvent = info.event const oldStart = moment(info.oldEvent.start).format('YYYY-MM-DD') const newStart = moment(changedEvent.start).format('YYYY-MM-DD') // 如果拖拽前后日期没有变化,直接忽略 if (oldStart === newStart) { info.revert() return } // 检查目标日期是否已满 const targetDateEvents = this.calendar.getEvents().filter(ev => { return moment(ev.start).format('YYYY-MM-DD') === newStart }).filter(ev => ev.id !== changedEvent.id) if (targetDateEvents.length >= this.maxSchedulePerDay) { this.$message.warning(`目标日期排班已满,最多 ${this.maxSchedulePerDay} 人`) info.revert() return } const payload = { id: changedEvent.id, startDate: newStart } const res = await updateScheduleDate(payload) if (res.code !== 0) { this.$message.error(res.msg) info.revert() // 后端失败时回滚日历,避免本地状态和服务端不一致 return } this.$message.success('调班成功') // 刷新数据 this.loadSchedules() }

拖拽调班最常见的坑是:校验时只看了用户拿到的scheduleList,但没有把日历实例的当前事件考虑全。比如这次拖拽后目标日期已经有 5 人了,你拖第 6 人过去,校验就该拦住。用this.calendar.getEvents()实时获取当前视图事件,再结合maxSchedulePerDay去判断,既准又稳。

还有一个细节:拖拽后的info.event.start已经是新的时间,而info.oldEvent记录的是拖拽前的原始数据。两者对比可以得到变更的日期区间。做批量调班时可以借这个实现“连续拖动多天”,但业务上很少用,这里不展开。

4.3 双击查看详情与快捷操作

FullCalendar 没有内置“双击事件”,但我们可以通过eventClick加业务逻辑来实现类似效果。排班管理中点击一条排班记录,弹出一个详情层,展示人员姓名、班次、备注,以及编辑和删除两个操作。

handleEventClick(info) { const event = info.event const { userId, shiftType, remark } = event.extendedProps this.detailUser = event.title.split(' - ')[0] this.detailShift = this.getShiftName(shiftType) this.detailRemark = remark || '无' this.detailEventId = event.id this.detailVisible = true }

详情弹窗里放了编辑入口。点击编辑直接打开 4.1 里的编辑弹窗,复用同一套表单逻辑,仅设置editingId为当前事件的 id:

openEditDialog() { const event = this.calendar.getEventById(this.detailEventId) if (!event) return this.scheduleForm = { scheduleDate: moment(event.start).format('YYYY-MM-DD'), userId: event.extendedProps.userId, shiftType: event.extendedProps.shiftType, remark: event.extendedProps.remark } this.editingId = this.detailEventId this.detailVisible = false this.dialogVisible = true }

这里一定要通过getEventById去拿一定是当前的、最新的数据,而不是用之前存在this.scheduleList里的旧对象。因为可能刚拖拽过、刚修改过,数组里的字段和日历实例的事件已经不一致了。日历实例的事件才是当前渲染出来的“事实”。

删除操作就比较简单,提醒确认后调接口:

async deleteSchedule() { const res = await deleteScheduleById(this.detailEventId) if (res.code === 0) { this.$message.success('删除成功') this.detailVisible = false this.loadSchedules() } }

5. 踩坑实录与排查思路

5.1 弹窗被日历遮挡

第一次用 element-ui 对话框组合 FullCalendar 时,出现了一个很奇怪的现象:日历渲染的月份区域部分元素会盖在弹窗上面,“新增排班”弹窗的标题区域和表单区域有一块被日历遮住,底下的切换按钮点不到。

排查之后发现,问题出在 FullCalendar 内部某些元素使用了较高层级的定位上下文,同时 element-ui 的el-dialog默认的z-index是动态计算的,如果日历父容器的z-index或者某个样式里设置了过高的堆叠顺序,就会产生层级覆盖。

解决办法有两个方向:

  • 给日历容器设置一个合理的z-index,并确保其父容器不创建新的层叠上下文
  • 给el-dialog加一个显式的:z-index,比如3000

我最终用的是设置:z-index="3000"的方式。要注意 element-ui 的 dialog 内容是在全局挂载还是局部渲染。如果用局部渲染且父容器带有transform属性,也会引入新的包含块问题。稳妥做法是把关键弹窗通过append-to-body挂到 body 上:

<el-dialog :visible.sync="dialogVisible" :append-to-body="true" :z-index="3000">

这样小概率的层叠问题也被规避掉了,实测稳定。

5.2 12小时制与 24 小时制“活见鬼”

排班数据里有08:00这种时间,FullCalendar 按照locale不同,部分环境会渲染成8AM或上午8:00。中文环境下如果还出现12:00 AM这种看起来像中午十二点的表述,就会误导用户。

排查下来,FullCalendar 默认使用了英文环境的时间格式h:mm a,即使语言已切换为中文,时钟格式未跟随业务偏好。处理方案是配置eventTimeFormat:

eventTimeFormat: { hour: '2-digit', minute: '2-digit', hour12: false }

显式指定为 24 小时制。另外也可以设置nextDayThreshold: '00:00:00',保证跨天事件在显示上不会出现歧义。日历顶部标题,默认情况下会被dayGridMonth渲染成“2024年6月”这类中文,不会有问题;但如果有人改过titleFormat,也要确保月份是中英文对应,否则月份切换后文案不对。

5.3 容器渲染顺序与日历空白

有同事在接入时遇到一个状况:日历区域刷新后一整片空白,控制台也没有报错。后来发现是v-if的锅——日历容器用了v-if="tab === 'schedule'",但日历在mounted里初始化,此时容器还在未渲染状态,this.$refs.calendar是 undefined,初始化一个寂寞。

解决方式有两种:

  • 在容器上用v-show替代v-if,保证 DOM 一直存在,日历只需要实例化一次
  • 如果用v-if,则要将日历初始化的时机放在组件内部的 tab 切换之后,比如在 watch 里监测tab,当容器挂载后再初始化this.$nextTick(() => this.initCalendar())

我的建议是尽量用v-show,因为排班日历的实例化成本不小,频繁销毁重建会导致每次切换都重新拉数据、重新渲染事件,用户会感知到明显的卡顿。让日历一直驻留内存,只是通过显示隐藏切换,体验更好。

5.4 操作完日历不刷新

事件拖拽后,我调用了loadSchedules,但发现日历上的事件完全没有变化。这是因为loadSchedules里调用的this.calendar.removeAllEvents()和addEventSource()在同一个 tick 中执行,数据还没走到渲染流程时,又被新的函数覆盖了。

更定位准确的写法是重新 refetch。FullCalendar 的 EventSource 可以维护一个引用,然后调用refetch():

async loadSchedules() { const start = moment(this.calendar.view.currentStart).format('YYYY-MM-DD') const end = moment(this.calendar.view.currentEnd).format('YYYY-MM-DD') const res = await getScheduleList({ start, end }) this.scheduleList = res.data if (this.eventSource) { this.eventSource.remove() } this.eventSource = this.calendar.addEventSource({ id: 'schedule-source', events: this.scheduleList.map(...) }) this.calendar.refetchEvents() }

关键点是refetchEvents()能强制日历重新走一遍事件获取流程。这个操作比手动getEvents()操作 DOM 干净很多,也不会有删了加、加了删的重复渲染问题。

6. 排班数量统计与校验逻辑

6.1 人员负荷统计视图

排班功能不能只看日历本身。如果不管“一个人一个月排了多少天”,时间一长就会出现某些人忙到飞起、某些人十天都不来一次的局面。利用 FullCalendar 的事件数据和 moment 的日期计算,可以做一个简单的负荷统计卡:

computed: { // 统计当月每个人的排班天数 monthlyStats() { const stats = {} const monthStart = moment(this.calendar.view.currentStart).format('YYYY-MM') this.scheduleList.forEach(item => { const itemMonth = moment(item.start).format('YYYY-MM') if (itemMonth !== monthStart) return if (!stats[item.userName]) { stats[item.userName] = { count: 0, lateCount: 0 } } stats[item.userName].count++ if (item.shiftType === 'night') { stats[item.userName].lateCount++ } }) return stats } }

这个统计卡片放在日历左侧或上方,管理员一眼就能看到当月谁排得多、谁排得少。配合maxSchedulePerDay的每日限制,虽然不能完全避免“撑死”和“饿死”的失衡,但至少有了数据支撑,后续做人工调整时也有依据。

6.2 冲突校验别漏边界情况

排班冲突校验看着简单,但实际需要覆盖的场景不少:

  • 同一个人同一天只能有一个班次
  • 同一天的总人数不超过maxSchedulePerDay
  • 连续夜班天数不能超过 N 天(业务硬规则)
  • 排班日期不能早于今天(防止补过去排班)
  • 夜班跨天时,结束日期是次日,不能和次日的早班冲突

前两条比较直观,直接在submitSchedule里判断即可。后几条在纯前端实现起来会多一些条件判断。我在实际项目里把前两条放在前端做实时反馈,后面几条交给后端接口统一校验,前端只要把后端的错误码和描述信息展示出来就行。前端校验属于辅助,后端校验才是最终防线,这个观念从开始就应该清楚。

比如接口返回约定一个错误码结构:

{ "code": 40001, "msg": "该员工本周连续夜班已达上限,无法继续安排" }

前端在catch或res.code !== 0分支中直接弹出对应提示。这样新增排班的弹窗即使通过了前端的松散过滤,后端也能挡住不合理的组合,数据才不会被污染。

7. 后续还能怎么扩展

FullCalendar 排班这块做完后,还有一些很自然的扩展方向,我这里提供几个参考思路:

  • 排班模板化:很多班组的排班规则是周期性的,比如“三班倒”固定节奏。可以把一个班次的序列保存为模板,一键套用,大幅减少手动操作
  • 班组维度过滤:当班组数量较多时,日历顶部加一个多选下拉框,按班组过滤事件源,实现只看某个组的人员排班
  • 导出图片或表格:由于 FullCalendar 只负责渲染,如果产品要求导出排班表,通常还是用后端按时间段生成 Excel 或 PDF 更可控
  • 节假日联动:对接节假日接口,把法定节假日用特殊底色标记出来,排班时自动避开,或者自动附加节假日薪资规则

这些扩展本质上都建立在日历事件数据和行为规范的基础上,前期的数据结构和交互设计越清晰,后面加功能就越顺手。

最后再分享一个小经验:FullCalendar 的坑大部分不是它本身的问题,而是 Vue 生命周期、容器渲染时机、数据刷新模式这些周边问题。只要把“日历实例的创建与销毁”控制在 Vue 生命周期内,把“事件数据的刷新机制”和“后端接口”完全绑定,这个组合在排班场景里就会非常稳。我后来在另一个项目里做类似的门诊排班,把这套代码稍加调整直接复用,两天就开发完了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询