移动测试开发 从 0 到 1 实现甘特图
本文源起于产品说要做一个甘特图。
甘特图作为一种时间管理工具,在项目管理中有着广泛的应用。对比传统的列表视图,它通过图形化的方式展示任务的进度和时间范围,更加直观,日期调整交互更方便,能更好的避免任务重叠和时间冲突。
产品主要需求:
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.大数据的性能问题。可采用虚拟列表的技术方案。不渲染所有列表项,而只是渲染可视区域内的一部分列表元素。
至此,从接到产品需求开始,调研现有工具库,到最后自己实现甘特图的整体思路就介绍完了。自己做时,发现网上相关对于如何实现甘特图的文章和工具库比较少,所以写了这一篇文章,希望能对大家有所帮助。