前言
FullCalendar 应该是现在做日历相关功能,一个比较多会选择的框架。虽然 FullCalendar 支持 React 有段时间了,但网上关于这方面的文章并不多,所以这里算是记录一下我自己的集成过程。
需求
- 日历提供 日,周,月,年视图,其中年视图 @fullcalendar 未提供,需要自己实现
- 在月视图中显示农历与节气
- 在月视图与年视图中,可以选某一天
- today 按钮,回到当前天,并选中当天
- 异步加载数据
- 显示中文
安装
npm install @fullcalendar/core \
@fullcalendar/react \
# 提供 dayGridMonth, dayGridWeek, dayGridDay, dayGrid 视图
@fullcalendar/daygrid \
# 如果需要 dateClick 需要该依赖
@fullcalendar/interaction \
# 提供 timeGridWeek, timeGridDay, timeGrid 视图
@fullcalendar/timegrid
@fullcalendar/core 与 @fullcalendar/react 为必须,其他的可以按实际情况选择,完整的插件可以查看官方插件列表
引入
import locale from '@fullcalendar/core/locales/zh-cn';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import FullCalendar from '@fullcalendar/react';
import timeGridPlugin from '@fullcalendar/timegrid';
使用
其中 yearGridPlugin 为自定义插件,先看效果
其中,蓝色当天,其他存在事件的,存在事件为黄色,事件多的话,色块会深点
通过 event-color-level-* 样式控制颜色
因为要支持日的选中效果,所以需要支持
options: { dayCellClassNames, dateClick }
这两个参数在日历初始化时,会设置
dayGridYear: {
dayCellClassNames(item) {
const _classnames = [];
if (!item.isOther && moment(item.date).isSame(moment(selectedDay), 'date')) {
_classnames.push('fc-day-selected');
}
return _classnames;
},
dateClick(event) {
handleSelectedDay(event.date);
},
},
yearGridPlugin 插件完整代码在最后面
如果要支持点击日支持选中,需要使用 fullCalendar 的 api
const fullCalendar = useRef<FullCalendar>(null);
const calendarApi = fullCalendar.current?.getApi();
# 通过 ref={withFullCalendar} 获取 fllcalender 的 api
const withFullCalendar = useCallback(
(_fullCalendar: FullCalendar) => {
(fullCalendar as any).current = _fullCalendar;
},
[setFullCalendar],
);
<FullCalendar
ref={withFullCalendar}
...
// 之后 dateClick 时,调用如下,代码,就会切换到指定的日期
calendarApi.gotoDate(selectedDay);
比如自定义今天的逻辑
customButtons={{
customToDay: {
text: '今天',
click: function () {
calendarApi.gotoDate(new Date());
},
},
}}
效果如下
月视图显示农历与节气
为了显示农历与节气,我们需要一个方便的第三方库,并同时重写 dayGridMonth 视图的部分逻辑
import { Lunar } from 'lunar-javascript'; // 计算农历与节气
// 将普通的 Date 对象转为 lunar 对象
const lunar = Lunar.fromDate(item.date);
// 农历
lunar.getDayInChinese()
// 节气
lunar.getJieQi()
dayGridMonth: {
// 设置选中的样式
dayCellClassNames(item) {
const _classnames = [];
if (item.isOther) {
const prevmont = moment(item.view.currentStart).subtract(1, 'days');
const nextmonth = moment(item.view.currentEnd);
const prevdays = moment(item.date).diff(moment(prevmont), 'days');
const nextdays = moment(item.date).diff(moment(nextmonth), 'days');
if (prevdays <= 0 && prevdays >= -6) {
_classnames.push('fc-day-prevmonth');
if (prevdays == 0 && item.dow != 6) {
_classnames.push('fc-day-month-divider');
}
}
if (nextdays >= 0 && nextdays <= 6) {
_classnames.push('fc-day-nextmonth');
if (nextdays == 0 && item.dow != 0) {
_classnames.push('fc-day-month-divider');
}
}
}
if (moment(item.date).isSame(moment(selectedDay), 'day')) {
_classnames.push('fc-day-selected');
}
return _classnames;
},
// 重写单元格,显示农历
dayCellContent(item) {
const lunar = Lunar.fromDate(item.date);
return (
<>
<label className="fc-daygrid-day-lunar">
{lunar.getDayInChinese()} {lunar.getJieQi()}
</label>
<label
className="fc-daygrid-day-solar"
data-date={moment(item.date).format()}
onClick={handleNavLink}
>
{item.date.getDate()}
</label>
</>
);
},
// 点击日期时,设置选中事件,当前也可以判断一些双击操作
dateClick(event) {
if (isDoubleClick(event.dayEl)) {
handleNewEvent(event.date);
console.log('双击事件', event);
} else {
handleSelectedDay(event.date);
}
},
eventClick(e) {
console.log(e);
},
},
加载数据, 为 events 属性设置一个加载函数,当日历显示的开始与结束时间都会传递到该函数,然后自己通过自己定义的 loadCalendarEvents 查询对应的事件,返回给 callback 函数就会刷新日历显示的事件了
const handleEventSource = useCallback( ( arg: { start: Date; end: Date; startStr: string; endStr: string; timeZone: string }, callback: SuccessCallback, ) => { loadCalendarEvents({ variables: { calendarSet: calendarSet == 'all' ? undefined : calendarSet, starts: arg.startStr, ends: arg.endStr, }, }).then(callback); }, [calendarSet, loadCalendarEvents], );
使用
<FullCalendar
.... events={handleEventSource as any}
完整代码
FullCalendar 初始化完整代码
<FullCalendar
ref={withFullCalendar}
plugins={[dayGridPlugin, timeGridPlugin, yearGridPlugin, interactionPlugin]}
locale={locale}
initialView="dayGridMonth"
customButtons={{
customToDay: {
text: '今天',
click: function () {
setSelectedDay(new Date());
},
},
}}
headerToolbar={{
left: 'prev,customToDay,next',
center: 'title',
right: 'timeGridDay,timeGridWeek,dayGridMonth,dayGridYear',
}}
views={{
dayGridMonth: {
dayCellClassNames(item) {
const _classnames = [];
if (item.isOther) {
const prevmont = moment(item.view.currentStart).subtract(1, 'days');
const nextmonth = moment(item.view.currentEnd);
const prevdays = moment(item.date).diff(moment(prevmont), 'days');
const nextdays = moment(item.date).diff(moment(nextmonth), 'days');
if (prevdays <= 0 && prevdays >= -6) {
_classnames.push('fc-day-prevmonth');
if (prevdays == 0 && item.dow != 6) {
_classnames.push('fc-day-month-divider');
}
}
if (nextdays >= 0 && nextdays <= 6) {
_classnames.push('fc-day-nextmonth');
if (nextdays == 0 && item.dow != 0) {
_classnames.push('fc-day-month-divider');
}
}
}
if (moment(item.date).isSame(moment(selectedDay), 'day')) {
_classnames.push('fc-day-selected');
}
return _classnames;
},
dayCellContent(item) {
const lunar = Lunar.fromDate(item.date);
return (
<>
<label className="fc-daygrid-day-lunar">
{lunar.getDayInChinese()} {lunar.getJieQi()}
</label>
<label
className="fc-daygrid-day-solar"
data-date={moment(item.date).format()}
onClick={handleNavLink}
>
{item.date.getDate()}
</label>
</>
);
},
dateClick(event) {
if (isDoubleClick(event.dayEl)) {
handleNewEvent(event.date);
console.log('双击事件', event);
} else {
handleSelectedDay(event.date);
}
},
eventClick(e) {
console.log(e);
},
},
timeGridWeek: {
dayCellClassNames(item) {
const _classnames = [];
if (moment(item.date).isSame(moment(selectedDay), 'day')) {
_classnames.push('fc-day-selected');
}
return _classnames;
},
},
dayGridYear: {
dayCellClassNames(item) {
const _classnames = [];
if (!item.isOther && moment(item.date).isSame(moment(selectedDay), 'date')) {
_classnames.push('fc-day-selected');
}
return _classnames;
},
dateClick(event) {
handleSelectedDay(event.date);
},
},
}}
aspectRatio={3}
firstDay={0}
contentHeight={contentHeight}
nowIndicator={true}
selectable={true}
selectMirror={true}
dayMaxEvents={true}
dateClick={(...args) => {
console.log('dateClick', args);
}}
events={handleEventSource as any}
/>
插件完整代码如下
import { useCallback, useMemo } from 'react';
import type {
CalendarApi,
ClassNamesGenerator,
DayCellContentArg,
Identity,
ViewOptionsRefined,
ViewProps,
} from '@fullcalendar/common';
import { DateComponent } from '@fullcalendar/common';
import type { DateClickArg } from '@fullcalendar/interaction';
import type { EventRenderRange } from '@fullcalendar/react';
import { createPlugin, sliceEvents } from '@fullcalendar/react';
import classnames from 'classnames';
import type { Moment } from 'moment';
import moment from 'moment';
type DayData = {
key: string;
dayNumberText: string;
isOther: boolean;
isPast: boolean;
isFuture: boolean;
date: Moment;
};
type MonthData = {
key: string;
title: string;
date: Moment;
days: DayData[];
};
type DayGridMonthProps = {
api: CalendarApi;
month: MonthData;
events: EventRenderRange[];
stat: {
dates: Map<
string,
{
number: number;
events: EventRenderRange[];
}
>;
max: number;
};
dateClick: Identity<(arg: DateClickArg) => void>;
dayCellClassNames: Identity<ClassNamesGenerator<DayCellContentArg>>;
};
function DayGridMonth(props: DayGridMonthProps) {
const { month, stat, dateClick, dayCellClassNames, api } = props;
const days = month.days;
const getEventNumber = useCallback(
(day: Moment) => {
const key = day.format('YYYYMMDD');
if (!stat.dates.has(key)) {
return 0;
}
return stat.dates.get(key)?.number || 0;
},
[stat],
);
const handleClick = (item: DayData) => () => {
dateClick({ ...item, date: item.date.toDate() } as any);
api.gotoDate(item.date.toDate());
};
return (
<div className="day-grid-month">
<h3>{month.title}</h3>
<table>
<thead>
<tr>
<th>周日</th>
<th>周一</th>
<th>周二</th>
<th>周三</th>
<th>周四</th>
<th>周五</th>
<th>周六</th>
</tr>
</thead>
<tbody>
{[1, 2, 3, 4, 5, 6].map((i) => (
<tr key={i}>
{days.slice((i - 1) * 7, i * 7).map((item) => {
const eventNumber = getEventNumber(item.date);
return (
<td key={item.key}>
<div
onClick={handleClick(item)}
className={classnames(
'day-of-month',
{
'is-other': item.isOther,
[`event-color-level-${stat.max == 1 ? 2 : eventNumber}`]:
!item.isOther && eventNumber > 0,
'is-today': !item.isOther && moment().isSame(item.date, 'date'),
},
dayCellClassNames({
...item,
date: item.date.toDate(),
} as any),
)}
>
{item.dayNumberText}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
type DayGridYearProps = ViewProps & {
api: CalendarApi;
options: ViewOptionsRefined;
};
function DayGridYear(props: DayGridYearProps) {
const {
api,
options: { dayCellClassNames, dateClick },
dateProfile: {
currentRange: { start, end },
},
} = props;
const months: MonthData[] = useMemo(() => {
const _months = [];
const ms = moment(end).diff(moment(start), 'months');
for (let i = 0; i < ms; i++) {
const m = moment(start).clone().month(i);
const _month: MonthData = {
key: m.format('YYYYMM'),
title: m.format('MMMM'),
date: m,
days: [],
};
_months.push(_month);
const mstart = m.clone().date(1);
const cstart = mstart.clone().day(0);
for (let j = 0; j < 42; j++) {
const day = cstart.clone().add(j, 'days');
const _dif = day.clone().date(1).diff(mstart, 'months');
_month.days.push({
key: day.format('YYYYMMDD'),
date: day,
isPast: _dif < 0,
isFuture: _dif > 0,
isOther: _dif != 0,
dayNumberText: day.date() + '',
});
}
}
return _months;
}, [start, end]);
const events = sliceEvents(props as any, true);
const stat = useMemo(() => {
let max = 1;
const dates = new Map<string, { number: number; events: EventRenderRange[] }>();
for (const event of events) {
const mstart = moment(event.range.start);
let days = moment(event.range.end).diff(mstart, 'day');
do {
const key = mstart.format('YYYYMMDD');
if (dates.has(key)) {
const number = dates.get(key)!.number! + 1;
max = Math.max(max, number);
dates.set(key, { number, events: [...dates.get(key)!.events, event] });
} else {
dates.set(key, { number: 1, events: [event] });
}
mstart.add(1, 'days');
} while (--days > 0);
}
return { dates, max };
}, [events]);
return (
<div className="fc-daygrid fc-dayGridYear-view fc-view">
{[1, 2, 3].map((i) => {
return (
<div key={`${(i - 1) * 4}-${i * 4}`} className="row">
{months.slice((i - 1) * 4, i * 4).map((item) => (
<div className="col" key={item.key}>
<DayGridMonth
api={api}
stat={stat}
dateClick={dateClick as any}
dayCellClassNames={dayCellClassNames as any}
month={item}
events={events}
/>
</div>
))}
</div>
);
})}
</div>
);
}
class YearView extends DateComponent<ViewProps> {
render() {
const { calendarApi, options } = this.context;
return <DayGridYear {...this.props} api={calendarApi} options={options} />;
}
}
const dayGridYearPlugin = createPlugin({
views: {
dayGridYear: {
buttonText: '年',
duration: { years: 1 },
titleFormat: (arg: any) => {
return arg.date.year + '年';
},
component: YearView,
},
},
});
export default dayGridYearPlugin;