前阵子做了一套运营管理后台,最核心也最让我头疼的一个功能,就是日历排班排课。
接到的需求并不复杂:在一个日历视图上,把老师的课程和员工的班次都排出来,支持点击空白处新增,拖拽调整时间,还要能按月份切换并按需加载数据。技术栈是定死的——Vue2 + Element UI。日历组件我一开始想省事,直接拿 Element UI 自带的 el-calendar 应付,结果原型还没跑通就发现这条路走不通。最终我选了 FullCalendar 这个老牌日历库,把排班排课的完整流程做了出来。这篇文章就是一次完整记录,从选型、封装、交互到数据层设计和踩坑,全部摊开讲。
如果你也在做排班、排课、预约这类日历场景的后台,这篇文章应该能帮你少走不少弯路。
1. 需求还原:排班和排课不是"在日历上点两下"这么简单
1.1 需求方口中的"排一下"到底包含什么
当时提需求的是运营部的人,原话是"就在日历上排一下嘛,拖一拖就行"。等你真上手拆解,才会发现这个功能的完整清单能写满两页纸:
- 排课场景:选择教师、班级、课程、教室,设置开始和结束时间,并且要能检测教师冲突、教室冲突和班级冲突。
- 排班场景:选择员工、日期、班次类型(早班、中班、晚班、休息),支持批量把同一个班次复制到一周或一个月。
- 展示要求:默认月视图,辅助周视图;课程和班次最好能分开显示,通过开关控制。
- 操作要求:点击空白新增、拖拽移动、拖拽改变时长、右键快速复制、删除前二次确认。
- 数据要求:切换月份时按需加载,不要一次把全年数据拉下来;提交后其他端刷新能看到最新状态。
这个需求清单里,真正难的不是"画一个日历",而是"操作之后的数据变化"——新增了哪条、改动了哪条、删除了哪条、拖拽之后冲突要不要拦,这些才是排班排课和其他日历业务最大的区别。
1.2 排课和排班在日历层的异同
先说相同点。两者在日历上都是"一个时间段块",有开始时间、结束时间、标题和颜色。FullCalendar 的 event 模型天然适合这种结构,所以底层完全可以共用一个日历组件。
差异集中在业务规则上。排课多了一个"冲突维度":同一个教室在同一时间不能排两门课,同一个老师也不能同时出现在两个班级。排班则是"人的连续覆盖":一个人一天排了早班就不能再排晚班,休息日不需要具体时间,但要占用当天格子的视觉位置。正因为差异在业务层而不是日历层,我决定把日历封装成通用组件,把排课和排班的相关逻辑全部放在业务侧处理。这个决定在后面开发中帮我省了很多事。
2. 选型对比:为什么 Element UI 的自带日历扛不住排班场景
2.1 el-calendar 的短板在哪里
Element UI 的 el-calendar 我并不是一开始就排除的。毕竟项目里全是 Element UI 组件,日历也用同一套视觉,理论上最协调。但真正跑通原型后,问题逐个暴露:
- 只有月视图。排课场景里老师需要看某一天的完整时间线,el-calendar 做不到。
- 不能选中时间范围。用户在日历上拖出一段空白区域来新增排班,这种操作 el-calendar 没有对应事件。
- 事件展示能力弱。它更多是"每个日期格子里放点内容",不是真正的时间块。上午9点到10点半的课,在格子里只能显示一行文字。
- 没有拖拽、缩放的交互基础。这些能力全得自己用鼠标事件重新实现,成本非常高。
我试过在 el-calendar 的插槽里做自定义内容,把课程信息塞进去。数据少的时候还能看,一旦一个月有几百条排班记录,格子里的内容直接挤成一团。
2.2 FullCalendar 的插件化架构与扩展能力
FullCalendar 是老牌日历库,我选它主要是看中三点:
第一是视图能力。它内置月视图、周视图、日视图、列表视图,排课用 timeGridWeek 看时间线,排班用 dayGridMonth 看月度总览,一套组件全搞定。
第二是事件交互。select、eventClick、eventDrop、eventResize 这些回调函数开箱即用。select 可以在空白区域拖拽选择时间段,eventDrop 可以在拖拽后拿到事件新位置,eventResize 可以拖拽改时长。这些能力如果自己写,至少得加一周开发量。
第三是插件化架构。日历核心只负责渲染和事件管理,dayGrid、timeGrid、interaction 都是独立插件,按需引入,不会打包一堆用不到的东西。
其它替代方案我也简单对比过。v-calendar 更偏日期选择器,vue-calendar 这类第三方组件维护活跃度不确定,自研在成本和风险上完全没必要。在 Vue2 + Element UI 生态里,FullCalendar 是当时综合性价比最高的选择。
| 能力 | el-calendar | FullCalendar | 自研组件 |
|---|---|---|---|
| 月视图 | 有 | 有 | 成本高 |
| 周/日/列表视图 | 无 | 有 | 成本高 |
| 选中时间范围 | 无 | select 事件 | 成本高 |
| 拖拽移动 | 无 | eventDrop | 成本高 |
| 拖拽改变时长 | 无 | eventResize | 成本高 |
| 中文语言包 | 可自配 | 内置 locales | 自配 |
| 社区与维护 | 官方但功能有限 | 活跃且成熟 | 无 |
3. 环境依赖与组件封装:Vue2 用 FullCalendar 5 的版本坑
3.1 版本选择与安装细节
FullCalendar 在 5.x 时代提供了针对 Vue2 的官方适配器 @fullcalendar/vue。到了 6.x,官方适配器转向 Vue3,Vue2 项目如果直接装最新版本,很大概率跑不起来。
我当时项目是 Vue 2.6,Element UI 2.x,所以锁定了 5.11 系列,安装时显式带版本号:
npm install @fullcalendar/vue@5.11.5 @fullcalendar/core@5.11.5 @fullcalendar/daygrid@5.11.5 @fullcalendar/timegrid@5.11.5 @fullcalendar/interaction@5.11.5这几个包分别是:核心逻辑、月视图、周/日视图、交互插件(选中、拖拽、缩放)。还有一个 @fullcalendar/list 是列表视图,如果后台不需要可以不加。版本号建议五个包保持一致,混用版本容易出兼容问题。
3.2 把日历封装成通用组件
为了让排课和排班共用同一套日历交互,我把 FullCalendar 封装成了一个独立组件 FullScheduler.vue。这个组件不关心业务,只负责暴露日历的配置项和事件回调,业务侧通过 props 传入数据、通过事件接收操作。
<template> <div class="full-scheduler"> <FullCalendar ref="calendar" :options="calendarOptions" /> </div> </template> <script> import FullCalendar from '@fullcalendar/vue' import dayGridPlugin from '@fullcalendar/daygrid' import timeGridPlugin from '@fullcalendar/timegrid' import interactionPlugin from '@fullcalendar/interaction' import zhCnLocale from '@fullcalendar/core/locales/zh-cn' export default { name: 'FullScheduler', components: { FullCalendar }, props: { events: { type: Array, default: () => [] }, editable: { type: Boolean, default: true } }, data() { return { calendarOptions: { plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin], locale: zhCnLocale, initialView: 'dayGridMonth', headerToolbar: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek' }, firstDay: 1, selectable: this.editable, editable: this.editable, dayMaxEvents: 3, datesSet: this.handleDatesSet, select: this.handleSelect, eventClick: this.handleEventClick, eventDrop: this.handleEventDrop, eventResize: this.handleEventResize } } }, methods: { handleDatesSet(info) { this.$emit('change-range', { start: info.startStr, end: info.endStr }) }, handleSelect(info) { this.$emit('select-slot', { start: info.startStr, end: info.endStr }) } } } </script>这里有个容易被忽略的点:calendarOptions 必须放在 data 里返回,而不是写在计算属性或直接绑定模板。因为 FullCalendar 的 Vue 适配器需要的是一个稳定对象,如果配置项被 Vue 的响应式系统深度代理,某些内部操作会触发不必要的重渲染,严重时直接卡顿。
获取日历实例也很关键。后续需要手动刷新、调用,不能直接操作组件内部状态,要通过 ref:
this.$refs.calendar.getApi().refetchEvents()3.3 对外暴露的属性和事件
组件的 API 设计我控制在最小范围:
- props:events 传入排班排课数据,editable 控制当前日历是否可编辑。
- emit:change-range 告诉父级当前视图覆盖的时间范围;select-slot 在用户选中空白区域时触发;click-event、drop-event、resize-event 分别对应点击、拖拽和缩放。
父组件只需要监听这些事件,就能实现新增、编辑、拖拽更新等业务逻辑。这样一来,以后接预约场景、会议室借用场景,直接复用这个组件,改业务层就行。
4. 从数据到日历事件:排班记录怎么映射成 FullCalendar 的 event
4.1 后端数据结构设计建议
FullCalendar 的 event 有几个保留字段:id、title、start、end、allDay、backgroundColor、extendedProps。前端的映射原则是:业务字段一律放进 extendedProps,不要把教师、教室、班次类型这种信息拼进 title,否则后面做编辑、删除、冲突检测都会很痛苦。
我建议后端返回的数据长这样:
{ "id": 1021, "type": "course", "title": "数学课 A班", "teacher": "A老师", "classroom": "A302", "start": "2024-06-10 09:00:00", "end": "2024-06-10 10:30:00", "bgColor": "#409EFF" }如果是排班:
{ "id": 2033, "type": "shift", "employeeName": "员工甲", "shiftName": "早班", "start": "2024-06-10 08:00:00", "end": "2024-06-10 16:00:00", "bgColor": "#67C23A" }这里为什么要强调字符串格式?因为 FullCalendar 默认按本地时间解析"YYYY-MM-DD HH:mm:ss"格式,非常稳定。如果后端返回带Z的 ISO 字符串,本地时区和 UTC 一转换,日期直接偏移一天。这个问题我后面专门讲,先记住结论:前后端统一用不带时区标识的本地时间字符串。
4.2 事件映射与颜色策略
拿到接口数组后,统一转成 FullCalendar 的 event 结构:
buildEvents(list) { return list.map(item => ({ id: String(item.id), title: item.type === 'course' ? `${item.title}` : `${item.employeeName} ${item.shiftName}`, start: item.start, end: item.end, allDay: item.allDay || false, backgroundColor: item.bgColor || '#409EFF', borderColor: 'transparent', extendedProps: { ...item } })) }颜色策略我放在业务侧统一定义,而不是写死在接口里。课程按科目分配色系,排班按班次类型分配:早班绿色、中班橙色、晚班深蓝、休息灰色。这样一个月度视图扫下来,哪个老师哪天有课、哪个员工哪天休息,一眼就能定位。
如果让后端传颜色,前端改一个配色需求就要动接口,特别不划算。
4.3 多数据源与视图切换
排班排课同时存在时,我用了 FullCalendar 的 eventSources 特性,把课程和班次拆成两个数据源,方便在界面上用开关分别控制显示和隐藏。
eventSources: [ { events: this.courseEvents, className: 'course-event' }, { events: this.shiftEvents, className: 'shift-event' } ]这样在业务侧,排课模块只维护 courseEvents,排班模块只维护 shiftEvents,不会互相污染。视图切换时,月视图默认显示排班总览,周视图切到某一天后,课程时间块和班次时间块会按各自的时间轴排开,视觉上很直观。
5. 核心交互闭环:新增、编辑、拖拽、删除、批量复制
5.1 新增排班:选中时间范围后弹窗处理
用户操作的第一步,是点击日历空白处拖拽出一个时间段。select-slot 事件会触发,父组件拿到 start 和 end 后弹出一个 Element UI 的对话框。
弹窗里的表单逻辑要处理两块:一是公共信息,比如类型(课程/班次)、标题、颜色;二是不同业务自己的字段,课程要选教师和教室,班次要选员工和班次类型。
新增提交成功后,我用两种方式之一刷新界面:要么把新事件直接 addEvent 到日历,要么重新请求当前时间范围的数据。实际项目里我推荐后者,因为服务端可能有一些扩展字段,比如冲突检测后的自动调整、排序权重之类,重新拉取最保险。
async confirmCreate(formData) { try { await createSchedule(formData) this.$message.success('排班创建成功') this.loadEvents(this.currentRange.start, this.currentRange.end) } catch (e) { this.$message.error('新增失败') } }5.2 拖拽与缩放更新、失败回滚
拖拽移动和拖拽改变时长,底层逻辑是一样的:日历组件把 event 的新 start/end 通过回调返回,业务侧组装成更新请求发到后端。
这里有一个关键习惯:拖拽之后不要只更新本地数据,要先请求后端,成功后再统一刷新。我把失败回滚写进了 eventDrop 的处理逻辑里,一旦接口报错,立即调用 refetchEvents 把事件恢复到原始位置,避免出现前端改了、后端没存的情况。
async handleEventDrop(info) { const payload = { id: info.event.id, start: this.formatTime(info.event.start), end: this.formatTime(info.event.end) } try { await updateSchedule(payload) this.$message.success('排班已更新') this.loadEvents(this.currentRange.start, this.currentRange.end) } catch (e) { this.$message.error('更新失败,已恢复原位置') this.$refs.calendar.getApi().refetchEvents() } }eventResize 的处理方式完全一样,只是回调参数里的 event.end 变成了新时长算出的结束时间。这类交互只要把"先请求后刷新"这个流程守住,后面基本不会出大乱子。
5.3 批量排班的实现思路
排班场景里,运营人员最常抱怨的就是"一个员工一个员工地排太慢了"。所以我加了一个批量排班入口:选择一个员工,指定班次类型,选一个日期范围,再勾选周几,系统自动生成多条排班记录。
实现思路其实不复杂:
batchCreateByWeek(employeeId, shiftType, startDate, endDate, weekdays) { const days = this.getDatesBetween(startDate, endDate) .filter(day => weekdays.includes(day.getDay())) const payload = days.map(day => ({ employeeId, shiftType, date: this.formatDate(day), startTime: SHIFT_MAP[shiftType].start, endTime: SHIFT_MAP[shiftType].end })) this.$refs.form.validate(async valid => { if (!valid) return await batchCreateSchedule(payload) this.loadEvents(this.currentRange.start, this.currentRange.end) }) }这里要特别注意重复提交问题。批量接口必须做幂等处理,我建议后端以"员工 + 日期 + 班次类型"作为唯一约束。已经存在的数据直接跳过,并把结果返回前端,让用户看到哪些排上了、哪些跳过。
5.4 冲突预检:把矛盾拦截在提交之前
排课的冲突检测是必须做的。同一个教室同一时间不能有两门课,同一个老师同一时间也不能出现在两个教室。这种规则如果只靠后端拦截,用户体验会很差——用户辛辛苦苦填完表单,提交后被后端打回,还得重新弄。
所以我在前端新增和拖拽时都做了预检。原理非常简单:拿到当前日历上所有事件,和目标时间段做重叠判断。重点检查两件事:对象是否相同、时间段是否重叠。
时间段重叠判断的核心逻辑:
isOverlap(aStart, aEnd, bStart, bEnd) { return aStart < bEnd && bStart < aEnd }判断教师冲突时,遍历当前事件列表,找到 teacher 相同的事件,再比对时间段;教室冲突同理。只要检测到重叠,就在弹窗里给出红字提示,并禁用保存按钮。这个预检不能替代后端校验,但能拦截掉绝大多数明显错误。
6. 数据层与接口约定:让多次提交不会总打架
6.1 按时间范围查询与批量保存
排班和排课的数据变更频率很高,接口设计尽量遵循两条原则:查询按时间范围,变更按批量处理。
我设计的接口像这样:
GET /api/schedules?start=YYYY-MM-DD&end=YYYY-MM-DD POST /api/schedules/batch PUT /api/schedules/:id DELETE /api/schedules/:id查询接口只负责返回时间范围内的记录,不在前端做额外的过滤。FullCalendar 的 datesSet 回调正好提供了当前视图的 startStr 和 endStr,直接拼进查询参数即可。
批量接口用来处理两种场景:一是批量排班的批量创建,二是日历上拖拽导致的"移动一个事件,跨越一个日期"这类单点修改。批量创建必须走事务,要么全成功,要么全失败,不能出现排了一半的情况。
6.2 乐观更新与失败回滚
排班编辑的体验,我选择了乐观更新策略:拖拽结束后,前端先让事件显示在新位置,同时发请求;请求成功就不管了,失败就 refetchEvents 回滚。这样做的好处是交互反馈几乎没有延迟,用户不会等着转圈。
这里有个细节:乐观更新之前,要先保存一份原始数据快照。回滚的时候,不是简单地把事件挪回去,而是重新从服务端拉取数据,确保回滚到的是服务端真实状态,而不是前端记忆的状态。因为拖拽期间可能有其他人改了同一条记录,用本地快照回滚会覆盖别人的修改。
6.3 按需加载与防抖设计
每次切换月份、切换视图都重新拉数据,如果用户在工具栏上快速点"下一页",会连续触发多次请求。我用了一个简单的防抖:
let timer = null handleDatesSet(info) { clearTimeout(timer) timer = setTimeout(() => { this.loadEvents(info.startStr, info.endStr) }, 300) }300 毫秒的防抖足够应对快速切换。加载数据时,我会显示一个全屏 loading,避免重复点击。数据量大的月份,加载动画会持续一两秒,这个体验可以接受。
7. 踩坑记录:时区偏移、事件消失、日历白屏这类问题
7.1 时区偏移导致日期差一天
这是排班排课里最经典的坑。后端把时间存成 UTC 标准时间,返回给前端时带上了Z后缀,比如2024-06-10T00:00:00Z。FullCalendar 拿到后按本地时区解析,在中国就是东八区,日期直接加了 8 小时,显示成 6 月 10 日早上 8 点,而原本只想要 6 月 10 日一整天,结果就错位了。
全天的休息、请假这种记录最容易被这个坑害。我的解决办法简单粗暴:前后端约定统一使用本地时间字符串,不传时区后缀。后端返回2024-06-10 00:00:00,前端直接 new Date 解析也不会偏移。如果未来系统要跨时区使用,再全局切换到 UTC,但那是另一个量级的改造。
7.2 end 时间的"排他性"造成的边界问题
FullCalendar 对事件结束时长的计算是排他性的。课程 09:00 到 10:00,如果 end 传 10:00,渲染完全正常;但如果你把 end 传成数据库里存的"结束时间",而这个结束时间是 10:00,那么在计算课时长时,FullCalendar 会认为事件只到 09:59:59,某些视图下最后一分钟直接被吞掉。
全天事件更是如此。休息日这种 allDay 事件,结束日期必须传"次日"。比如 6 月 10 日休息,start 传 2024-06-10,end 必须传 2024-06-11,才表示完整的 6 月 10 日一天。这个约定我跟后端同学对齐过多次,最后直接在接口文档里写明:所有全天事件的结束日期均向后顺延一天。
7.3 拖拽后的残留与回滚失效
有段时间发现,拖拽某个课程之后,旧位置的时间块还在画面上,新位置也出现了一个时间块,看起来像复制了一份。排查下来,问题出在我直接修改了响应式数据里的 events 数组,并把修改后的数组塞回 props。FullCalendar 的内部事件缓存没有同步更新,于是新旧事件同时渲染。
解决办法是不要在业务侧直接改 events 数组里的对象。要么通过日历实例的 addEvent、removeEvent、refetchEvents 来操作,要么整体替换事件源并调用 refetchEvents 强制刷新。现在我的代码里,所有拖拽、新增、删除成功后的刷新操作,都是先重新请求数据,再整体替换事件源,彻底避开了这个问题。
7.4 弹窗和 Tab 切换导致日历白屏
日历放在 Tab 页签里,默认 Tab 是隐藏状态,切换过来后经常出现日历宽度为 0、内容挤成一团的白屏现象。原因很简单:日历在初始化时测量了容器宽度,那时容器是隐藏的,宽度为 0,它就把自己的宽度定格成 0 了。
解决思路有两种。一种是在 Tab 激活之后,手动调用日历的 updateSize 方法重新计算尺寸;另一种是给容器设置一个固定的最小高度和最小宽度,等日历可用后再用 css 过渡展示。我采用的是第一种,在 Tab 切换事件里触发:
this.$nextTick(() => { this.$refs.calendar && this.$refs.calendar.getApi().updateSize() })Element UI 的弹层(下拉框、日期选择器)和 FullCalendar 的事件弹出层有时会有 z-index 打架的问题。遇到的话,给弹层设置append-to-body或调整popper-options里的 z-index 即可,这个看具体情况调。
8. 体验优化与后续扩展思路
8.1 体验细节:事件上限、打印、移动端
日视图和月视图的事件一多,格子会变得非常拥挤。FullCalendar 提供了 dayMaxEvents 配置,超过指定数量后显示"更多"按钮,点击可以弹出完整列表。
dayMaxEvents: 3这个配置值不是越大越好。月视图里普通屏幕能清晰展示 3 到 4 个事件,再多就要靠滚动或弹层。我用 3 作为默认值,用户需要看完整一天时,切到周视图更直观。
打印排班表也是刚需。运营每个月要贴一张排班表到公告栏。我在样式表里加了专门的打印规则,把工具栏、导航按钮隐藏掉,只保留日历主体,然后通过 window.print 触发打印。打印样式要单独写,不能用页面初始化时的尺寸,否则打印出来不是过宽就是字体过大。
移动端方面,后台系统的日历在手机上体验一般。如果必须做移动端适配,我建议首次加载时根据屏幕宽度自动切换初始视图,窄屏默认 list 视图,宽度超过 768px 再显示月视图。
8.2 资源视图与多实例联动
有些排课需求是按"人"或"教室"的维度来排,每个老师一行,横向时间轴上排布课程。这种资源视图,FullCalendar 的官方高级插件里是有现成方案的,但它属于 Scheduler 插件,使用上有额外的许可限制。如果团队严格走合规,不想引入带限制的插件,我有两个替代思路:
一是做多日历实例联动。每个教室或每个老师生成一个独立的 FullCalendar 实例,纵向排列,切换日期时通过代码同步所有实例的视图。这个方案代码量会多一些,但组件没有额外许可负担。
二是自己写资源行。在 timeGridWeek 视图上,用 CSS 把事件按资源分组,把同一老师的课程固定在同一行区域里。这种方式实现起来比较绕,适合资源数较少、结构固定的场景。
8.3 后续升级 Vue3 的注意事项
项目整体还没升级 Vue3,但我提前了解过迁移路径。FullCalendar 6.x 对 Vue3 的适配器已经成熟,依赖包变成@fullcalendar/vue3,插件包统一归到@fullcalendar/*下面,事件模型和配置项基本延续 5.x 的风格。
如果哪天技术栈升级到 Vue3 + Element Plus,主要的工作量其实不在这套日历组件上,而是在表单弹窗和业务校验这一层。日历封装组件改改引入方式就能复用,这是一个当初做好隔离设计带来的长期收益。
最后说点实际的
排班排课这类功能,技术难点从来不在日历渲染,而在业务动作和数据边界。把"日历操作 → 业务动作 → 数据变更"这条链路在设计阶段就理清楚,后面会顺畅很多。我做这套功能时,一开始一心想把拖拽做得炫,结果被时区和批量冲突磨了两天,后来把交互收敛成几个稳定的动作,整个功能才真正稳下来。如果你正卡在类似问题上,建议先跑通最小闭环:新增、删除、拖拽刷新,再加批量;版本千万别随手装最新,Vue2 就用 5.x。这套日历方案我后来又复用在排课和预约场景,改改配置就能上,性价比确实高。