table标签实现甘特图效果
目录
0.背景
1. 演示效果
2.实现原理
2.1 处理表头数据(日期对应的是星期几)
2.2 获取项目数据
2.3 合并单元格
3.源码
0.背景
目遇到一个展示项目进度的需求,类似甘特图的效果,不同的是,每一个项目都有计划和实际两种,然后点击可以进行交互等。其实有很多可以拿来的甘特图组件,眼花缭乱的。但是因为我们需要自定义拓展,还是有些受限,干脆用table自己写了一个组件。
1. 演示效果
2.实现原理
选择两个日期,处理日期区间,渲染表头,获取数据后,根据不同的状态进行渲染即可。
用到的无非就是table的固定列和合并单元格。
2.1 处理表头数据(日期对应的是星期几)
用到了moment.js,根据所选择的日期区间,所处间隔天数,再根据日期进行当前日期是周几的运算,得出表头数据。如下图所示👇
getDaysWeek() {this.dateRange = [];const diffDays = moment(this.month[1]).diff(moment(this.month[0]), 'days') + 1;this.tableWidth = 800 + 100 * diffDays;const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];const dayOfWeek = moment(this.month[0]).day();for (let i = 0; i < diffDays; i++) {const day = moment(this.month[0]).add(i, 'days').format('YYYY-MM-DD');const week = weekDays[(dayOfWeek + i) % 7]this.dateRange.push({day: day, week: week})}}
2.2 获取项目数据
项目数据的格式如下,因为一个项目分了两条数据,所以两条数据为一组。如下所示
self.planData = [{startTime: '2024-10-02', // 开始时间endTime: '2024-10-05', // 结束时间entrustName: '一个大的项目名称', // 委托名称planId: '1', // 项目idplanName: '测试项目1', // 项目名称showName: '计划中', // 展示名称status: 0 // 状态:0:计划;1:执行中;2:已完成}, {endTime: "2024-10-04",entrustName: "一个大的项目名称",planId: "1",planName: "测试项目1",showName: "赶工完成",startTime: "2024-10-02",status: 2},{endTime: '2024-10-15',entrustName: '一个小的项目名称',planId: '2',planName: '测试项目2',showName: '计划中',startTime: '2024-10-09',status: 0}, {endTime: "",entrustName: "一个小的项目名称",planId: "2",planName: "测试项目2",showName: "实际执行",startTime: "2024-10-10",status: 1}];
2.3 合并单元格
根据项目的开始结束日期范围,进行单元格合并。如下图红色标注所示
handleColspanData() {const self = this;let colIndex = 0;self.mergeColumnsData = [];self.planData.forEach((item, index) => {colIndex = index % 2 === 0 ? 6 : 3;self.dateRange.forEach((child, childIndex) => {if (this.isBetweenCheck(item, child.day)) {// 判断mergeColumnsData是否存在?存在则替换,反之插入let id = index % 2 === 0 ? 'p-' + item.planId : 'r-' + item.planId;let targetIndex = self.mergeColumnsData.findIndex(col => col.id === id);if (targetIndex !== -1) {self.mergeColumnsData[targetIndex].colCount = self.mergeColumnsData[targetIndex].colCount + 1} else {self.mergeColumnsData.push({id: id,rowIndex: index,colIndex: colIndex + childIndex,colCount: 1});}}})});self.mergeColumns('gantt-table', self.mergeColumnsData);},
mergeColumns(tableId, mergeInfo) {const table = document.getElementById(tableId);if (!table) return;// 获取tbody元素const tbody = table.tBodies[0];if (!tbody) return;mergeInfo.forEach(info => {const startRow = info.rowIndex;const startCol = info.colIndex;const colCount = info.colCount || 1; // 默认合并1列// 获取需要合并的起始行const rowToMerge = tbody.rows[startRow];if (!rowToMerge) return;// 合并列if (colCount > 1) {const cellToMerge = rowToMerge.cells[startCol];if (!cellToMerge) return;cellToMerge.colSpan = colCount;// 删除后续的单元格for (let i = 1; i < colCount; i++) {const cell = rowToMerge.cells[startCol + i];if (!cell) break;cell.style.display = 'none'; // 隐藏单元格而不是删除// 标记为稍后删除cell.classList.add('remove-later');}}});document.querySelectorAll('.remove-later').forEach(cell => cell.remove());},
3.源码
直接铁源码吧
<template><div class="lcdp_axe_main projectProgress"><div class="search-row"><div><span class="ins_format default">时间范围</span><el-date-pickersize="mini"v-model="month":clearable="false"type="daterange"value-format="yyyy-MM-dd"start-placeholder="开始日期"end-placeholder="结束日期"@change="changeDate"></el-date-picker></div></div><div class="status-box"><p class="title">项目进度</p><p><span>状态:</span><span class="green-point"></span><span>计划</span><span class="blue-point"></span><span>已完成</span><span class="orange-point"></span><span>进行中</span></p></div><div class="gantt-box"><table id="gantt-table" :key="tabKey" :style="'width:' + tableWidth +'px'" cellspacing="0"><thead><tr><th style="width:100px" rowspan="2">序号</th><th style="width:200px" rowspan="2">委托名称</th><th style="width: 200px" rowspan="2">项目名称</th><th style="width: 300px" colspan="3">时间节点</th><th :colspan="dateRange.length">项目进度</th></tr><tr><th>执行</th><th>开始</th><th>结束</th><th style="width: 100px" v-for="(item, index) in dateRange" :key="index">{{ item.day.slice(5) }}<br/>{{ item.week }}</th></tr></thead><tbody><tr v-for="(item, index) in planData" :key="index"><td v-if="index%2===0" rowspan="2">{{ index / 2 + 1 }}</td><td v-if="index%2===0" rowspan="2">{{ item.entrustName }}</td><td v-if="index%2===0" rowspan="2">{{ item.planName }}</td><td>{{ index % 2 === 0 ? '计划' : '实际' }}</td><td>{{ item.startTime }}</td><td>{{ item.endTime }}</td><td v-for="(cell, ind) in dateRange" :key="'cell' + ind":ref="index%2===0?'p-':'r-'+item.planId+'-' + cell.day"><span :class="{'planBar': item.status === 0}"@click="granttCick(item,index)"v-if="item.status === 0&&isBetweenCheck(item,cell.day)">{{ item.showName }}</span><span :class="{'doingBar': item.status === 1}"@click="granttCick(item,index)"v-if="item.status === 1&&isBetweenCheck(item,cell.day)">{{ item.showName }}</span><span :class="{'completedBar': item.status === 2}"@click="granttCick(item,index)"v-if="item.status === 2&&isBetweenCheck(item,cell.day)">{{ item.showName }}</span></td></tr></tbody></table></div><el-dialogtitle="计划名称":close-on-click-modal="false":visible.sync="dialogVisible"width="30%"><el-form ref="planForm" :rules="rules" size="mini" :model="planForm" label-width="80px"><el-form-item label="计划名称" prop="showName"><el-input v-model="planForm.showName"></el-input></el-form-item></el-form><span slot="footer" class="dialog-footer"><el-button size="mini" @click="dialogVisible = false">取 消</el-button><el-button size="mini" type="primary" @click="save">确 定</el-button></span></el-dialog></div>
</template><script>
import moment from 'moment';export default {name: "projectProgress",data() {return {tabKey: Math.random(),month: [moment().format('YYYY-MM') + '-01', moment().format('YYYY-MM') + '-' + moment().daysInMonth()],dateRange: [],planData: [],tableWidth: 800,mergeColumnsData: [],dialogVisible: false,isPlanFlag: true,planForm: {},rules: {showName: {required: true, message: '请输入计划名称', trigger: 'change'}}}},async mounted() {await this.getDaysWeek();await this.getTaskProgressList();},methods: {async getTaskProgressList() {const self = this;let params = {planStartTime: self.month[0],planEndTime: self.month[1]}// self.planData = [];self.planData = [{endTime: '2024-10-05',entrustName: '一个大的项目名称',planId: '1',planName: '测试项目1',showName: '计划中',startTime: '2024-10-02',status: 0}, {endTime: "2024-10-04",entrustName: "一个大的项目名称",planId: "1",planName: "测试项目1",showName: "赶工完成",startTime: "2024-10-02",status: 2},{endTime: '2024-10-15',entrustName: '一个小的项目名称',planId: '2',planName: '测试项目2',showName: '计划中',startTime: '2024-10-09',status: 0}, {endTime: "",entrustName: "一个小的项目名称",planId: "2",planName: "测试项目2",showName: "实际执行",startTime: "2024-10-10",status: 1}];this.$nextTick(() => {this.handleColspanData();})},changeDate() {this.tabKey = Math.random();this.getDaysWeek();this.$nextTick(() => {this.getTaskProgressList();});},granttCick(data, index) {this.isPlanFlag = index % 2 === 0 ? true : false;this.planForm = JSON.parse(JSON.stringify(data));this.dialogVisible = true;},save() {const self = this;this.$refs.planForm.validate((valid) => {if (valid) {let params = {planId: self.planForm.planId,}if (self.isPlanFlag) {params.showName = self.planForm.showName;} else {params.actualName = self.planForm.showName;}setShowName(params).then(res => {if (res.code === 10000) {self.getTaskProgressList();} else {}}).catch(() => {})self.dialogVisible = false;} else {return false;}});},handleColspanData() {const self = this;let colIndex = 0;self.mergeColumnsData = [];self.planData.forEach((item, index) => {colIndex = index % 2 === 0 ? 6 : 3;self.dateRange.forEach((child, childIndex) => {if (this.isBetweenCheck(item, child.day)) {// 判断mergeColumnsData是否存在?存在则替换,反之插入let id = index % 2 === 0 ? 'p-' + item.planId : 'r-' + item.planId;let targetIndex = self.mergeColumnsData.findIndex(col => col.id === id);if (targetIndex !== -1) {self.mergeColumnsData[targetIndex].colCount = self.mergeColumnsData[targetIndex].colCount + 1} else {self.mergeColumnsData.push({id: id,rowIndex: index,colIndex: colIndex + childIndex,colCount: 1});}}})});self.mergeColumns('gantt-table', self.mergeColumnsData);},isBetweenCheck(data, current) {let startTime = moment(data.startTime);let endTime = moment(data.endTime === '' ? this.month[1] : data.endTime);let dateToCheck = moment(current);const isWithinRange = dateToCheck.isBetween(startTime, endTime, null, '[]');return isWithinRange;},mergeColumns(tableId, mergeInfo) {const table = document.getElementById(tableId);if (!table) return;// 获取tbody元素const tbody = table.tBodies[0];if (!tbody) return;mergeInfo.forEach(info => {const startRow = info.rowIndex;const startCol = info.colIndex;const colCount = info.colCount || 1; // 默认合并1列// 获取需要合并的起始行const rowToMerge = tbody.rows[startRow];if (!rowToMerge) return;// 合并列if (colCount > 1) {const cellToMerge = rowToMerge.cells[startCol];if (!cellToMerge) return;cellToMerge.colSpan = colCount;// 删除后续的单元格for (let i = 1; i < colCount; i++) {const cell = rowToMerge.cells[startCol + i];if (!cell) break;cell.style.display = 'none'; // 隐藏单元格而不是删除// 标记为稍后删除cell.classList.add('remove-later');}}});document.querySelectorAll('.remove-later').forEach(cell => cell.remove());},getDaysWeek() {this.dateRange = [];const diffDays = moment(this.month[1]).diff(moment(this.month[0]), 'days') + 1;this.tableWidth = 800 + 100 * diffDays;const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];const dayOfWeek = moment(this.month[0]).day();for (let i = 0; i < diffDays; i++) {const day = moment(this.month[0]).add(i, 'days').format('YYYY-MM-DD');const week = weekDays[(dayOfWeek + i) % 7]this.dateRange.push({day: day, week: week})}}}
}
</script><style lang="scss" scoped>
.lcdp_axe_main {overflow: auto;width: calc(100% - 20px);height: calc(100% - 20px);position: relative;top: 10px;left: 10px;right: 10px;bottom: 10px;display: block;padding: 12px;background: #ffffff;.search-row {height: 40px;margin-bottom: 10px;display: flex;justify-content: space-between;border-bottom: 1px solid var(--c-borderColor);;.ins_format {margin-right: 10px;}}.status-box {display: flex;justify-content: space-between;align-items: center;padding: 0 20px;height: 40px;.title {font-weight: bold;padding-left: 5px;border-left: 5px solid var(--c-themeColor);}.green-point, .blue-point, .orange-point {display: inline-flex;width: 15px;height: 15px;border-radius: 50%;margin-left: 20px;margin-right: 5px;}.green-point {background: #00b57b;}.blue-point {background: #18aaf1;}.orange-point {background: #f19418;}}.gantt-box {min-width: 100%;height: calc(100% - 90px);overflow: auto;#gantt-table {table-layout: fixed;min-width: 100%;td, th {border: 1px solid #d5d9dc;height: 45px;text-align: center;color: var(--c-mainTxtColor);}thead tr:nth-child(1),tbody tr:nth-child(odd) {td:nth-child(1),th:nth-child(1) {position: sticky;left: 0;z-index: 1;background: #f7fbff;}td:nth-child(2),th:nth-child(2) {position: sticky;left: 100px;z-index: 1;background: #f7fbff;}td:nth-child(3),th:nth-child(3) {position: sticky;left: 300px;z-index: 1;background: #f7fbff;}td:nth-child(4),th:nth-child(4) {position: sticky;left: 500px;z-index: 1;background: #f7fbff;}td:nth-child(5) {position: sticky;left: 600px;z-index: 1;background: #f7fbff;}td:nth-child(6) {position: sticky;left: 700px;z-index: 1;background: #f7fbff;}}tbody tr:nth-child(even) {td:nth-child(1) {position: sticky;left: 500px;z-index: 1;background: #f7fbff;}td:nth-child(2) {position: sticky;left: 600px;z-index: 1;background: #f7fbff;}td:nth-child(3) {position: sticky;left: 700px;z-index: 1;background: #f7fbff;}}thead tr:nth-child(2) {th:nth-child(1) {position: sticky;left: 500px;z-index: 1;background-color: lightpink;}th:nth-child(2) {position: sticky;left: 600px;z-index: 1;background-color: lightpink;}th:nth-child(3) {position: sticky;left: 700px;z-index: 1;background-color: lightpink;}}thead tr:nth-child(1) {th:nth-child(1),th:nth-child(2),th:nth-child(3),th:nth-child(4) {position: sticky;background: #f7fbff;z-index: 2;top: 0px;}th {position: sticky;background: #f7fbff;font-size: 16px;font-weight: bold;z-index: 1;top: 0px;}}thead tr:nth-child(2) {th:nth-child(1),th:nth-child(2),th:nth-child(3) {position: sticky;z-index: 2;background: #f7fbff;top: 45px;}th {position: sticky;z-index: 1;background: #f7fbff;top: 45px;}}tbody tr:nth-child(odd) td {border-top: 2px solid var(--c-normalTxtColor);}.planBar {display: inline-block;width: 100%;height: 60%;background: #00b57b;background-image: repeating-linear-gradient(45deg,hsla(0, 0%, 100%, 0.1),hsla(0, 0%, 100%, 0.1) 15px,transparent 0,transparent 30px);}.doingBar {display: inline-block;width: 100%;height: 60%;background: #f19418;background-image: repeating-linear-gradient(45deg,hsla(0, 0%, 100%, 0.1),hsla(0, 0%, 100%, 0.1) 15px,transparent 0,transparent 30px);}.completedBar {display: inline-block;width: 100%;height: 60%;background: #18aaf1;background-image: repeating-linear-gradient(45deg,hsla(0, 0%, 100%, 0.1),hsla(0, 0%, 100%, 0.1) 15px,transparent 0,transparent 30px);}span {color: #ffffff;font-weight: bold;font-size: 18px;cursor: pointer;}span:hover {box-shadow: 0 0 5px 5px #eaeaea;background-image: url("~@/assets/img/icon-edit.png");background-repeat: no-repeat;background-size: auto 80%;background-position: 10px center;}}}
}
</style>