阅读视图

发现新文章,点击刷新页面。

在 Vue 项目中玩转 FullCalendar:从零搭建可交互的事件日历

在很多业务场景中,我们都需要把「时间维度上的事件」清晰地呈现在一个可交互的日历里,并和其它数据视图(图表、报表、分析面板等)联动。本文基于一个典型的「事件日历 + 外部报表联动」场景,总结一套在 Vue 项目中落地 FullCalendar 的通用实践。

概览

FullCalendar 是最受欢迎的js calendar组件

官网:fullcalendar.io/

demos: fullcalendar.io/demos

vue demo: github.com/fullcalenda…

安装

使用 NPM 或 Yarn 安装软件包core以及您计划使用的任何插件(按需安装) 插件列表:fullcalendar.io/docs/plugin…

标题 描述 示意
@fullcalendar/core 必须
@fullcalendar/vue vue2项目
@fullcalendar/vue3 vue3项目
@fullcalendar/interaction 支持点击、拖拽等事件
@fullcalendar/daygrid DayGrid 视图 image.png
@fullcalendar/timegrid timegrid视图 image.png
@fullcalendar/resource-timeline 时间轴视图 image.png
npm install \
  @fullcalendar/core \ 
  @fullcalendar/vue  \  
  @fullcalendar/daygrid \   
  @fullcalendar/timegrid \   
  @fullcalendar/interaction \  

如果是vue3项目用@fullcalendar/vue3

使用

引入组件

<template>
    <FullCalendar ref="myCalendar" :options="calendarOptions" />
</template>

<script>
  // 引入已经安装好的,页面所需要的 FullCalendar 插件
  import FullCalendar from '@fullcalendar/vue'
  import dayGridPlugin from '@fullcalendar/daygrid'
  import timeGridPlugin from '@fullcalendar/timegrid'
  import interactionPlugin from '@fullcalendar/interaction'
  // 日历参数配置
  const calendarOptions = {}

  export default {
    name: "my-calendar",
    components: {
      FullCalendar
    },
    data () {
      return {
        calendarOptions
      }
    }
  }
</script>

参数配置

dayGridMonth视图的简单配置参考:

calendarOptions = {
        locale: 'zh-cn',
        plugins: [
          dayGridPlugin,
          // interactionPlugin, // needed for dateClick
        ],
        headerToolbar: {
          left: 'prev,next today',
          center: 'title',
          right: 'dayGridMonth',
        },
        buttonText: { today: '今天', prev: '上个月', next: '下个月', dayGridMonth: '月' }, // 设置按钮文本内容
        initialView: 'dayGridMonth',
        initialEvents: [], // alternatively, use the `events` setting to fetch from a feed

        firstDay: 1,
        aspectRatio: 1.35, // 日历单元格宽高比 默认值:1.35
        eventColor: '#3a79eb', // 日历中事件的默认背景色颜色,优先级低于添加事件时设置的背景色
        dayMaxEvents: true,
        editable: false,
        selectable: false,
        selectMirror: true,
        dayMaxEvents: true,
        weekends: true,
        events: this.fetchEvents, // 获取事件
        eventClick: this.handleEventClick, // 点击事件
        eventsSet: this.handleEvents,
        // you can update a remote database when these fire:
        // eventChange: this.handleEventChange        // eventAdd:
        // eventRemove:

详细的配置可参考文章:blog.csdn.net/FlowGuanEr/…

slot模板

<template>
  <FullCalendar :options="calendarOptions">
    <template v-slot:eventContent='arg'>
      <b>{{ arg.event.title }}</b>
    </template>
  </FullCalendar>
</template>

Calendar API

let calendarApi = this.$refs.myCalendar.getApi() 
calendarApi.next()

事件

1. 事件对象

var calendar = new Calendar(calendarEl, {
  timeZone: 'UTC',
  events: [
    {
      id: 'a',
      title: 'my event',
      start: '2018-09-01',
      end: '2018-09-01'
    }
  ]
})

更多字段详解:fullcalendar.io/docs/event-…

2. 初始化事件

可以在initialEvents配置初始化事件列表

也可以用events中配置方法,调用接口去获取数据,将数据格式化成事件对象规范的格式,显示事件列表。

事件排序:接口返回的顺序可能杂乱,建议在传给 successCallback 前对列表按 start(及可选的 endtitle)排序,这样同一天内多事件在月视图中的展示顺序一致、可预期(FullCalendar 会按你传入的顺序在同一格内排列)。

fetchEvents (info, successCallback, failureCallback) {
  // info.start / info.end 是当前视图的起止时间
  requestAPI(api.fetchCalendarEvents, {
    startTime: info.start.getTime(),
    endTime: info.end.getTime()
  }).then(data => {
    if (data?.length) {
      const list = data.map((item) => ({
        id: item.eventId,
        title: item.eventTitle,
        start: moment(item.startTime).format('YYYY-MM-DD'),
        end: moment(item.endTime + MS_PER_DAY).format('YYYY-MM-DD')
      }))
      // 按开始时间排序,同一天内按结束时间、再按标题排序,保证展示顺序稳定
      list.sort((a, b) => {
        const startDiff = new Date(a.start) - new Date(b.start)
        if (startDiff !== 0) return startDiff
        const endDiff = new Date(a.end) - new Date(b.end)
        if (endDiff !== 0) return endDiff
        return (a.title || '').localeCompare(b.title || '')
      })
      successCallback(list)
    } else {
      failureCallback()
    }
  }).catch(failureCallback)
}

3. 事件回调

实战:事件日历与外部报表的联动方案

下面是一个抽象化的实战场景:在某个运营看板页面,我们用 FullCalendar 搭建了一个「事件日历」,并和右侧的数据分析报表(通过 iframe 嵌入)联动,整体思路可以概括为三步:

  1. Calendar 只负责展示事件排期
    • 通过 events: this.fetchEvents 懒加载当前视图范围内的事件,避免一次性加载整年数据。
    • 接口返回后在前端做一次格式化,转成 FullCalendar 认可的事件对象:
      • id: 使用 eventId 标识事件;
      • title: 使用 eventTitle 作为日历上展示的文案;
      • start / end: 用 moment 格式化为 YYYY-MM-DD,结束时间额外 +1 天,避免跨天活动少算一天。
    • 在调用 successCallback(list) 前对 liststartendtitle 排序,保证同一天内多事件的展示顺序稳定(见上文「事件排序」)。
fetchEvents (info, successCallback, failureCallback) {
  requestAPI(api.fetchCalendarEvents, {
    startTime: info.start.getTime(),
    endTime: info.end.getTime()
  }).then(data => {
    if (data?.length) {
      const list = data.map(item => ({
        id: item.eventId,
        title: item.eventTitle,
        start: moment(item.startTime).format('YYYY-MM-DD'),
        end: moment(item.endTime + MS_PER_DAY).format('YYYY-MM-DD')
      }))
      list.sort((a, b) => {
        const startDiff = new Date(a.start) - new Date(b.start)
        if (startDiff !== 0) return startDiff
        const endDiff = new Date(a.end) - new Date(b.end)
        if (endDiff !== 0) return endDiff
        return (a.title || '').localeCompare(b.title || '')
      })
      successCallback(list)
    } else {
      failureCallback()
    }
  }).catch(failureCallback)
}
  1. 点击日历事件,高亮并联动外部报表
    • eventClick 中拿到被点击的事件,做两件事:
      • 把上一次选中的事件颜色还原为默认蓝色;
      • 把当前事件改成高亮色,并记录 currentEventId
    • 同时,基于事件编号 eventId 拼接外部报表地址,赋值给 pageUrl,iframe 会自动切到该事件对应的数据分析页面:
handleEventClick (clickInfo) {
  if (clickInfo?.event?.id) {
    this.currentEvents.forEach(event => {
      if (event.id === this.currentEventId) {
        event.setProp('color', '#3a79eb')
      }
    })
    clickInfo.event.setProp('color', '#db3491')
    this.currentEventId = clickInfo.event.id
    this.pageUrl = `${REPORT_BASE_URL}?eventId=${clickInfo.event.id}`
  }
}
  1. 通过插槽自定义事件渲染
    • 使用 eventContent 插槽可以灵活控制日历单元里的展示结构,比如在运营看板里我们希望同时显示「活动时间 + 活动名称」:
<FullCalendar class="calendar-app-calendar" :options="calendarOptions">
  <template v-slot:eventContent="arg">
    <b>{{ arg.timeText }}</b>
    <i>{{ arg.event.title }}</i>
  </template>
</FullCalendar>

综合以上三点,一个完整的「事件日历 + 数据分析联动」就搭建好了:
使用者只需要在日历上点选某个事件,对应的外部报表就会自动切换到该事件的分析视图,从「时间排期」自然跳转到「结果分析」,大大提升日常分析效率。

如何优雅地上传大文件?分片上传实战指南

一、背景与流程

当文件体积较大(如视频)时,直接上传容易超时、失败,且无法展示进度。分片上传将文件切成多块依次上传,再在服务端合并,可提升稳定性和体验。

整体流程

初始化(init) → 判断是否分片 → 切片 → 逐块上传(upload) → 合并(merge)
                                    ↑
                            异常/取消时调用 abort

二、核心步骤

2.1 初始化

向服务端发起初始化请求,获取本次上传的 uploadIdfid 及建议的 chunkSize。若返回的 chunkCount <= 1,则走普通单文件上传,不分片。

项目 说明
请求方式 GET
参数 fileName、fileSize、chunkSize(可选)
响应 chunkCount、fid、uploadId、chunkSize

2.2 切片

  • 使用 File.prototype.slice(start, end) 切分文件
  • 每片建议 5M~70M,每片 ≥ 5M
  • 若最后一块 < 5M,需与倒数第二块合并

2.3 逐块上传

  • 请求方式:POST,Content-Type: multipart/form-data
  • 每块需传:fid、uploadId、chunkIndex(从 0 开始)、file
  • 每块返回 chunkTag(JSON 字符串),需按顺序收集,供合并使用
  • 建议控制并发数,避免压垮服务端

2.4 合并

  • 请求方式:POST
  • 参数:fid、uploadId、chunkTagList(按 partNumber 顺序的 JSON 数组字符串)
  • 成功即上传完成,返回最终文件标识

2.5 放弃上传(abort)

初始化后若异常或用户取消,应调用 abort 接口,传入 fid、uploadId,释放服务端资源。


三、关键代码示例

3.1 切片与合并最后小块

const MIN_CHUNK = 5 * 1024 * 1024  // 5M
const CHUNK_SIZE = 50 * 1024 * 1024  // 50M

function createChunkList(file, chunkSize = CHUNK_SIZE) {
  let chunkCount = Math.ceil(file.size / chunkSize)
  const chunkList = []
  for (let i = 0; i < chunkCount; i++) {
    const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize)
    chunkList.push(new File([chunk], file.name))
  }
  // 最后一块 < 5M 必须与前一块合并
  if (chunkList.length > 1 && chunkList[chunkList.length - 1].size < MIN_CHUNK) {
    const lastTwo = new Blob([
      chunkList[chunkList.length - 2],
      chunkList[chunkList.length - 1]
    ])
    chunkList[chunkList.length - 2] = new File([lastTwo], file.name)
    chunkList.pop()
  }
  return chunkList
}

3.2 分片上传主流程

async function uploadChunks(file, initResult, options) {
  const { fid, uploadId, chunkSize } = initResult
  const chunkList = createChunkList(file, chunkSize)
  const chunkTagList = []
  const total = chunkList.length

  for (let i = 0; i < chunkList.length; i++) {
    if (options.abortRequested) {
      await callAbort(fid, uploadId)
      options.onError('已取消上传')
      return
    }
    const chunkTag = await uploadOneChunk({
      file: chunkList[i],
      fid,
      uploadId,
      chunkIndex: i,
      onProgress: options.onProgress,
      partPercent: ((i + 1) / total) * 100
    })
    if (!chunkTag) {
      options.onError('分片上传失败')
      return
    }
    chunkTagList.push(chunkTag)
  }

  await mergeChunks(fid, uploadId, chunkTagList, options)
}

3.3 单块上传(含进度)

import axios from 'axios'

function uploadOneChunk({ file, fid, uploadId, chunkIndex, onProgress, partPercent }) {
  const formData = new FormData()
  formData.append('fid', fid)
  formData.append('uploadId', uploadId)
  formData.append('chunkIndex', chunkIndex)
  formData.append('file', file)

  return axios.post(`${UPLOAD_BASE}/chunk/upload/${fid}`, formData, {
    onUploadProgress: (e) => {
      if (e.lengthComputable) {
        const percent = (e.loaded / e.total) * partPercent
        onProgress({ percent })
      }
    }
  }).then(res => {
    const data = res.data
    if (data.code === 200 && data.result?.chunkTag) {
      return JSON.parse(data.result.chunkTag)
    }
    return null
  }).catch(() => null)
}

3.4 合并

import axios from 'axios'

function mergeChunks(fid, uploadId, chunkTagList, options) {
  const formData = new FormData()
  formData.append('fid', fid)
  formData.append('uploadId', uploadId)
  formData.append('chunkTagList', JSON.stringify(chunkTagList))

  axios.post(`${UPLOAD_BASE}/chunk/merge/${fid}`, formData).then(res => {
    const data = res.data
    if (data.code === 200) options.onSuccess(data)
    else options.onError(data.msg || '合并失败')
  }).catch(() => options.onError('合并失败'))
}

3.5 进度计算

普通上传:percent = (loaded / total) * 100

分片上传:按块权重累加

// 当前块进度 × 该块占总进度的权重
const partPercent = ((currentChunkIndex + 1) / totalChunks) * 100
const overallPercent = (chunkLoaded / chunkTotal) * partPercent

四、实现要点

说明
FormData axios 传 FormData 时无需手动设置 Content-Type,由 axios 自动添加 boundary
chunkTag 响应为 JSON 字符串,需 JSON.parse 后按顺序 push 到 chunkTagList
并发 建议串行或限制并发数,避免服务端压力过大
取消 用户取消或异常时调用 abort,释放服务端资源
鉴权 按项目约定在 FormData 或请求头中附带 token 等鉴权信息

五、检查清单

  • 初始化用 GET,chunkCount ≤ 1 时走普通上传
  • chunkSize 在 5M~70M,每片 ≥ 5M,最后小块已合并
  • 切片顺序与 chunkIndex 一致,chunkTagList 按 partNumber 顺序
  • 分片上传控制并发,取消/异常时调用 abort

六、视频播放:进度条拖动与 Range 请求排查

分片上传后,视频通常通过 CDN 或文件服务直链播放。若 <video> 标签的进度条无法拖动、或拖动后跳转失败,多半与 HTTP Range 请求 有关。

6.1 原理简述

拖动进度条本质是「跳转播放位置」。浏览器会发起带 Range 头的请求,按需拉取视频片段:

GET /video.mp4
Range: bytes=1048576-2097151

服务端需返回 206 Partial Content 及对应字节范围,否则无法按需跳转。

6.2 常见问题与排查

现象 可能原因 排查方法
进度条拖动无效 服务端未支持 Range 在 Network 面板查看请求是否有 Range 头,响应是否为 206
只能从头播放 未返回 Accept-Ranges: bytes 检查响应头是否包含 Accept-Ranges: bytes
跨域视频无法 seek CORS 未正确配置 服务端需返回 Access-Control-Allow-OriginAccess-Control-Expose-Headers: Content-Length, Content-Range
部分时段可拖动、部分不可 视频 moov 在文件末尾 MP4 的 moov 元数据在末尾时,需先下载到末尾才能 seek。用 ffmpeg -movflags faststart 将 moov 移到文件头
小文件可拖动、大文件不可 大文件未做 Range 支持 确认 CDN / 对象存储 / 反向代理均已开启 Range 支持

6.3 快速排查步骤

  1. 打开开发者工具 → Network,播放视频并拖动进度条。
  2. 观察视频请求:
    • 是否有 Request Headers: Range: bytes=xxx-xxx
    • 响应状态是否为 206 Partial Content
    • 响应头是否包含 Accept-Ranges: bytes
  3. 若为跨域视频,检查响应头是否包含 Access-Control-Allow-Origin 等 CORS 头。
  4. 若服务端不支持 Range,在 Nginx 等配置中可添加:
add_header Accept-Ranges bytes;
  1. 若为 MP4 格式,可用 ffmpeg 优化:
ffmpeg -i input.mp4 -movflags faststart output.mp4

6.4 前端 preload 影响

<video preload="metadata"> 只加载元数据,不预加载内容。在未缓冲到的区域拖动时,必须依赖服务端 Range 支持才能跳转。若改为 preload="auto" 可预加载更多,可 seek 范围更大,但会增加流量消耗。

❌