目录
- 一、前言
- 二、主要功能
- 三、功能实现
- 四、实现效果
- 总结
一、前言
接到公司需求,要做一个可拖拽的甘特图来实现排期需求,官方的插件要付费还没有中文的官方文档可以看,就去找了各种开源的demo来看,功能上都不是很齐全,于是总结了很多demo,合在一起组成了一版较为完整的满足需求的甘特图。
二、主要功能
1.拖拽 拖拽功能是甘特图的主要功能,该demo实现了甘特图时间块上、下、左、右拖拽功能。
2.排序 拖拽后时间块进行排序,计算重叠区域大小确定插入位置。
3.时间选择 结合element-ui的日期时间选择器来确定时间轴。
4.搜索 搜索已存在的时间块,并定位到相应位置。
5.新建排期任务 使用element-ui的弹框以及表单 新建成功的排期列表添加到排期任务中。
6.右键菜单 右击时间块,可以进行查看、删除、修改等操作。
7.撤回 每删除或移动时间块后,增加一条操作记录,点击撤回可撤回当前操作。
8.批量操作 在批量操作后点击保存,才向后端存储数据。
三、功能实现
1.默认时间轴线今天的前三后四
// 设置默认时间 当前日期前三后四 | |
defaultDate() { | |
const beg = new Date(new Date().getTime() - * 1000 * 24 * 3) | |
.toISOString() | |
.replace('T', ' ') | |
.split('.')[] //默认开始时间3天前 | |
const end = new Date(new Date().getTime() + * 1000 * 24 * 4) | |
.toISOString() | |
.replace('T', ' ') | |
.split('.')[]//默认结束时间4天后 | |
this.choiceTime = [beg, end] //将值设置给插件绑定的数据 | |
// console.log(this.value); | |
}, |
2.拖拽事件实现
onMouseDown(e, blockId, rowIndex) { | |
// 删除模式下不处理拖动事件 | |
if (this.isDeleteMode) { | |
return; | |
} | |
this.moveX =; | |
this.moveY =; | |
// 用box 移动,不采用 Doucment | |
const box = this.$refs.box; | |
const dom = e.target; | |
// 算出鼠标相对元素的位置 | |
const disX = e.clientX - dom.offsetLeft; | |
const disY = e.clientY - dom.offsetTop; | |
console.log('鼠标正在拖动',e.clientX,dom.offsetLeft); | |
// 当点击下来的时候 nowSuck 其实等于的就是当前index | |
this.nowSuck = rowIndex; | |
// 让本来拥有手掌样式的class取消 | |
dom.classList.remove('gantt-grab'); | |
// 让整个box 鼠标都是抓住 | |
box.classList.add('gantt-grabbing'); | |
// 如果事件绑定在 dom上,那么一旦鼠标移动过快就会脱离掌控 | |
box.onmousemove = ee => { | |
// 获得当前受到拖拽的div 用于计算suck 所谓拖引的行数 | |
const top = parseInt(dom.style.top); | |
// 四舍五入 获得磁性吸附激活的值 (索引值)是block的height 10是时间块距离block的top suck 可以当作row的索引 | |
let suck = Math.round((top -) / 60) + rowIndex; | |
// suck的边界值设置 | |
if (suck <) { | |
suck =; | |
} else if (suck > this.ganttData.length -) { | |
suck = this.ganttData.length -; | |
} | |
// suck 行样式变化 | |
this.nowSuck = suck; | |
// 移动事件 | |
this.onMouseMove(ee, disX, disY, dom); | |
// dom.style.left=this.moveX; | |
}; | |
// 不管在哪里,鼠标松开的时候,清空事件 所以对于这个 “不管在哪里,使用了window” | |
window.onmouseup = () => { | |
// 鼠标松开了,让时间块恢复手掌样式 | |
dom.classList.add('gantt-grab'); | |
// 整个box 不在抓住了,变成箭头鼠标 | |
box.classList.remove('gantt-grabbing'); | |
// 当移动距离小于时,不做移动处理 | |
//console.log('移动距离:', this.moveX, this.moveY); | |
if (this.moveX < && this.moveY < 1 && this.moveX > -1 && this.moveY > -1) { | |
console.log('无效移动'); | |
box.onmousemove = null; | |
window.onmouseup = null; | |
this.nowSuck = -; | |
return; | |
} | |
// 保存操作日志 | |
this._addHisList(this.ganttData); | |
const index = this.ganttData[rowIndex][this.listKey].findIndex(item => { | |
return item.id === blockId; | |
}); | |
const oldTimeBlock = this.ganttData[rowIndex][this.listKey][index]; | |
// let timeId = oldTimeBlock.id; | |
// startTime 与 endTime 用于数据重新替换 上面css已经经过计算px为1小时 | |
const startTime = new Date(Date.parse(this.choiceTime[]) + (parseInt(dom.style.left) * 3600000) / 15); | |
const endTime = new Date(this._getTime(startTime) + (parseInt(dom.style.width) *) / 15); | |
// console.log(startTime, endTime, dom.style.width, parseInt(dom.style.left) * * 1000); | |
const suck = this.nowSuck; | |
// 加入新数据 | |
const data = oldTimeBlock; | |
// 更新开始时间和结束时间 | |
this.$set(data, 'startTime', startTime); | |
this.$set(data, 'endTime', endTime); | |
// 修改时间块的equipmentId | |
this.$set(data, 'equipmentId', this.ganttData[suck].id); | |
/** | |
* 本来dom元素磁性吸附是打算用document.appendChild() 方式来做的,但是对于vue来说 for出来的子元素就算变了位置,其index也不属于新的row | |
*/ | |
// 老数据 删除 | |
this.ganttData[rowIndex][this.listKey].splice(index,); | |
// 新数据加入 | |
this.ganttData[suck][this.listKey].push(data); | |
// 坐标定位 磁性吸附 永远的px 不知道为啥动态绑定的元素也会受到以前元素的影响,可能是 for 中 index带来的影响 | |
dom.style.top = this.defaultTop + 'px'; | |
// 松开鼠标的时候 清空 | |
box.onmousemove = null; | |
window.onmouseup = null; | |
// 需要重新计算吸附位置,以及整行重新排列 | |
this.$nextTick(() => { | |
this._recalcRowTimes(data, this.ganttData[suck][this.listKey]); | |
}); | |
// 将当前row 清空 | |
this.nowSuck = -; | |
// 改变位置后回调事件 | |
this.afterChangePosition(data, this.ganttData[rowIndex].id, this.ganttData[suck].id); | |
}; | |
}, | |
/** | |
* 鼠标移动的时候,前置条件鼠标按下 | |
* @param e 时间块的 event 事件 | |
* @param disX x轴 | |
* @param disY y轴 | |
* @param targetDom 时间块的dom 其实可以直接 e.target 获取 | |
*/ | |
onMouseMove(e, disX, disY, targetDom) { | |
// 用鼠标的位置减去鼠标相对元素的位置,得到元素的位置 | |
let left = e.clientX - disX; | |
const top = e.clientY - disY; | |
console.log('x轴移动距离',left); | |
this.moveX = left; | |
this.moveY = top; | |
// 移动位置不能小于零(开始时间) | |
if (left <) { | |
left =; | |
} | |
//拖动时间块关闭右键菜单 | |
this.menuVisible = false; | |
targetDom.style.left = left + 'px'; | |
targetDom.style.top = top + 'px'; | |
}, | |
/** | |
* 时间块位置变化后回调事件 | |
* @param data 时间块的值 包括时间块id startTime endTime | |
* @param rowOId 时间块原来所在的那个飞机id (一条横线) | |
* @param rowNId 时间块新的所在的那个飞机id | |
*/ | |
afterChangePosition(data, rowOId, rowNId) { | |
console.log('时间块位置变化后回调事件', rowOId, rowNId); | |
}, | |
save() { | |
console.log(JSON.stringify(this.ganttData)); | |
}, |
3. 右击设置自定义右键菜单
onRightClick(MouseEvent, row, block) { | |
//编辑需要把时间长度先计算好 | |
MouseEvent.preventDefault(); //关闭浏览器右键默认事件 | |
block.timeDiff = (block.endTime - block.startTime) /; | |
this.editRow = row; | |
this.editBlock = block; | |
// this.menuVisible = false; // 先把模态框关死,目的是 第二次或者第n次右键鼠标的时候 它默认的是true | |
this.menuVisible = true; // 显示模态窗口,跳出自定义菜单栏 | |
console.log('唤醒点击事件', this.menuVisible, this.editBlock, MouseEvent.clientX); | |
this.CurrentRow = row; | |
var menu = document.querySelector('.menu'); | |
if (MouseEvent.clientX >) { | |
menu.style.left = MouseEvent.clientX - + 'px'; | |
} else { | |
menu.style.left = MouseEvent.clientX + + 'px'; | |
} | |
document.addEventListener('click', this.cancelMouse); // 给整个document新增监听鼠标事件,点击任何位置执行foo方法 | |
if (MouseEvent.clientY >) { | |
menu.style.top = MouseEvent.clientY - + 'px'; | |
} else { | |
menu.style.top = MouseEvent.clientY - + 'px'; | |
} | |
console.log('位置問題', MouseEvent.clientY - + 'px', MouseEvent.clientY - 10 + 'px'); | |
// this.styleMenu(menu); | |
}, | |
cancelMouse() { | |
// document.oncontextmenu=false; | |
// 取消鼠标监听事件 菜单栏 | |
this.menuVisible = false; | |
document.removeEventListener('click', this.foo); // 关掉监听, | |
}, |
4.计算时间选择器相差天数以渲染时间轴
choiceTimeArr() { | |
const timeArr = []; | |
// 时间戳毫秒为单位 | |
// 尾时间-首时间 算出头尾的时间戳差 再换算成天单位 毫秒->分->时->天 | |
// const diffDays = (this.choiceTime[].getTime() - this.choiceTime[0].getTime()) / 1000 / 60 / 60 / 24; | |
const diffDays = Math.abs(Date.parse(this.choiceTime[])- Date.parse(this.choiceTime[0])) / 1000 / 60 / 60 / 24 | |
console.log('我是时间差啊', diffDays); | |
// 一天的时间戳) | |
const oneDayMs = * 60 * 60 * 1000; | |
// 差了多少天就便利多少天 首时间+当前便利的天数的毫秒数 | |
for (let i =; i < diffDays + 1; i++) { | |
// 时间戳来一个相加,得到的就是时间数组 | |
timeArr.push(new Date(Date.parse(this.choiceTime[]) + i * oneDayMs)); | |
} | |
// console.log(diffDays, oneDayMs, timeArr); | |
return timeArr; | |
}, |
5.搜索功能(使用element-ui的示例)
// 搜索数据数组 | |
loadAll() { | |
return [ | |
{ "value": "三全鲜食(北新泾店)", "address": "长宁区新渔路号" }, | |
{ "value": "Hot honey 首尔炸鸡(仙霞路)", "address": "上海市长宁区淞虹路号" }, | |
{ "value": "新旺角茶餐厅", "address": "上海市普陀区真北路号创邑金沙谷6号楼113" }, | |
{ "value": "泷千家(天山西路店)", "address": "天山西路号" }, | |
{ "value": "胖仙女纸杯蛋糕(上海凌空店)", "address": "上海市长宁区金钟路号1幢18号楼一层商铺18-101" }, | |
{ "value": "贡茶", "address": "上海市长宁区金钟路号" }, | |
{ "value": "豪大大香鸡排超级奶爸", "address": "上海市嘉定区曹安公路曹安路号" }, | |
{ "value": "茶芝兰(奶茶,手抓饼)", "address": "上海市普陀区同普路号" }, | |
{ "value": "十二泷町", "address": "上海市北翟路弄81号B幢-107" }, | |
{ "value": "星移浓缩咖啡", "address": "上海市嘉定区新郁路号" }, | |
{ "value": "阿姨奶茶/豪大大", "address": "嘉定区曹安路号" }, | |
{ "value": "新麦甜四季甜品炸鸡", "address": "嘉定区曹安公路弄55号" } | |
]; | |
}, | |
querySearchAsync(queryString, cb) { | |
var restaurants = this.restaurants; | |
var results = queryString ? restaurants.filter(this.createStateFilter(queryString)) : restaurants; | |
clearTimeout(this.timeout); | |
this.timeout = setTimeout(() => { | |
cb(results); | |
}, * Math.random()); | |
}, | |
createStateFilter(queryString) { | |
return (state) => { | |
return (state.value.toLowerCase().indexOf(queryString.toLowerCase()) ===); | |
}; | |
}, | |
handleSelect(item) { | |
console.log(item); | |
}, |
四、实现效果
由于需求是以弹框形式实现,没有做全屏显示,具体效果如下:
甘特图实现