【JS】vis.js使用之vis-timeline使用攻略,vis-timeline在vue3中实现时间轴、甘特图

vis.js是一个基于浏览器的可视化库,它提供了多个组件,包括DataSet, Timeline, Network, Graph2d和Graph3d。该库具有易用性、能够处理大量动态数据和允许数据操作和交互的特点。

vis.js

1、vis-timeline简介

vis-timeline时间轴是一个交互式可视化图表,用于实时可视化时间数据。数据项可以只与某个时间点关联,也可以有开始和结束日期(即一个时间范围)。vis-timeline可以通过拖拽和滚动时间轴自由移动和缩放。可以在时间轴中创建、编辑和删除数据项目。轴上的时间尺度是自动调整的,支持从毫秒到年的尺度。

vis-time时间轴使用常规HTML DOM呈现时间轴和放在时间轴上的项目,这样的好处就是可以使用自定义css样式进行灵活定制。

Timeline 地址
vis.js官网 https://visjs.org/
vis-timeline官方英文文档 https://visjs.github.io/vis-timeline/docs/timeline/
vis-timeline官方示例 https://visjs.github.io/vis-timeline/examples/timeline/
vis-timeline的github源码 https://github.com/visjs/vis-timeline

可实现效果如下:纵向可以分组,横向可以是时间轴,每个item项目可以自定义内容与样式。
实现效果

2、安装插件及依赖

// vis-timeline包
cnpm install -S vis-timeline

// vis.js提供的可以实现数据双向绑定的包
cnpm install -S vis-data

// 实现时间轴中文的moment.js库的包
cnpm install -S moment

3、简单示例

<template>
  <div class="bindNurseToRoom-container">
    <!-- 时间轴-绑定元素 -->
    <div ref="timelineRef" id="timeline" class="bindNurseToRoom-container"></div>
  </div>
</template>

<script setup lang="ts" name="bindNurseToRoom">
import {
      
       onMounted, ref, watch, nextTick, reactive, defineAsyncComponent } from 'vue';
import "vis-timeline/styles/vis-timeline-graph2d.min.css";
import {
      
       DataSet } from 'vis-data'; // 为timeline提供双向数据绑定,加快渲染速度
import {
      
       Timeline } from "vis-timeline"; //standalone,peer不同的包装方式
import moment from 'moment';
import  "moment/dist/locale/zh-cn.js";
import {
      
       ElMessage, ElMessageBox } from 'element-plus';
import {
      
       useOperatingRoomApi } from '/@/api/room/operatingRoom';
import {
      
       useOperationScheduleStore } from '/@/stores/operationScheduleStore';
const _useOperationScheduleStore = useOperationScheduleStore();

// 引入组件
const BindNurseToRoomDialog = defineAsyncComponent(() => import('./bindNurseToRoomDialog.vue'));

const timelineRef = ref(null);
const bindDialogRef = ref();
// 定义父组件传过来的值
const props = defineProps({
      
      
	// 当前操作时间
	operateTime: {
      
      
		type: String,
		default: () => '',
	},
	// 配置项
	config: {
      
      
		type: Object,
		default: () => {
      
      },
	},
});
const curOperateTime = ref(''); // 当前操作日期格式化的字符串 或 undefined 或 ""

let dataList:any = new DataSet([
  // {
      
      
  //   id: 1,
  //   content: "手术1",
  //   start: "2023-04-07 08:00",
  //   end: "2023-04-07 10:00",
  //   group: "5a92fde514c2c842f680885b1d31b9b8",
  //   style: "color: white; background-color: #1abc9c;",
  //   idCard: "123456",
  //   patientName: "李秀莲",
  //   doctorName: "李莲",
  //   anaesthesiaType: "局部",
  // },
  // {
      
      
  //   id: 2,
  //   content: "手术2",
  //   start: "2023-04-06 10:00",
  //   end: "2023-04-06 12:00",
  //   group: "手术室1",
  //   style: "color: white; background-color: #2ecc71;",
  //   idCard: "12346",
  //   patientName: "李秀",
  //   doctorName: "李莲",
  //   anaesthesiaType: "局部",
  // },
  // {
      
      
  //   id: 3,
  //   content: "手术3",
  //   start: "2023-04-06 08:30",
  //   end: "2023-04-06 09:30",
  //   group: "手术室2",
  //   style: "color: white; background-color: #3498db;",
  //   idCard: "1456",
  //   patientName: "莲",
  //   doctorName: "李",
  //   anaesthesiaType: "局部",
  // },
  // {
      
      
  //   id: 4,
  //   content: `<div style="display:block;height:100px;background:red;">
  //       123123123213
  //       </div> `, //content接收字符串类型的文本或html
  //   start: "2023-04-06 11:00",
  //   end: "2023-04-06 14:00",
  //   group: "手术室2",
  //   style: "color: white; background-color: #9b59b6;",
  //   idCard: "1236",
  //   patientName: "李的",
  //   doctorName: "李莲时",
  //   anaesthesiaType: "局部",
  // },
  // {
      
      
  //   id: 5,
  //   content: `<div style="display:block;height:100px;background:red;">手术5</div> `,
  //   start: "2023-04-06 06:30",
  //   end: "2023-04-06 10:10",
  //   group: "手术室5",
  //   className:'icu',
  //   editable: false, // 给某个特定的设置为不可编辑
  //   idCard: "156",
  //   patientName: "李秀消",
  //   doctorName: "李莲",
  //   anaesthesiaType: "局部",
  // },
]);

const state:any = reactive({
      
      
  groups: null, // 手术室分组-new DataSet()格式的数据集
  timeline: null, // 手术室当前排班时间轴-new DataSet()格式的数据集
});

// 监听当前操作日期变化
watch(
	() => props.operateTime,
	(newValue: any) => {
      
      
		if (newValue) {
      
      
      curOperateTime.value = newValue; // 保存当前操作日期到变量中,以便以后使用。
      nextTick(async () => {
      
      
        if(state.timeline){
      
      
          // state.timeline.setItems([], { clearNetwork: false });
          // state.timeline.destroy(); // 销毁时间轴
        } 
        await getOperationRoom();
        await renderTimeLine(); // 渲染时间轴
        state.timeline.redraw();
      });
		}
	},
  {
      
       immediate : true } //在组件初次加载的时候执行
);

onMounted(async () => {
      
      
  state.timeline = new Timeline(
    timelineRef.value as unknown as HTMLElement, //document.getElementById("timeline") as HTMLElement, 
    dataList, 
    {
      
      
      locale: 'zh-cn', //moment.locale('zh-cn'), // 时间轴国际化
      editable: {
      
      
        add: true,         // 双击添加新项-add new items by double tapping
        updateTime: true,  // 水平拖拉项目-drag items horizontally
        updateGroup: true, // 从一个分组拖拽到另一个分组-drag items from one group to another
        remove: true,       // 通过右上角按钮删除项目-delete an item by tapping the delete button top right
        // overrideItems: false  // allow these options to override item.editable
      },
      selectable: true,
      // height: '730px', // 时间轴高度
      minHeight: 400, // timeline表格的最小高度
      maxHeight: 750, // timeline表格的最大高度
      groupHeightMode: 'fixed', // 指定分组高度: 自动auto, fixed固定, fitItems适应项目
      stack: false, // ture则不重叠
      verticalScroll: true, // 竖向滚动
      orientation: 'top', // 时间轴位置
      showCurrentTime: true, // 显示当前时间
      zoomKey: "ctrlKey", // 缩放按键
      zoomMax: 1000 * 60 * 60 * 24,
      zoomMin: 1000 * 60 * 30,
      moment: function(date:any) {
      
      
        return moment(date).locale('zh-cn'); //vis.moment(date).utcOffset('+08:00');
      },
      // 显式将此选项设置为true以完全禁用Timeline的XSS保护
      xss: {
      
      
        disabled: true,
      },
      //可以提供模板处理程序。(或许可以直接放插槽?待测试)
      //此处理程序是一个函数,接受项的数据作为第一个参数,项元素作为第二个参数,编辑后的数据作为第三个参数,并输出格式化的HTML:
      template: function (sourceData:any, targetElement:any, parsedData:any) {
      
      
        console.log('parsedData: ', parsedData);
        targetElement.className = 'custom-item-template-class'; // 将自定义class写在className属性中
        return `<div class="custom-item ${ 
        sourceData.customClassName}">
                  <div class="top">
                    <span>
                      ${ 
        moment(sourceData.start).format('YYYY-MM-DD HH:mm:ss').split(' ')[1]}
                      -${ 
        moment(sourceData.end).format('YYYY-MM-DD HH:mm:ss').split(' ')[1]}
                    </span> 
                    <span>${ 
        sourceData.doctorName}</span>
                  </div>
                  <div class="center-box">
                    <div class="info">
                      <span>${ 
        sourceData.patientName}</span> &nbsp;
                      <span>${ 
        sourceData.idCard}</span> &nbsp;
                      <span>${ 
        sourceData.sex?'男':'女'}</span> &nbsp;
                      <span>${ 
        sourceData.age}岁</span>
                    </div>
                    <h3>${ 
        sourceData.content}</h3>
                  </div>
                  <div class="nurse-box">
                    <span>${ 
         sourceData?.selectedNurse?.xshs1.name ? sourceData.selectedNurse.xshs1.name :'---' }</span>
                    <span>${ 
         sourceData?.selectedNurse?.xhhs1.name ? sourceData.selectedNurse.xhhs1.name :'---' }</span>
                  </div>
                  <div class="bottom-box">
                    <h4>${ 
        sourceData.anaesthesiaType}</h4>
                  </div>
                </div>`;
      },
       tooltip: {
      
      
         followMouse: false,
         template: (originalItemData:any, parsedItemData:any) => {
      
      
           console.log('hover-parsedItemData: ', parsedItemData);
           return `<div>
                     <p>
                       <span>开始时间:</span>
                       <span>${ 
        moment(originalItemData.start).format('YYYY-MM-DD HH:mm:ss')}</span>
                     </p>
                     <p>
                       <span>结束时间:</span>
                       <span>${ 
        moment(originalItemData.end).format('YYYY-MM-DD HH:mm:ss')}</span>
                     </p>
                     <p>
                       <span>手术内容:</span>
                       <span>${ 
        originalItemData.content}</span>
                     </p>
                   </div>`
         }
       },
      // onAdd(item, callback)在将要添加新项时触发。如果未实现,将使用默认文本内容添加该项。
      onAdd: (originalItemData:any, callback:any) => {
      
      
        debugger
        console.log('新增originalItemData: ', originalItemData);
        if (originalItemData.id) {
      
      
          originalItemData.customClassName = 'un-submit'; // 未提交状态的样式
          callback(originalItemData); // 成功返回 这行相当于调用了dataList.add(originalItemData)
        }
        else {
      
      
          callback(null); // 失败取消
        }
      },
      // onDropObjectOnItem(objectData,Item)在将对象放入现有时间轴项时触发。
      // 当拖动数据中包含target:'item'的对象被放入时间轴项时触发回调函数。
      onDropObjectOnItem: function (objectData:any, item:any) {
      
      
        debugger
        if (!item) {
      
      
          ElMessage({
      
      message: '请拖动护士到对应的手术项目中',type: 'warning'})
          return;
        }
        onDropToItem(objectData, item);
      },
      // onUpdate(item,callback)在项目即将更新时触发(双击item时)。此函数通常会显示用户更改项目的对话框。如果不执行,什么都不会发生。
      // 示例:https://visjs.github.io/vis-timeline/examples/timeline/editing/editingItemsCallbacks.html
      onUpdate: function (item:any, callback:any) {
      
      
        if (item.id) {
      
      
          callback(item); // send back adjusted item
          bindDialogRef.value.openDialog(item); // 打开弹窗
        }
        else {
      
      
          callback(null); // cancel updating the item
        }
      },
      // 当项目被移动时重复触发的回调函数。仅在selectable和editable.updateTime或editable.updateGroup选项都设置为true时才适用
      onMoving: function (item:any, callback:any) {
      
      
        console.log('item: ', item);
        item.moving = true;
        callback(item);
      },
      // 当项目即将被删除时触发onRemove(item, callback)。如果未实现,该项将始终被删除。
      onRemove: (item:any, callback:Function) => {
      
      
        onDeleteByItemType(item,callback);
      },
    }
  );
});

// 获取医院手术室信息
const getOperationRoom = async () => {
      
      
	let {
      
       data } = await useOperatingRoomApi().selectAdministrativeOffice();
	if(data.length){
      
      
    let temp = data.map((item:any) => {
      
      
      return {
      
      
        ...item,
        content: item.administrativeOfficeCard,
        style: "color: #fff; background: #5E8DFF;",
      }
    }).sort((a:any, b:any) => {
      
      return a.content - b.content});
    state.groups =  new DataSet(temp);
	}
}

// 渲染时间轴timeline = new Timeline(container, items, groups, options);
const renderTimeLine = async () => {
      
      
  // 清空数据集
  // dataList.clear();
  dataList = new DataSet([]);
  // 获取当天已经排班的数据
  await getScheduledData();

  // 设置setItems
  state.timeline.setItems(dataList);
  // 更新配置选项
  state.timeline.setOptions({
      
      
    min: moment(curOperateTime.value + ' 7:00:00').format('YYYY-MM-DD HH:mm:ss'), // 设置时间轴可见范围的最小日期
    max: moment(curOperateTime.value).endOf('day').format('YYYY-MM-DD HH:mm:ss'), // 设置时间轴可见范围的最大日期
    groupTemplate: (groupData:any, element:any) => {
      
      
      element.className = 'custom-group-template-class'; // 将自定义class写在className属性中
      return `<div class="group" title="${ 
        groupData.description}">
                <span class="group-id">${ 
        groupData.content}</span>
                <span class="group-name">${ 
        groupData.administrativeOfficeName}</span>
              </div>`;
    },
  });
  // 跳转到当前时间轴
  state.timeline.moveTo(curOperateTime.value);
  // 设置分组
  state.timeline.setGroups(state.groups);
  // 打印当前数据
  dataList.forEach((element: any) => {
      
      
    console.log('---------dataList: ', element);
  });
}


</script>

<style scoped lang="scss">
.bindNurseToRoom-container {
      
      
	width: 100%;
  position: relative;
}

// vis-timeline样式
:deep(#timeline){
      
      
  .vis-top{
      
      
    background-color: #90e0db9c;

    .vis-even,.vis-odd{
      
      
      border-left: 1px solid;
    }
  }

  // (此项目必须设置)自定义group分组样式
  .custom-group-template-class{
      
      
    height: 160px;
    width: 80px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    .group{
      
      
      display: flex;
      flex-direction: column;
      justify-content: center;
      align-items: center;
      .group-id{
      
      
        font-size: 22px;
      }
      .group-name{
      
      
        font-size: 14px;
        margin-top: 10px;
      }
    }
  }

  // (此项必须设置)自定义item样式
  .custom-item-template-class{
      
      
    // color: #fff;
    .custom-item {
      
      
      .top{
      
      
        font-size: 18px;
        font-weight: bold;
        color: #5E8DFF;
        border-bottom: 1px dashed #C4C4C4;
      }
      .center-box{
      
      
        padding: 5px;
      }
      .nurse-box{
      
      
        display: flex;
        flex-direction: row;
        flex-wrap: wrap;
        justify-content: space-around;

        span{
      
      
          width: 40%;
          height: 30px;
          padding: 5px;
          border: 1px dashed #5E8DFF;
          border-radius: 5px;
          text-align: center;
        }
      }
      .bottom-box{
      
      
        padding: 5px;
      }
    }
    // 未提交状态的样式
    .un-submit{
      
      
      border: 2px solid #698df0;
      padding: 5px;
    }
    .ed-submit{
      
      
      border: 2px solid #efb03f;
      padding: 5px;
    }
  }

  // 使用自定义class实现不同手术状态
  .vis-item.icu {
      
      
    color: white;
    background-color: rgb(228, 210, 93);
    border-color: darkred;
    height: 100px;
  }
}

// groups样式
:deep(.group-icu){
      
      
  background-color: rgba(244, 176, 59, 0.2);
}
</style>


<!-- 
为啥我的template中的自定义的class并没有被渲染到元素中?
你的自定义class选择器写错了:如果在template中自定义了class,但是并没有在CSS样式表中定义,那么这个class将不会生效。
请检查你的CSS样式表中是否已经定义了相应的类选择器,或者将class直接写在style属性中。

vis-timeline对class属性进行了过滤:vis-timeline默认会对content和className等属性进行过滤,以避免XSS攻击。
如果你的class名称被视为可疑字符,那么它将被自动过滤掉。你可以通过在options选项中增加content属性的设置,来打开这个过滤功能:

vis-timeline缓存了渲染数据,导致更新不及时:有时候,即使你已经在代码中正确设置了class属性,但是图表仍然没有反应出来。
这可能是因为vis-timeline缓存了渲染数据,需要手动调用timeline.redraw()方法来更新图表。
你可以在修改了item对象的class之后,手动调用timeline.redraw()方法,以更新图表。
-->

4、疑难问题集合

1. 中文zh-cn本地化

import moment from 'moment';
// 需要引入下方这个文件
import  "moment/dist/locale/zh-cn.js";

网上说的在配置项options中引入locale: moment.locale('zh-cn')无法实现本地化

2. 关于自定义class样式无法被渲染

写了自定义样式后,发现没有在元素中渲染出对应的class,原因有两点:

  • vis-time本身为了防止xss攻击,自动过滤了你写的样式类。需要在options中配置打开xss: {disabled:true,},
  • 你写的样式类需要通过:v-deep()渲染到界面,不写对应的样式,只写class类名是无法渲染class名字

3. 关于双向数据绑定

需要使用let dataList = new DataSet([ ]);

官网真的写的挺详细的,不得不说国外这种开源网站确实非常给力。
有啥疑问可以一起交流哦

猜你喜欢

转载自blog.csdn.net/weixin_42960907/article/details/130213139