vue3+element plus实现甘特图效果
在项目管理场景中,甘特图可能我们都不陌生,它是可视化任务进度和时间管理的利器。本文将演示如何使用Vue3结合Element Plus组件库,实现一个轻量级交互式甘特图组件,包含时间轴渲染、任务条、依赖关系展示等核心功能。
目录
一、安装组件
二、引入组件
三、使用组件实现甘特图
1.搭建框架
2.左侧固定列任务
3.右侧任务进度条
四、获取数据
五、滚动条实现分页
六、处理头部列表样式
七、全部代码
一、安装组件
对于项目的框架具体怎么安装,这里不做说明,下面是安装element plus组件方式。
npm install element-plus
二、引入组件
在框架中的main.js文件中引入刚安装的组件
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
app.use(ElementPlus)
三、使用组件实现甘特图
1.搭建框架
在项目中可能甘特图这个使用的地方会很多,所以在这里作为一个组件来进行开发,首先是搭建一个具体的框架样式,然后给他填充相应的数据
<template><div class="gantt"><el-table:data="tableData"bordermax-height="calc(100vh - 25.5vw)"height="400px":empty-text="emptyText":loading="loading"ref="tableRef"></el-table></div>
</template><script setup>
const tableData = ref([])
const emptyText = ref('暂无数据')
const tableRef = ref(null)
const loading = ref(false)
</script><style lang="less" scoped>
.gantt {
}
</style>
2.左侧固定列任务
甘特图如果大致分的话,其实分为两大部分,分别为左侧的固定列展示每个任务的具体数据信息,右侧为 每个任务的时间进度条。接下来我们先实现左侧的固定列部分,该代码是在以上代码的基础上进行展示
<!-- 左侧固定列 - 总体实施计划 --><el-table-column align="center" fixed="left" label="总体实施计划"><template #header><span class="header-overall-plan">总体实施计划</span></template><el-table-columnlabel="工单编号"prop="workOrderNum"align="center"min-width="120"fixed="left":show-overflow-tooltip="true"/><el-table-columnlabel="停电场所"prop="site"align="center"min-width="120"fixed="left":show-overflow-tooltip="true"/><el-table-columnlabel="停电范围"prop="range"align="center"min-width="120"fixed="left":show-overflow-tooltip="true"/><el-table-column label="进度" align="center" min-width="120" fixed="left"><template #default="scope"><span>计划</span><br /><span>实际</span></template></el-table-column></el-table-column>
3.右侧任务进度条
由于每个人任务的甘特图分为计划时间和实际时间,即在一个任务中需要显示两个任务进度条,所以在这里我们需要做一些处理。
<!-- 右侧甘特图部分 --><el-table-column align="center" label="检修甘特图"><template #header><span>检修甘特图</span></template><!-- 月份列 --><el-table-columnv-for="month in 12":key="month":label="`${currentYear}年${month}月`"align="center"min-width="150"><!-- 天数列 --><el-table-columnv-for="day in getDaysInMonth(currentYear, month)":key="`${currentYear}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`":prop="`${currentYear}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`":label="day.toString()"align="center"width="50"><template #default="scope"><div><i class="plan" v-if="isPlanDay(scope.row, scope.column.property)"></i><i class="empty" v-else></i><i :class="getActualityClass(scope.row)" v-if="isActualDay(scope.row, scope.column.property)"></i><i class="empty" v-else></i></div></template></el-table-column></el-table-column></el-table-column>
从上述的头部列表中我们可以看到使用了很多的方法,用于获取年份、月份和天数等,先来实现获取年份和月份的
// 当前年份
const currentYear = new Date().getFullYear()
接下来是获取天数的
// 获取指定月份的天数
const getDaysInMonth = (year, month) => {return new Date(year, month, 0).getDate()
}
然后就是要判断当前日期是否在计划时间的范围内,在写这个方法之前我们需要先来做个甘特图的配置
// 甘特图配置
const ganttConfig = {planBeginColumn: 'planBegin',planEndColumn: 'planEnd',actualityBeginColumn: 'actualityBegin',actualityEndColumn: 'actualityEnd'
}
// 判断当前日期是否在计划范围内
const isPlanDay = (row, currentDateStr) => {if (!currentDateStr || !row) return falseconst currentDay = new Date(currentDateStr)const begin = new Date(row[ganttConfig.planBeginColumn])const end = new Date(row[ganttConfig.planEndColumn])return currentDay >= begin && currentDay <= end
}
实际的也一样,需要做判断当前日期是否在实际范围内这样一个处理
// 判断当前日期是否在实际范围内
const isActualDay = (row, currentDateStr) => {if (!currentDateStr || !row || !row[ganttConfig.actualityBeginColumn] || !row[ganttConfig.actualityEndColumn])return falseconst currentDay = new Date(currentDateStr)const begin = new Date(row[ganttConfig.actualityBeginColumn])const end = new Date(row[ganttConfig.actualityEndColumn])return currentDay >= begin && currentDay <= end
}
由于对于实际时间的进度条颜色有区分的需求,所以还需要写个样式的方法,该方法可根据自身项目需要进行使用
const getActualityClass = row => {// 处理可能的null/undefined值const actualBegin = row.actualityBegin || ''const actualEnd = row.actualityEnd || ''// 增加trim处理防止空格干扰const hasActualBegin = actualBegin.trim() !== ''const hasActualEnd = actualEnd.trim() !== ''if (!hasActualBegin && !hasActualEnd) {return 'actuality-green'} else if (hasActualBegin && !hasActualEnd) {return 'actuality-yellow'} else {return 'actuality-grey'}
}
具体的样式代码
.gantt {// margin-top: 1vw;.plan {display: flex;width: calc(100% + 24px);height: 16px;background-color: #6dcaa6;margin: 0 -12px;/* border-radius: 15px; */}.actuality-green {display: flex;width: calc(100% + 24px);height: 16px;margin: 0 -12px;background-color: #6dcaa6;}.actuality-yellow {display: flex;width: calc(100% + 24px);height: 16px;margin: 0 -12px;background-color: #f59a23;}.actuality-grey {display: flex;width: calc(100% + 24px);height: 16px;margin: 0 -12px;background-color: #aaaaaa;}.empty {display: flex;width: calc(100% + 24px);height: 16px;margin: 0 -12px;}.legend {display: flex;line-height: 40px;flex-direction: row;justify-content: right;align-items: center;padding: 0 20px;i {width: 32px;height: 16px;}}:deep .el-table thead {color: #595858;}}
四、获取数据
以上代码概述完之后,甘特图大致的样式就可以实现了,接下来就是如何获取后端的数据进行展示,由于本案例获取后端数据的方法是封装axios后的,在这里不做过多的阐述
import { getManiProgressTable} from '@/api/home/home-center.js'
const getMainProgressData = params => {loading.value = truereturn new Promise((resolve, reject) => {getManiProgressTable(params).then(res => {if (res.code === 200 && res.data) {// console.log(res.data.total, '实时检修进度')total.value = res.data.total // 更新总条数tableData.value = res.data.data.map((item, index) => {return {workOrderNum: item.JHBH,site: item.TYDW,range: item.TDFW,actualityBegin: item.SJGZKSSJ.substring(0, 10),actualityEnd: item.SJGZJSSJ.substring(0, 10),planBegin: item.PZGZKSSJ.substring(0, 10),planEnd: item.PZGZJSSJ.substring(0, 10)}})resolve(res.data)} else {}}).catch(err => {reject(err)}).finally(() => {loading.value = false// 在数据加载完成后,恢复滚动位置setTimeout(() => {tableRef.value.$refs.bodyWrapper.scrollTop = currentScrollTop}, 100)})})
}
异步加载数据,由于该甘特图的数据过多,需要有分页的情况,下面代码将进行阐述如何做分页,在初始化加载数据时需要传分页参数
//定义开始入参
const initParams = ref({currentPage: 1,pageSize: 20
})
onMounted(() => {getMainProgressData(initParams.value)
})
五、滚动条实现分页
因为直接在 甘特图下方做分页效果太丑,所以新的需求是通过滚动条来实现分页效果展示数据,先在头部添加滚动事件
<el-table:data="tableData"bordermax-height="calc(100vh - 25.5vw)"height="400px":empty-text="emptyText":loading="loading"ref="tableRef"@scroll="handleScroll">
书写滚动分页事件方法
const pageSize = ref(20)
let currentPage = 1
const total = ref(0)
// 用于记录滚动位置
let currentScrollTop = 0
// 处理滚动事件,加载更多数据
const handleScroll = e => {if (e.scrollTop == 0) {return}const scrollHeight = tableRef.value.$refs.bodyWrapper.scrollHeightconst clientHeight = tableRef.value.$refs.tableBody.clientHeightconst scrollTop = Math.round(e.scrollTop) + 1// 是否触底判断const isBottom = scrollHeight + scrollTop >= clientHeightif (isBottom) {if (currentPage * pageSize.value < total.value) {// 记录当前滚动位置currentScrollTop = e.scrollTopcurrentPage++initParams.value = {currentPage,pageSize: pageSize.value}getMainProgressData(initParams.value)console.log('触底')}}
}
六、处理头部列表样式
由于以上书写的头部样式只是单纯的表格样式,过于简单,所以这里再重新做一下处理,在头部添加一个方法
<el-table:data="tableData"bordermax-height="calc(100vh - 25.5vw)"height="400px":empty-text="emptyText":loading="loading":header-cell-style="headerStyle"ref="tableRef"@scroll="handleScroll">
书写样式方法代码
const headerStyle = ({ column }) => {// console.log(column.label, 'column')if (column.label === '总体实施计划') {return { 'background-color': '#02A7F0' }}if (column.label === '检修甘特图') {return { 'background-color': '#FFE0B2' }}if (column.label === '进度' ||column.label === '工单编号' ||column.label === '停电场所' ||column.label === '停电范围') {return { 'background-color': '#BBDEFB' }}// const monthMatch = column.label?.match(/年(\d+)月/)// if (monthMatch) {// return { 'background-color': parseInt(monthMatch[1]) % 2 ? '#FFCDD2' : '#C8E6C9' }// }const isMonthColumn = column.label?.includes('年') && column.label?.includes('月')const isDayColumn = column.property?.includes('-') // 通过prop属性判断是否为天数列if (isMonthColumn || isDayColumn) {// 从label或property中提取月份const month = isMonthColumn ? parseInt(column.label.match(/年(\d+)月/)[1]) : parseInt(column.property.split('-')[1])return {'background-color': month % 2 ? '#FFCDD2' : '#C8E6C9',padding: '8px 0' // 保持原有内边距样式}}return {}
}
七、全部代码
<template><div class="gantt"><el-table:data="tableData"bordermax-height="calc(100vh - 25.5vw)"height="400px":empty-text="emptyText":loading="loading":header-cell-style="headerStyle"ref="tableRef"@scroll="handleScroll"><!-- 左侧固定列 - 总体实施计划 --><el-table-column align="center" fixed="left" label="总体实施计划"><template #header><span class="header-overall-plan">总体实施计划</span></template><el-table-columnlabel="工单编号"prop="workOrderNum"align="center"min-width="120"fixed="left":show-overflow-tooltip="true"/><el-table-columnlabel="停电场所"prop="site"align="center"min-width="120"fixed="left":show-overflow-tooltip="true"/><el-table-columnlabel="停电范围"prop="range"align="center"min-width="120"fixed="left":show-overflow-tooltip="true"/><el-table-column label="进度" align="center" min-width="120" fixed="left"><template #default="scope"><span>计划</span><br /><span>实际</span></template></el-table-column></el-table-column><!-- 右侧甘特图部分 --><el-table-column align="center" label="检修甘特图"><template #header><span>检修甘特图</span></template><!-- 月份列 --><el-table-columnv-for="month in 12":key="month":label="`${currentYear}年${month}月`"align="center"min-width="150"><!-- 天数列 --><el-table-columnv-for="day in getDaysInMonth(currentYear, month)":key="`${currentYear}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`":prop="`${currentYear}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`":label="day.toString()"align="center"width="50"><template #default="scope"><div><i class="plan" v-if="isPlanDay(scope.row, scope.column.property)"></i><i class="empty" v-else></i><i :class="getActualityClass(scope.row)" v-if="isActualDay(scope.row, scope.column.property)"></i><i class="empty" v-else></i></div></template></el-table-column></el-table-column></el-table-column></el-table></div>
</template><script setup>
import {getManiProgressTable} from '@/api/home/home-center.js'
// 当前年份
const currentYear = new Date().getFullYear()
const emptyText = ref('暂无数据')
//定义开始入参
const initParams = ref({currentPage: 1,pageSize: 20
})
// 表格引用
const tableRef = ref(null)
const pageSize = ref(20)
let currentPage = 1
const total = ref(0)
const loading = ref(false)
const tableData = ref([
])
// 甘特图配置
const ganttConfig = {planBeginColumn: 'planBegin',planEndColumn: 'planEnd',actualityBeginColumn: 'actualityBegin',actualityEndColumn: 'actualityEnd'
}// 获取指定月份的天数
const getDaysInMonth = (year, month) => {return new Date(year, month, 0).getDate()
}// 判断当前日期是否在计划范围内
const isPlanDay = (row, currentDateStr) => {if (!currentDateStr || !row) return falseconst currentDay = new Date(currentDateStr)const begin = new Date(row[ganttConfig.planBeginColumn])const end = new Date(row[ganttConfig.planEndColumn])return currentDay >= begin && currentDay <= end
}// 判断当前日期是否在实际范围内
const isActualDay = (row, currentDateStr) => {if (!currentDateStr || !row || !row[ganttConfig.actualityBeginColumn] || !row[ganttConfig.actualityEndColumn])return falseconst currentDay = new Date(currentDateStr)const begin = new Date(row[ganttConfig.actualityBeginColumn])const end = new Date(row[ganttConfig.actualityEndColumn])return currentDay >= begin && currentDay <= end
}
const getActualityClass = row => {// 处理可能的null/undefined值const actualBegin = row.actualityBegin || ''const actualEnd = row.actualityEnd || ''// 增加trim处理防止空格干扰const hasActualBegin = actualBegin.trim() !== ''const hasActualEnd = actualEnd.trim() !== ''if (!hasActualBegin && !hasActualEnd) {return 'actuality-green'} else if (hasActualBegin && !hasActualEnd) {return 'actuality-yellow'} else {return 'actuality-grey'}
}
const headerStyle = ({ column }) => {// console.log(column.label, 'column')if (column.label === '总体实施计划') {return { 'background-color': '#02A7F0' }}if (column.label === '检修甘特图') {return { 'background-color': '#FFE0B2' }}if (column.label === '进度' ||column.label === '工单编号' ||column.label === '停电场所' ||column.label === '停电范围') {return { 'background-color': '#BBDEFB' }}// const monthMatch = column.label?.match(/年(\d+)月/)// if (monthMatch) {// return { 'background-color': parseInt(monthMatch[1]) % 2 ? '#FFCDD2' : '#C8E6C9' }// }const isMonthColumn = column.label?.includes('年') && column.label?.includes('月')const isDayColumn = column.property?.includes('-') // 通过prop属性判断是否为天数列if (isMonthColumn || isDayColumn) {// 从label或property中提取月份const month = isMonthColumn ? parseInt(column.label.match(/年(\d+)月/)[1]) : parseInt(column.property.split('-')[1])return {'background-color': month % 2 ? '#FFCDD2' : '#C8E6C9',padding: '8px 0' // 保持原有内边距样式}}return {}
}
// 用于记录滚动位置
let currentScrollTop = 0
// 处理滚动事件,加载更多数据
const handleScroll = e => {if (e.scrollTop == 0) {return}const scrollHeight = tableRef.value.$refs.bodyWrapper.scrollHeightconst clientHeight = tableRef.value.$refs.tableBody.clientHeightconst scrollTop = Math.round(e.scrollTop) + 1// 是否触底判断const isBottom = scrollHeight + scrollTop >= clientHeightif (isBottom) {if (currentPage * pageSize.value < total.value) {// 记录当前滚动位置currentScrollTop = e.scrollTopcurrentPage++initParams.value = {...initParams.value,currentPage,pageSize: pageSize.value}getMainProgressData(initParams.value)console.log('触底')}}
}
const getMainProgressData = params => {loading.value = truereturn new Promise((resolve, reject) => {getManiProgressTable(params).then(res => {if (res.code === 200 && res.data) {// console.log(res.data.total, '实时检修进度')total.value = res.data.total // 更新总条数tableData.value = res.data.data.map((item, index) => {return {workOrderNum: item.JHBH,site: item.TYDW,range: item.TDFW,actualityBegin: item.SJGZKSSJ.substring(0, 10),actualityEnd: item.SJGZJSSJ.substring(0, 10),planBegin: item.PZGZKSSJ.substring(0, 10),planEnd: item.PZGZJSSJ.substring(0, 10)}})resolve(res.data)} else {}}).catch(err => {reject(err)}).finally(() => {loading.value = false// 在数据加载完成后,恢复滚动位置setTimeout(() => {tableRef.value.$refs.bodyWrapper.scrollTop = currentScrollTop}, 100)})})
}
onMounted(() => {getFilterData()// init()getMainProgressData(initParams.value)
})
</script><style lang="less" scoped>.gantt {// margin-top: 1vw;.plan {display: flex;width: calc(100% + 24px);height: 16px;background-color: #6dcaa6;margin: 0 -12px;/* border-radius: 15px; */}.actuality-green {display: flex;width: calc(100% + 24px);height: 16px;margin: 0 -12px;background-color: #6dcaa6;}.actuality-yellow {display: flex;width: calc(100% + 24px);height: 16px;margin: 0 -12px;background-color: #f59a23;}.actuality-grey {display: flex;width: calc(100% + 24px);height: 16px;margin: 0 -12px;background-color: #aaaaaa;}.empty {display: flex;width: calc(100% + 24px);height: 16px;margin: 0 -12px;}.legend {display: flex;line-height: 40px;flex-direction: row;justify-content: right;align-items: center;padding: 0 20px;i {width: 32px;height: 16px;}}:deep .el-table thead {color: #595858;}}
</style>