先看效果,还有一些细节没处理


任务时间线效果图


本篇文章 使用 vue + day.js

// day.js 文档
https://dayjs.fenxianglu.cn/category/
1.任务状态有5个
- 任务响应 
		 - 实时反馈
		 - 任务挂起
		 - 任务结束
		 - 任务中止
2.后端会返回当前任务主任务信息和子任务信息
// 数据格式大概是这样 
// 很明显我们需要显示 标题 和 timeline包含的数据点位信息,然后后续,根据点击后得到的id,在从后端获取详情数据
[{
          name: "区域挖掘救援4",
          id: 962,
          timeline: [
            { date: "2023-07-04 15:20", id: 21221, state: "任务响应" },
            { date: "2023-07-04 17:50", id: 186254, state: "实时反馈" },
          ],
          children: [
            {
              name: "务4-1",
              id: 6235,
              timeline: [
                { date: "2023-07-04 17:12", id: 36511, state: "任务响应" },
              ],
              children: [
                {
                  name: "区域挖区域挖掘救援2掘救援4-1-1",
                  id: 154123,
                  timeline: [
                    { date: "2023-07-04 15:20", id: 19991, state: "任务响应" },
                    { date: "2023-07-04 17:50", id: 16661, state: "实时反馈" },
                  ],
                  children: [
                    {
                      name: "务4-1-1-1",
                      id: 8222,
                      timeline: [
                        {
                          date: "2023-07-04 17:12",
                          id: 77741,
                          state: "任务响应",
                        },
                      ],
                    },
                  ],
                },
              ],
            },
          ],
        },
      ]
3.分析
1.怎让上方的时间轴获取最小时间和最大时间
// arayFlat方法
/**
 * @method arrayFlat 嵌套数组拍平
 * @param {Array} data 源数据
 * @param {String} [childrenKey='children'] 嵌套的子数据key
 * @param {String} [itemType=''] 需要的itemkey
 * @returns {Array} 返回处理好的数据
 * @Date 2023-06-30 14:23:52
 **/
export const arrayFlat = (data, childrenKey = "children", itemkey = "") => {
  const result = [];

  const filterItem = (item) => {
    let dataType = Object.prototype.toString.call(item[itemkey]).slice(8, -1);

    dataType == "Array"
      ? item[itemkey].forEach((r) => result.push(r))
      : result.push(item[itemkey]);
  };

  data.forEach((item) => {
    itemkey ? filterItem(item) : result.push(item);

    if (item[childrenKey] && item[childrenKey].length > 0) {
      result.push(...arrayFlat(item[childrenKey], childrenKey, itemkey));
    }
  });

  return result;
};
// arrayFlat 处理结果
[
    {
        "date": "2023-07-04 14:08:00",
        "id": 11,
        "state": "任务响应"
    },
    {
        "date": "2023-07-04 15:32:00",
        "id": 12,
        "state": "实时反馈"
    },
    {
        "date": "2023-07-04 15:46:00",
        "id": 1223,
        "state": "任务挂起"
    },
    {
        "date": "2023-07-04 15:56:00",
        "id": 11232,
        "state": "任务结束"
    },
    {
        "date": "2023-07-04 17:56:00",
        "id": 14232,
        "state": "任务中止"
    },
    {
        "date": "2023-07-04 15:40:00",
        "id": 111,
        "state": "任务响应"
    },
    {
        "date": "2023-07-04 14:50:00",
        "id": 112,
        "state": "任务响应"
    },
    {
        "date": "2023-07-04 15:20",
        "id": 221,
        "state": "任务响应"
    },
    {
        "date": "2023-07-04 15:50",
        "id": 222,
        "state": "实时反馈"
    },
    {
        "date": "2023-07-04 15:10",
        "id": 1231,
        "state": "任务响应"
    },
    {
        "date": "2023-07-04 15:20",
        "id": 32411,
        "state": "任务响应"
    },
    {
        "date": "2023-07-04 17:50",
        "id": 1231,
        "state": "实时反馈"
    },
    {
        "date": "2023-07-04 17:12",
        "id": 321,
        "state": "任务响应"
    },
    {
        "date": "2023-07-04 15:20",
        "id": 21221,
        "state": "任务响应"
    },
    {
        "date": "2023-07-04 17:50",
        "id": 186254,
        "state": "实时反馈"
    },
    {
        "date": "2023-07-04 17:12",
        "id": 36511,
        "state": "任务响应"
    },
    {
        "date": "2023-07-04 15:20",
        "id": 19991,
        "state": "任务响应"
    },
    {
        "date": "2023-07-04 17:50",
        "id": 16661,
        "state": "实时反馈"
    },
    {
        "date": "2023-07-04 17:12",
        "id": 77741,
        "state": "任务响应"
    }
]
// 获取数据的事件 最大时间和最小时间
    getTableMinAndMaxTime(tableData) {
      let minTimeValue = dayjs().format("YYYY-MM-DD HH:mm:ss");
      const maxTime = (date) =>
        dayjs(date).add(5, "hour").format("YYYY-MM-DD HH:mm:ss");

      const isBeforeTime = (minTime, newTime) =>
        dayjs(minTime).isBefore(dayjs(newTime));

      const getTime = (item) => dayjs(item.date).unix();

      let copyData = JSON.parse(JSON.stringify(tableData));

      let newcopyAndFlatData = arrayFlat(copyData, "children", "timeline");
     console.log('newcopyAndFlatData',newcopyAndFlatData);
      newcopyAndFlatData.sort(
        (befor, after) => getTime(befor) - getTime(after)
      );

      minTimeValue = !isBeforeTime(minTimeValue, newcopyAndFlatData[0].date)
        ? newcopyAndFlatData[0].date
        : minTimeValue;
      let maxTimeValue = maxTime(
        newcopyAndFlatData[newcopyAndFlatData.length - 1].date
      );
      this.getHour(minTimeValue, maxTimeValue);
    },
    // 获取实际时间线数据  (最大和最小时间都要是整数方便计算)
    getHour(minTime, maxTime) {
      // 初始化当前时间为开始时间
      let maxT = this.setMaxHours(maxTime);
      let currentDateTime = this.setMinHours(minTime);

      this.minTime = dayjs(currentDateTime).format("YYYY-MM-DD HH:mm:ss");
      this.maxTime = dayjs(maxT).format("YYYY-MM-DD HH:mm:ss");
      this.tiemDeff(currentDateTime, maxT);
      const result = [];
      while (currentDateTime.isBefore(maxT) || currentDateTime.isSame(maxT)) {
        result.push({
          date: currentDateTime.format("YYYY-MM-DD HH:mm"),
          value: currentDateTime.format("HH:mm"),
        });
        currentDateTime = currentDateTime.add(10, "minute"); // 每次加10分钟
      }

      this.timeArr = result;
      this.hoursDiff = result.length;
      //console.log("最小时间~最大时间", result);
      //console.log("最大移动次数", result.length);
    },
// 考虑到不能让最小或者最大时间贴边显示这样不是很好看,所以每个时间±1小时处理
 // 最大时间取整 +1小时   这里和当前时间最下比较,因为任务可能是当前时间往后推30分钟执行,也有可能没有最新任务比当前时间大
   setTime() {
      // 当前时间分钟向下取整
      let minutesToAdd = dayjs().minute() % 10;
      let fullCurrenTime = dayjs()
        .subtract(minutesToAdd, "minute")
        .format("YYYY-MM-DD HH:mm");
      console.log("fullCurrenTime", fullCurrenTime);
      this.fullCurrenTime = fullCurrenTime;
    },
    setMaxHours(dateTimeString) {
      // 当前时间
      const currentTiem = dayjs(this.fullCurrenTime);
      // 时间线的最大时间
      const timeLIneMaxTime = dayjs(dateTimeString);
      // 是否在之后
      let isAfter = currentTiem.isAfter(timeLIneMaxTime);
      // 赋值最大时间
      let maxTime = isAfter ? this.fullCurrenTime : dateTimeString;
      const minutesToAdd = 10 - (dayjs(maxTime).minute() % 10);
      const roundedDate = dayjs(maxTime).add(minutesToAdd, "minute");
      return roundedDate;
    },
    // 最小时间取整 -1小时
    setMinHours(dateTimeString) {
      const minutesToAdd = dayjs(dateTimeString).minute() % 10;
      const roundedDate = dayjs(dateTimeString)
        .subtract(minutesToAdd, "minute")
        .subtract(1, "hours");
      return roundedDate;
    },
2.时间问题处理完毕了,接下来就是把处理好的数据递渲染出来,这里需要用到递归组件
需要用到的处理
/**
 * @method getTaskPositon 计算当前事件在事件线的百分比位置
 * @param {Number} maxTime  最大事件
 * @param {Number} minTime  最小事件
 * @param {String} taskItem 当前事件
 * @returns {Number} 距离左侧定位的 百分比数值
 * @Date 2023-07-03 09:53:39
 **/
export const getTaskPositon = (minTime, maxTime, taskItem) => {
  let leftDistancePx = 0;
  // 把时间都转换成dayjs
  const date1 = dayjs(maxTime);
  const date2 = dayjs(minTime);
  const date3 = dayjs(taskItem);

  // 得出俩个时间的  差异总分钟值
  const diffMinute = date1.diff(date2, "minute");
  // 取出当前事件到起始事件(最小事件) 分钟值
  const taskItemMinute = date3.diff(date2, "minute");
  // 事件线总分钟 / 事件从起点到自己的时间差值  = 当前事件应该所在的百分比位置

  leftDistancePx = taskItemMinute / diffMinute;

  return leftDistancePx;
};

export const timeDiff = (minTime, maxTime) => {
  const date1 = dayjs(maxTime);
  const date2 = dayjs(minTime);
  // 得出俩个时间的  差异总分钟值
  const diffMinute = date1.diff(date2, "minute");
  let lineWidth = ((diffMinute + 10) * 1.4).toFixed(0);
  return Number(lineWidth);
};
1.父组件
<template>
  <div class="tree_container">
    <tree-item
      v-for="item in innerOptions"
      :key="item.id"
      :item="item"
      :checkbox="checkbox"
      :position="position"
      :timeArr="timeArr"
      :minTime="minTime"
      :maxTime="maxTime"
    ></tree-item>
  </div>
</template>

<script>
import TreeItem from "./tree-item.vue";
export default {
  name: "tree",
  data() {
    return {
      innerOptions: [], //进行处理成tree能用的数据
      filterNode: "", // 过滤字段
    };
  },
  props: {
    // 表格数据
    treeData: {
      type: Array,
      default: () => [],
    },
    // 是否选中
    checkbox: {
      type: Boolean,
      default: false,
    },
    // 每次移动的数值
    position: {
      type: Number,
      default: 0,
    },
    // 时间线数据
    timeArr: {
      type: Array,
      default: () => [],
    },
    // 最小时间
    minTime: {
      type: String,
      default: "",
    },
    // 最大时间
    maxTime: {
      type: String,
      default: "",
    },
  },
  components: {
    TreeItem,
  },
  created() {
    this.innerOptions = this.treeData.map((item) => this.handleData(item));
  },
  mounted() {},
  methods: {
    // 处理数据 变成tree需要的格式
    handleData(item, indent = 0) {
      return {
        ...item,
        indent, // 缩进
        expand: false, //是否展开
        checked: false, //是否选中
        disabled: false, //是否禁止选中
        children: (item.children || []).map((children) =>
          this.handleData(children, indent + 1)
        ),
      };
    },
  },
  computed: {},
  beforeDestroy() {},
};
</script>

<style lang="scss" scoped>
.tree_container {
  width: 955px;
  max-height: 370px;
  overflow: hidden;
  overflow-y: scroll;
  padding-right: 5px;
  padding-top: 4px;
  padding-bottom: 4px;
  // 滚动条样式
  &::-webkit-scrollbar-track {
    background-color: #4175ac;
  }

  &::-webkit-scrollbar {
    width: 5px;
  }
  &::-webkit-scrollbar-thumb {
    border-radius: 5px;
    border: 1px solid rgba(0, 174, 255, 0.604);
    background: #69a9db;
  }
}
</style>
2.子组件
<template>
  <div class="treeItem_container">
    <!-- 没有子元素 -->
    <div class="row">
      <!-- 左侧标题 -->
      <div
        :style="{ 'padding-left': indentLeft + 'px' }"
        :class="[
          'row-parent',
          { checkbox: item.checked },
          item.indent == 0 ? 'levelMax' : 'levelmin',
          item.expand && item.indent == 0 ? 'active' : 'deactive',
        ]"
        @click.stop="handleClick(item)"
      >
        <!-- 标题 -->
        <div class="title">
          <div class="box"></div>
          <div
            class="img"
            :class="[item.indent == 0 ? 'level-0' : 'level-1']"
          ></div>
          <div class="text">{{ item.name }}</div>
        </div>
        <!-- 三角形展开收起 -->
        <div
          v-show="item.children.length > 0"
          :class="[
            item.children.length > 0 ? 'arrows' : '',
            !item.expand ? 'close' : 'open'
          ]"
        ></div>
      </div>

      <div class="row-content">
        <!-- 时间线 -->
        <div
          class="timeLine"
          :style="{
            transform: `translateX(${position}px)`,
            width: `${lineWidth}px`,
          }"
        >
          <div
            class="row-content-time"
            v-for="(timeArrItem, timeArrIndex) in timeArr"
            :key="timeArrIndex"
            @click="getTime(timeArrItem)"
          ></div>
        </div>
        <!-- 任务线 -->
        <div
          class="taskLine"
          :style="{
            transform: `translateX(${position}px)`,
            width: `${lineWidth}px`,
          }"
        >
          <div
            :class="stateIcon[taskDate.state]"
            :style="{
              left: `${lineWidth * setItmePosition(taskDate.date)}px`,
            }"
            @click="getTime(taskDate)"
            v-for="taskDate in item.timeline"
            :key="taskDate.id"
          ></div>
        </div>
      </div>
    </div>

    <!-- 子元素,并且是展开的状态 -->
    <div v-if="item.expand && item.children.length" class="item-children">
      <tree-item
        v-for="item in item.children"
        :key="item.id"
        :item="item"
        :checkbox="checkbox"
        :position="position"
        :timeArr="timeArr"
        :minTime="minTime"
        :maxTime="maxTime"
      ></tree-item>
    </div>
  </div>
</template>

<script>
import { timeDiff, getTaskPositon } from "@/utils/common/index.js";
export default {
  name: "tree-item",
  data() {
    return {
      titleIcon: [
        {
          indent: 0,
          style: "level-0",
        },
        {
          indent: 1,
          style: "level-1",
        },
      ],
      stateIcon: {
        任务响应: "bluePoint",
        实时反馈: "yellowPoint",
        任务挂起: "greyPoint",
        任务结束: "greendPoint",
        任务中止: "redPoint",
      },
      lineWidth: 0,
    };
  },
  props: {
    item: {
      type: Object,
      default: () => ({
        name: "",
        indent: 0, // 缩进
        expand: true, //是否展开
        checked: true, //是否选中
        disabled: false, //是否禁止选中
        children: [],
      }),
    },
    checkbox: {
      type: Boolean,
      default: false,
    },
    // 时间线数据
    timeArr: {
      type: Array,
      default: () => [],
    },
    position: {
      type: Number,
      default: 0,
    },
    minTime: {
      type: String,
      default: "",
    },
    maxTime: {
      type: String,
      default: "",
    },
    //----下面还没扩展
    // 手风琴模式
    accordion: {
      type: Boolean,
      default: false,
    },
    // 节点过滤
    "filter-node-method": {
      type: String,
      default: "",
    },
  },
  created() {
    this.setLineWidth();
  },
  methods: {
    handleClick(item) {
      console.log("handleItem", item);

      const setChildrenExpand = (item) => {
        item.expand = !item.expand;
        if (item.children && item.children.length) {
          setChildrenExpand(item.children);
        }
      };
      // 判断子节点是否禁用
      if (item.disabled) {
        return;
      }
      // 判断该节点是否有children
      if (item.children && item.children.length) {
        setChildrenExpand(item);
      } else {
        // 处理item展开收起状态
        item.expand = !item.expand;
      }
    },
    setLineWidth() {
      let lineWidth = timeDiff(this.minTime, this.maxTime);
      this.lineWidth = lineWidth;
    },
    // 设置item的定位
    setItmePosition(itemTime) {
      return getTaskPositon(this.minTime, this.maxTime, itemTime);
    },
    getTime(data) {
      if (data?.state) this.$emit("getTaskDate", data);
      console.table([data]);
    },
  },
  computed: {
    indentLeft() {
      return this.item.indent * 30;
    },
  },
  beforeDestroy() {},
};
</script>

<style lang="scss" scoped>
.treeItem_container {
  width: 955px;
  max-height: 370px;
  overflow: hidden;
  overflow-y: scroll;
  padding-right: 5px;
  padding-top: 4px;
  padding-bottom: 4px;
  // 滚动条样式
  &::-webkit-scrollbar-track {
    background-color: #4175ac;
  }

  &::-webkit-scrollbar {
    width: 5px;
  }
  &::-webkit-scrollbar-thumb {
    border-radius: 5px;
    border: 1px solid rgba(0, 174, 255, 0.604);
    background: #69a9db;
  }

  .row {
    display: flex;
    border-radius: 32px;
    width: 100%;
    height: 54px;
    margin-bottom: 10px;
    // 左侧内容
    .row-parent {
      width: 211px;
      height: 54px;
      line-height: 54px;
      font-size: 18px;
      font-family: Microsoft YaHei;
      font-weight: 400;
      color: #ffffff;
      display: flex;
      align-items: center;
      justify-content: space-between;
      box-sizing: border-box;
      .title {
        display: flex;
        align-items: center;
        overflow: hidden;
        .box {
          width: 18px;
        }
        .img {
          width: 16px;
          height: 18px;
          text-align: center;
          line-height: 18px;
        }
        .text {
          /* 将超出的内容隐藏 */
          overflow: hidden;
          /*  禁止文字自动换行 */
          white-space: nowrap;
          /* 多余的文字显示为省略号 */
          text-overflow: ellipsis;
        }
      }
      .arrows {
        width: 0px;
        height: 0px;
        margin-right: 9px;
        border: 1px teal;
      }
      .open {
        border-top: none;
        border-right: 6px solid transparent;
        border-bottom: 8px solid #ffffff;
        border-left: 6px solid transparent;
      }
      .close {
        border-top: 8px solid #ffffff;
        border-right: 6px solid transparent;
        border-bottom: none;
        border-left: 6px solid transparent;
      }
    }
    .deactive {
      background: url("@/assets/img/commandModule/任务跟踪/默认.png")
        no-repeat;
      background-size: cover;
    }
    .active {
      background: url("@/assets/img/commandModule/任务跟踪/选中.png")
        no-repeat;
      background-size: cover;
    }
    // 右侧内容
    .row-content {
      width: 714px;
      height: 54px;
      margin-left: 24px;
      overflow: hidden;
      position: relative;
      // 任务线
      .taskLine {
        display: flex;
        align-items: center;
        position: absolute;
        left: 0px;
        top: 50%;
      }
      // 时间线
      .timeLine {
        display: flex;
        align-items: center;
        height: 100%;
        // 时间线的小段
        .row-content-time {
          width: 10px;
          height: 2px;
          border-radius: 50%;
          background-color: #6d8484c3;
          margin: 0px 2px;
        }
      }
    }
  }
}

.level-0 {
  width: 16px;
  height: 18px;
  margin-right: 20px;
  background: url("/src/assets/img/commandModule/任务跟踪/任务图标.png") 0px 0px /
    100% 100% no-repeat;
}
.level-1 {
  width: 16px;
  height: 18px;
  margin-right: 20px;
  background: none;
  position: relative;
  &::before {
    content: "";
    width: 10px;
    height: 10px;
    background: #00f2ff;
    border-radius: 50%;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }
}
.bluePoint {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: url("/src/assets/img/commandModule/任务跟踪/1.png") center / 300%
    300% no-repeat;
  cursor: pointer;
  display: inline-block;
  position: absolute;
  //   top: 0px;
}
.yellowPoint {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: url("/src/assets/img/commandModule/任务跟踪/2.png") center / 300%
    300% no-repeat;
  cursor: pointer;
  position: absolute;
  display: inline-block;

  //   top: 0px;
}
.greyPoint {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: url("/src/assets/img/commandModule/任务跟踪/4.png") center / 300%
    300% no-repeat;
  cursor: pointer;
  position: absolute;
  //   top: 0px;
}
.greendPoint {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: url("/src/assets/img/commandModule/任务跟踪/3.png") center / 300%
    300% no-repeat;
  cursor: pointer;
  position: absolute;
  //   top: 0px;
}
.redPoint {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: url("/src/assets/img/commandModule/任务跟踪/5.png") center / 300%
    300% no-repeat;
  cursor: pointer;
  position: absolute;
  //   top: 0px;
  //   background: radial-gradient(circle, #ff0000, #990000);
}
</style>
3.计算处理后的最小时间~最大时间可以点击的次数,计算出可点击移动的范围。这里后续可以优化
tiemDeff(minTime, maxTime) {
      let min = dayjs(minTime);
      let max = dayjs(maxTime);
      let timeDiffHour = max.diff(min, "hour");
      this.timeLinemMovingRange = timeDiffHour - 8; // 最大移动值
    },
 rightMoving() {
     if(this.rightMovingRange == 0) return
      this.leftMovingRange -= 1;
      this.rightMovingRange -= 1;
      this.position += this.moveNumber; // 每次点击增
    },
    leftMoving() {
      if(this.rightMovingRange == this.timeLinemMovingRange) return
      this.leftMovingRange += 1;
      this.rightMovingRange += 1;
      this.position -= this.moveNumber; // 每次点击增
    },