在 React 中使用 FullCalendar

2,643 阅读3分钟

前言

        FullCalendar 应该是现在做日历相关功能,一个比较多会选择的框架。虽然 FullCalendar 支持 React 有段时间了,但网上关于这方面的文章并不多,所以这里算是记录一下我自己的集成过程。

需求

  1. 日历提供 日,周,月,年视图,其中年视图 @fullcalendar 未提供,需要自己实现
  2. 在月视图中显示农历与节气
  3. 在月视图与年视图中,可以选某一天
  4. today 按钮,回到当前天,并选中当天
  5. 异步加载数据
  6. 显示中文

安装

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;

代码仓库地址