table标签实现甘特图效果

目录

0.背景

[1. 演示效果](#1. 演示效果)

2.实现原理

[2.1 处理表头数据(日期对应的是星期几)](#2.1 处理表头数据(日期对应的是星期几))

[2.2 获取项目数据](#2.2 获取项目数据)

[2.3 合并单元格](#2.3 合并单元格)

3.源码


0.背景

目遇到一个展示项目进度的需求,类似甘特图的效果,不同的是,每一个项目都有计划和实际两种,然后点击可以进行交互等。其实有很多可以拿来的甘特图组件,眼花缭乱的。但是因为我们需要自定义拓展,还是有些受限,干脆用table自己写了一个组件。

1. 演示效果

2.实现原理

选择两个日期,处理日期区间,渲染表头,获取数据后,根据不同的状态进行渲染即可。

用到的无非就是table的固定列和合并单元格。

2.1 处理表头数据(日期对应的是星期几)

用到了moment.js,根据所选择的日期区间,所处间隔天数,再根据日期进行当前日期是周几的运算,得出表头数据。如下图所示👇

java 复制代码
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 获取项目数据

项目数据的格式如下,因为一个项目分了两条数据,所以两条数据为一组。如下所示

javascript 复制代码
self.planData = [
            {
              startTime: '2024-10-02', // 开始时间
              endTime: '2024-10-05', // 结束时间
              entrustName: '一个大的项目名称', // 委托名称
              planId: '1', // 项目id
              planName: '测试项目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 合并单元格

根据项目的开始结束日期范围,进行单元格合并。如下图红色标注所示

javascript 复制代码
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.源码

直接铁源码吧

javascript 复制代码
<template>
  <div class="lcdp_axe_main  projectProgress">
    <div class="search-row">
      <div>
        <span class="ins_format default">时间范围</span>
        <el-date-picker
          size="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-dialog
      title="计划名称"
      :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>
相关推荐
CodeCraft Studio8 天前
【实用教程】使用思维导图增强 JavaScript甘特图项目工作流程的可见性
javascript·信息可视化·甘特图
Ganttable24 天前
甘特图代做服务
甘特图
进度猫24 天前
项目管理软件:5款甘特图工具测评
甘特图
RumbleWx1 个月前
什么是甘特图?
甘特图
Ganttable1 个月前
甘特图基线-用起来了吗~
甘特图
Ganttable2 个月前
免费的高质量、美观的甘特图模板
甘特图
和风微凉2 个月前
Highcharts甘特图基本用法(highcharts-gantt.js)
前端·javascript·echarts·甘特图
Ganttable2 个月前
5分钟快速制作高质量、美观的Excel甘特图
excel·甘特图
白鹭凡2 个月前
react 甘特图之旅
前端·react.js·甘特图