移动测试开发 从 0 到 1 实现甘特图

opentest-oper@360.cn · 2023年04月26日 · 最后由 opentest-oper@360.cn 回复于 2023年05月10日 · 9961 次阅读

本文源起于产品说要做一个甘特图。
甘特图作为一种时间管理工具,在项目管理中有着广泛的应用。对比传统的列表视图,它通过图形化的方式展示任务的进度和时间范围,更加直观,日期调整交互更方便,能更好的避免任务重叠和时间冲突。

产品主要需求:

1.分为左右面板,左侧为树形表格结构展现 id,名称等基本信息,可展开折叠,右侧为甘特图。左右面板可调整宽度
2.为了便于修改日期:小横条可整行横向拖动,可按住一段拉长或缩短。用户调整后需要自适应坐标格子(不能出现半格的情况)
3.按日/周/月/季度/年查看
说干就干,立马去找轮子。

现有工具库的对比:

坏消息,无可直接用的工具库,好消息,v-gantt 的 ui 和功能比较贴近,并且整体项目简洁,可借鉴实现思路。

先展示一下完成后的效果:

实现思路:

1.左右面板,可调整宽度 -> 整体=普通表格 + 甘特图

①.问题:既然分成了两部分,那么如何同步两边的状态(某项是折叠还是展开,hover 高亮,滚动同步)

字段说明:
list - 列表数据
collapsedMap – 树形结构中展开折叠的状态。监听三角形的点击事件,折叠则记录 id
curHover – 当前 hover 高亮的 id
scrollTop – 滚动的高度

②.面板大小调整的实现

<div class="container">
  <div class="container-left">
    ...
  </div>

  <div class="resize"></div>

  <div class="container-right">
  ...
  </div>
</div>


<script>
const dragResize = function () {
  const resizeEl = document.querySelector(".resize");
  const leftEl = document.querySelector(".container-left");
  const rightEl = document.querySelector(".container-right");
  const containerEl = document.querySelector(".container");

  resizeEl.onmousedown = function (e) {
    resizeEl.classList.add("active");
    const startX = e.clientX;
    resizeEl.left = resizeEl.offsetLeft;

    document.onmousemove = function (e) {
      const endX = e.clientX;
      let moveLen = resizeEl.left + (endX - startX);

      if (moveLen < 380) moveLen = 380; // 左边区域的最小宽度为380px
      if (moveLen > 908) moveLen = 908; // 左边区域的最大宽度为908px

      resizeEl.style.left = moveLen + "px";
      leftEl.style.width = moveLen + "px";
      rightEl.style.width = (containerEl.clientWidth - moveLen - 10) + "px";
    };

    document.onmouseup = function (evt) {
      resizeEl.classList.remove("active");
      document.onmousemove = null;
      document.onmouseup = null;
      resizeEl.releaseCapture && resizeEl.releaseCapture();
    };
    resizeEl.setCapture && resizeEl.setCapture();
    return false;
  };
};
</script>


<style>
.container {
  display: flex;
  height: 100%;
  overflow: hidden;
  position: relative;
}

.container-left {
  width: calc(50% - 1px);
  box-shadow: 1px 0 4px rgba(0, 0, 0, 0.1%);
}

.container-right {
  margin-left: 2px;
  width: calc(50% - 1px);
}

.resize {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(50% - 1px);
  width: 2px;
  height: 100%;
  cursor: col-resize;
  z-index: 10;
}

.resize:hover,
.resize.active {
  background-color: #2d6cf9;
}
</style>

2.普通表格比较简单,重点关注右侧的甘特图部分。
可拖动,可调整长短 -> 两个图层(时间坐标轴 + 可拖动的小横条)

坐标轴就是用 dayjs 去生成然后展示即可。

// gantt中日期的生成函数
import dayjs from "dayjs";
import { generateUUID } from "@/common/utils.js";

/**
 * 年-半年模式gantt标题
 */
export function yearTitleDate (start, end) {
  const start_year = dayjs(start).year();
  const end_year = dayjs(end).year();

  const list = [];
  for (let i = start_year; i <= end_year; i++) {
    list.push({
      name: `${i}年`,
      id: generateUUID(),
      children: [{
        name: `${i}上半年`,
        range: [dayjs(`${i}-01-01`).format("YYYY-MM-DD"), dayjs(`${i}-06-30`).format("YYYY-MM-DD")],
        id: generateUUID()
      }, {
        name: `${i}下半年`,
        range: [dayjs(`${i}-07-01`).format("YYYY-MM-DD"), dayjs(`${i}-12-31`).format("YYYY-MM-DD")],
        id: generateUUID()
      }]
    });
  }

  return list;
}

/**
 * 年-季度模式gantt标题
 */
export function quarterTitleDate (start, end) {
  const start_year = dayjs(start).year();
  const start_month = dayjs(start).month() + 1;
  const end_year = dayjs(end).year();
  const end_month = dayjs(end).month() + 1;

  // 处理年份
  const year_diff = end_year - start_year;
  if (year_diff === 0) { // 年间隔为同一年
    const quarters = generationQuarters(start_year, start_month, end_month + 1); // 处理月份
    return quarters;
  }
  // 处理开始年
  const start_quartes = generationQuarters(start_year, start_month, 12);

  // 处理结束年
  const end_quartes = generationQuarters(end_year, 1, end_month + 1);

  // 间隔一年
  if (year_diff === 1) {
    return start_quartes.concat(end_quartes);
  }

  // 年间隔大于1年
  if (year_diff > 1) {
    let quarters = start_quartes;
    for (let i = 1; i < year_diff; i++) {
      const item_year = start_year + i;
      const onYearQuarter = generationQuarters(item_year, 1, 12);
      quarters = quarters.concat(onYearQuarter);
    }
    quarters = quarters.concat(end_quartes);
    return quarters;
  }
}

/**
 * 年-月模式gantt标题
 */
export function monthTitleDate (start, end) {
  const start_year = dayjs(start).year();
  const start_month = dayjs(start).month() + 1;
  const end_year = dayjs(end).year();
  const end_month = dayjs(end).month() + 1;

  // 日期数据盒子
  const dates = [
    {
      name: `${start_year}年`,
      date: start_year,
      id: generateUUID(),
      children: []
    }
  ];
  // 处理年份
  const year_diff = end_year - start_year;
  // 年间隔小于一年
  if (year_diff === 0) {
    const isLeapYear = isLeap(start_year); // 是否闰年
    const months = generationMonths(start_year, start_month, end_month + 1, isLeapYear, false); // 处理月份
    dates[0].children = months;
    return dates;
  }
  // 处理开始月份
  const startIsLeap = isLeap(start_year);
  const start_months = generationMonths(start_year, start_month, 13, startIsLeap, false);
  // 处理结束月份
  const endIsLeap = isLeap(end_year);
  const end_months = generationMonths(end_year, 1, end_month + 1, endIsLeap, false);
  // 年间隔等于一年
  if (year_diff === 1) {
    dates[0].children = start_months;
    dates.push({
      name: `${end_year}年`,
      date: end_year,
      children: end_months,
      id: generateUUID()
    });
    return dates;
  }
  // 年间隔大于1年
  if (year_diff > 1) {
    dates[0].children = start_months;
    for (let i = 1; i < year_diff; i++) {
      const item_year = start_year + i;
      const isLeapYear = isLeap(item_year);
      const month_and_day = generationMonths(item_year, 1, 13, isLeapYear, false);
      dates.push({
        name: `${item_year}年`,
        date: item_year,
        id: generateUUID(),
        children: month_and_day
      });
    }
    dates.push({
      name: `${end_year}年`,
      date: end_year,
      children: end_months,
      id: generateUUID()
    });
    return dates;
  }
}
/**
 * 年-周模式gantt标题
 */
export function weekTitleDate (start, end) {
  const start_year = dayjs(start).year();
  const start_month = dayjs(start).month() + 1;
  const end_year = dayjs(end).year();
  const end_month = dayjs(end).month() + 1;

  // 处理年份
  const year_diff = end_year - start_year;
  if (year_diff === 0) {
    // 年间隔为同一年
    const isLeapYear = isLeap(start_year); // 是否闰年
    const months = generationMonths(start_year, start_month, end_month + 1, isLeapYear, true, true); // 处理月份
    return months;
  }
  // 处理开始月份
  const startIsLeap = isLeap(start_year);
  const start_months = generationMonths(start_year, start_month, 13, startIsLeap, true, true);

  // 处理结束月份
  const endIsLeap = isLeap(end_year);
  const end_months = generationMonths(end_year, 1, end_month + 1, endIsLeap, true, true);

  // 间隔一年
  if (year_diff === 1) {
    return start_months.concat(end_months);
  }

  // 年间隔大于1年
  if (year_diff > 1) {
    let months = start_months;
    for (let i = 1; i < year_diff; i++) {
      const item_year = start_year + i;
      const yearIsLeap = isLeap(item_year);
      const onYearMonth = generationMonths(item_year, 1, 13, yearIsLeap, true, true);
      months = months.concat(onYearMonth);
    }
    months = months.concat(end_months);
    return months;
  }
}

/**
 * 月-日模式gantt标题
 */
export function dayTitleDate (start, end) {
  const start_year = dayjs(start).year();
  const start_month = dayjs(start).month() + 1;
  const end_year = dayjs(end).year();
  const end_month = dayjs(end).month() + 1;

  // 处理年份
  const year_diff = end_year - start_year;
  if (year_diff === 0) {
    // 年间隔为同一年
    const isLeapYear = isLeap(start_year); // 是否闰年
    const months = generationMonths(start_year, start_month, end_month + 1, isLeapYear); // 处理月份
    return months;
  }
  // 处理开始月份
  const startIsLeap = isLeap(start_year);
  const start_months = generationMonths(start_year, start_month, 13, startIsLeap);
  // 处理结束月份
  const endIsLeap = isLeap(end_year);
  const end_months = generationMonths(end_year, 1, end_month + 1, endIsLeap);

  // 间隔一年
  if (year_diff === 1) {
    return start_months.concat(end_months);
  }

  // 年间隔大于1年
  if (year_diff > 1) {
    let months = start_months;
    for (let i = 1; i < year_diff; i++) {
      const item_year = start_year + i;
      const yearIsLeap = isLeap(item_year);
      const onYearMonth = generationMonths(item_year, 1, 13, yearIsLeap);
      months = months.concat(onYearMonth);
    }
    months = months.concat(end_months);
    return months;
  }
}

/**
 * 生成月份函数
 * year: Number 当前年份
 * start_num: Number 开始月分
 * end_num:Number 结束月份
 * isLeap: Boolean 是否闰年
 * insert_days: Boolean 是否需要插入 日
 * week: 是否以周的间隔
 */
function generationMonths (year, start_num = 1, end_num = 13, isLeap = false, insert_days = true, week = false) {
  const months = [];
  for (let i = start_num; i < end_num; i++) {
    const obj = {
      name: `${i}月`,
      fullname: `${year}年${i}月`,
      range: [
        dayjs(`${year}-${i}-01`).format("YYYY-MM-DD"),
        dayjs(`${year}-${i}`).endOf("month").format("YYYY-MM-DD")
      ],
      id: generateUUID()
    };

    if (insert_days) {
      const days = generationDays(year, i, isLeap, week);
      obj.children = days;
    }

    months.push(obj);
  }
  return months;
}

/**
 * 生成日期函数
 * year: Number 当前年份
 * month: Number 当前月份
 * isLeap: Boolean 是否闰年
 * week: Boolean 是否间隔一周
 */
function generationDays (year, month, isLeap = false, week = false) {
  const big_month = [1, 3, 5, 7, 8, 10, 12].includes(month);
  const small_month = [4, 6, 9, 11].includes(month);
  const dates_num = big_month ? 32 : small_month ? 31 : isLeap ? 30 : 29;
  const days = [];
  if (week) {
    let _day = 1; // 从周一开始
    const _start_day_inweek = timeInWeek(`${year}-${month}-1`);
    if (_start_day_inweek > 1) {
      _day = 9 - _start_day_inweek;
    } else if (_start_day_inweek === 0) {
      _day = 2;
    }
    for (let i = _day; i < dates_num; i += 7) {
      const week_start = dayjs(`${year}-${month}-${i}`);
      const week_end = dayjs(week_start).add(6, "day");
      const week_num = dayjs(week_start).week();
      days.push({
        name: `${week_num}周(${week_start.format("DD")} ~ ${week_end.format("DD")})`,
        id: generateUUID(),
        range: [week_start.format("YYYY-MM-DD"), week_end.format("YYYY-MM-DD")],
        week_num: week_num
      });
    }
  } else {
    const dayArr = ["日", "一", "二", "三", "四", "五", "六"];
    for (let i = 1; i < dates_num; i++) {
      const full_date = `${year}-${month}-${i}`;
      const day_name = dayArr[dayjs(full_date).day()];
      days.push({
        name: `${i}`,
        dayName: day_name,
        id: generateUUID(),
        full_date: dayjs(full_date).format("YYYY-MM-DD")
      });
    }
  }
  return days;
}

/**
 * 生成季度函数
 * year: Number 当前年份
 * start_num: Number 开始月分
 * end_num:Number 结束月份
 */
function generationQuarters (year, start_num = 1, end_num = 12) {
  const list = [];

  const startQuarter = Math.floor(start_num / 4) + 1;
  const endQuarter = Math.floor(end_num / 4) + 1;

  for (let i = startQuarter; i <= endQuarter; i++) {
    list.push({
      name: `第${i}季度`,
      range: [
        dayjs(`${year}-${(i - 1) * 3 + 1}`).startOf("month").format("YYYY-MM-DD"),
        dayjs(`${year}-${i * 3}`).endOf("month").format("YYYY-MM-DD")
      ],
      id: generateUUID()
    });
  }
  return [{
    name: `${year}年`,
    id: generateUUID(),
    children: list
  }];
}

/**
 * 是否闰年函数
 * year: Number 当前年份
 */
function isLeap (year) {
  return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}

function timeInWeek (date) {
  return dayjs(date).day();
}

效果如下:


3.小横条的位置和长度确定 -> 依据:【时间长度】与【位置长度】成正比
①.把整个视图看成一个由无数个固定大小的小格子组成,每个小格子宽度为 n。在按日视图下, 一个小格子表示一天,按周则一个小格子表示一周
②.计算一天的宽度(按日则是 n,按周则是 n/7,按月则是 n/当月的天数)
③.小横条的距离坐标轴左侧距离 =(开始时间 - 坐标轴开始时间)* 一天的宽度
④.小横条的宽度 =(结束时间 – 开始时间)* 一天的宽度

4.用户调整时,得出新的时间周期,并且调整后需要自适应坐标格子(不能出现半格的情况)
①.调整时,实时计算距离坐标轴左侧的距离 / 一天的宽度,取整,得出距离坐标轴起始的天数,得出小横条代表的开始时间
②.计算小横条宽度 / 一天的宽度,取整,得出小横条表示的持续时间,得出结束时间
③.调整完毕后,根据最后计算取整的结果,微调小横条的位置

5.给小横条加上 tooltip,拖动时显示修改的时间,非拖动时显示当前项的详情。

<template>
  <div class="node"
    :style="{
      left: data.left,
      width: drag.resizing ? drag.newWidth : data.width,
      transform: drag.dragging ? `translate(${drag.offsetX}px, 0)` : ''
    }"
    :class="[data.colorClass, (drag.dragging|| drag.resizing) ? 'moving' : '']"
    @user1="onDragStart"
    @user2="isShowPopper = true"
    @user3="updatePoper"
    @user4="isShowPopper = false"
  >
    <div class="drag-handle" @user5="onResizeStart">
      <span class="icon"></span>
    </div>

    <teleport to="body" v-if="isShowPopper">
      <div class="task-time-tooltip ">
        <div v-if="drag.dragging || drag.resizing">
          {{drag.newStartTime}} ~ {{drag.newEndTime}}
        </div>
        <div v-else>
            {{data.title}}
          <div class="line">
            任务状态:
            <span class="block" :class="data.colorClass"></span>
            {{data.status}}
          </div>
          <div>当前进度:{{data.process}}%</div>
          <div>开始时间:{{data.start_time || '-'}}</div>
          <div>结束时间:{{data.end_time || '-'}}</div>
        </div>
      </div>
    </teleport>
  </div>
</template>

<script>
import { reactive, ref } from "@vue/reactivity";
import dayjs from "dayjs";
import { computed, watch } from "@vue/runtime-core";

export default {
  name: "taskProcess",
  props: {
    data: Object,
    step: Number // 单元宽度,移动的距离必须是step的整数倍
  },
  emits: ["change", "changeMoving"],
  setup (props, context) {
    const drag = reactive({
      dragging: false,
      resizing: false,
      offsetX: 0,
      moveWidth: 0,
      newStartTime: "",
      newEndTime: "",
      newWidth: ""
    });

    const moving = computed(() => {
      return drag.dragging || drag.resizing;
    });

    watch(moving, () => {
      context.emit("changeMoving", moving.value);
    });

    function onDragStart () {
      resetDrag();
      drag.newStartTime = props.data.start_time;
      drag.newEndTime = props.data.end_time;
      drag.dragging = true;
      document.addEventListener("mousemove", onDrag);
      document.addEventListener("mouseup", onDragEnd);
    }

    function onDrag (e) {
      drag.offsetX += e.movementX;
      drag.moveDay = getMoveDay(drag.offsetX);
      drag.newStartTime = dayjs(props.data.start_time).add(drag.moveDay, "day").format("YYYY-MM-DD");
      drag.newEndTime = dayjs(props.data.end_time).add(drag.moveDay, "day").format("YYYY-MM-DD");
    }

    function onDragEnd (e) {
      document.removeEventListener("mousemove", onDrag);
      document.removeEventListener("mouseup", onDragEnd);
      if (props.data.start_time !== drag.newStartTime || props.data.end_time !== drag.newEndTime) {
        props.data.left = Number(props.data.left.replace("px", "")) + drag.moveDay * props.step + "px";
        props.data.start_time = drag.newStartTime;
        props.data.end_time = drag.newEndTime;
        emitData();
      }
      resetDrag();
    }

    function resetDrag () {
      drag.dragging = false;
      drag.resizing = false;
      drag.offsetX = 0;
      drag.moveDay = 0;
      drag.newStartTime = "";
      drag.newEndTime = "";
      drag.newWidth = "";
    }

    function getMoveDay (offsetX) {
      const adjust = Math.round((offsetX % props.step) / props.step);
      const moveDay = adjust + Math.trunc(offsetX / props.step);
      return moveDay;
    }

    function onResizeStart () {
      resetDrag();
      drag.newWidth = props.data.width;
      drag.newStartTime = props.data.start_time;
      drag.newEndTime = props.data.end_time;
      drag.resizing = true;
      document.addEventListener("mousemove", onResize);
      document.addEventListener("mouseup", onResizeEnd);
    }

    function onResize (e) {
      drag.offsetX += e.movementX;
      drag.moveDay = getMoveDay(drag.offsetX);
      drag.newWidth = Number(props.data.width.replace("px", "")) + drag.offsetX + "px";
      drag.newStartTime = dayjs(props.data.start_time).format("YYYY-MM-DD");
      drag.newEndTime = dayjs(props.data.end_time).add(drag.moveDay, "day").format("YYYY-MM-DD");
    }

    function onResizeEnd (e) {
      document.removeEventListener("mousemove", onResize);
      document.removeEventListener("mouseup", onResizeEnd);
      if (props.data.start_time !== drag.newStartTime || props.data.end_time !== drag.newEndTime) {
        props.data.width = Number(props.data.width.replace("px", "")) + drag.moveDay * props.step + "px";
        props.data.start_time = drag.newStartTime;
        props.data.end_time = drag.newEndTime;
      }
      emitData();
      resetDrag();
    }

    function emitData () {
      context.emit("change", props.data);
    }

    const isShowPopper = ref(false);
    function updatePoper (event) {
      const hoverDom = document.querySelector(".task-time-tooltip");
      const bodyWidth = document.body.clientWidth;
      const bodyHeight = document.body.clientHeight;

      // 超出屏幕的处理
      const left = (bodyWidth - event.pageX > 200) ? event.pageX - 10 : bodyWidth - 200;
      // 如果下方位置不够,弹框就显示在上面
      const top = (drag.dragging || drag.resizing || (bodyHeight - event.pageY > 200)) ? event.pageY + 20 : event.pageY - 20 - hoverDom.clientHeight;

      if (hoverDom) {
        hoverDom.style.top = top + "px";
        hoverDom.style.zIndex = 11111;
        hoverDom.style.left = left + "px";
      }
    }

    return {
      drag,
      onDragStart,
      onResizeStart,
      isShowPopper,
      updatePoper
    };
  }
};
</script>

<style lang="scss" scoped>
.node {
  position: absolute;
  top: 15px;
  cursor: grab;
  height: 16px;
  will-change: transform, width;

  &:hover,
  &.moving {
    .drag-handle {
      opacity: 1;
    }
  }
}

.bg-blue {
  background-color: $color-primary;
}

.bg-green {
  background-color: $color-green;
}

.bg-gray {
  background-color: #c8cacd;
}

.grabbing {
  cursor: grabbing !important;
}

.drag-handle {
  opacity: 0;
  display: flex;
  position: absolute;
  right: 0;
  top: 0;
  width: 16px;
  height: 16px;
  cursor: col-resize;
  align-items: center;
  justify-content: center;

  &:hover {
    .icon {
      opacity: 1;
    }
  }

  .icon {
    opacity: 0.7;
    width: 9px;
    height: 8px;
    background: linear-gradient(to right, black 1px, transparent 1px);
    background-size: 33.3%;
  }
}
</style>

<style lang="scss">
.task-time-tooltip {
  position: absolute;
  background-color: #000;
  color: #fff;
  opacity: 0.8;
  font-size: 12px;
  padding: 15px 10px;
  top: 0;
  z-index: -1;

  .line {
    display: flex;
    align-items: center;
  }

  .block {
    display: inline-block;
    width: 6px;
    height: 6px;
    border-radius: 1px;
    margin-right: 4px;

    &.bg-blue {
      background-color: $color-primary;
    }

    &.bg-green {
      background-color: $color-green;
    }

    &.bg-gray {
      background-color: #c8cacd;
    }
  }
}

</style>

优化与扩展:

1.依赖关系。使用工具库 vue3-leaderline 可实现。

2.大数据的性能问题。可采用虚拟列表的技术方案。不渲染所有列表项,而只是渲染可视区域内的一部分列表元素。

至此,从接到产品需求开始,调研现有工具库,到最后自己实现甘特图的整体思路就介绍完了。自己做时,发现网上相关对于如何实现甘特图的文章和工具库比较少,所以写了这一篇文章,希望能对大家有所帮助。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 4 条回复 时间 点赞

有 angluar 实现版本不?

3楼 已删除
邓⑤猫 回复

angluar 的实现版本 没有

因公司要求!源码无法全部提供,文章中提供的代码是重点源码

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册