vue2纯手撸一个简单的甘特图

83 阅读13分钟

背景是公司的需求,要做一个项目人力展示,刚接手这个项目,这个项目里有类似的甘特图用的是dhtmlx-gantt,这个国外的库功能很强大,但是缺点也很明显:

文档是英文的,没有中文版
一些功能是收费的
样式很难自定义
...

基于这样的考虑,再加上需求不是很难,只是做进度展示,就自己写了,先上成果图

image.png

功能很简单只做年月的切换,左侧的树结构只有一层。

下面说一下思路:

1.布局:由于设计稿就这样左右中间有个缝隙,我就左右都基于树的数据去遍历,只是右侧不展示树而已,右侧也是有滚动条的只是隐藏掉了,和左侧的进度条同步滚动,同理上面的日期也是和下面的同步滚动。 2.日期:日期的展示是最重要的,我的思路是拿到后台的数据,找到最早的和最晚的,基于这个去计算两个日期经过了多少天月年,展示出来,进度条是用定位做的,需要计算出宽度和left,宽度就是每条数据两个日期经过的天数,(月就/30),left就是当前数据的开始时间和所有数据的最早时间的跨度。 3.不多说了,直接看代码吧,我把数据用json写好,放在vue项目里能直接运行 4.只是提供一个雏形,更多的功能和样式自定义完全可以基于这个去做

<!-- 
  项目资源占用模块
  @ lizibin
 -->
<template>
  <div class="resourceOccupy">
    <div class="resourceOccupy-search">
      <!-- <div class="resourceOccupy-search-left">
        <div style="margin:0 5px;">
          <el-date-picker style="height: 36px;width: 250px" v-model="date" type="daterange" range-separator="至"
            start-placeholder="开始日期" end-placeholder="结束日期" value-format="yyyy-MM-dd"
            @change="changeList"></el-date-picker>
        </div>
        <div style="margin: 0 5px; width: 250px;font-size: small;">
          <treeselect v-model="orderOrgLimit" :options="datalist" :multiple="false" placeholder="选择组织" />
        </div>
        <div>
          <el-button type="primary" size="mini"
            style="margin-left:5px;background-color:#2675FB;color:#FFFFFF;height: 34px;" @click="_search()">搜索</el-button>
        </div>
      </div> -->
      <div class="resourceOccupy-search-right">
        <el-radio-group v-model="dateChaneValue" size="medium" @change="_dateChange">
          <el-radio-button label="month"></el-radio-button>
          <el-radio-button label="year"></el-radio-button>
        </el-radio-group>
      </div>
    </div>

    <div v-loading="loading" style="height: 100%;">
      <div class="resourceOccupy-top">
        <div class="resourceOccupy-top-left">
          <div class="resourceOccupy-top-left-title">
            <!-- 表头固定死的 -->
            <div>工号</div>
            <div>负责人</div>
          </div>
        </div>

        <div class="resourceOccupy-top-right" ref="taskDiv">
          <!-- 啊啊啊啊啊啊啊 头要爆炸了 -->
          <!--**************************** 第一行年 start *****************************-->
          <!-- 固定的 -->
          <div class="resourceOccupy-top-right-one"
            v-if="taskWidth < (monthData.length * itemWidth + monthData.length) && dateChaneValue === 'year' && yearData.length !== 0">
            <!-- 年  -->
            <!-- 如果大于taskWidth -->
            <span :style="{ 'width': (item.num * itemWidth) + 'px' }" v-for="item in yearData" :key="item.name">{{
              item.name
            }}</span>
          </div>
          <!-- 自适应的 -->
          <div class="resourceOccupy-top-right-one" style="display: flex;"
            v-if="taskWidth >= (monthData.length * itemWidth + monthData.length) && dateChaneValue === 'year' && yearData.length !== 0">
            <!-- 年 -->
            <!-- 判断宽度是不是比taskWidth要宽 -->
            <!-- 如果小于taskWidth -->
            <span :style="{ 'width': (item.num * (taskWidth / monthData.length)) + 'px' }" v-for="item in yearData"
              :key="item.name">{{
                item.name
              }}</span>
          </div>
          <!--yearData为空的 -->
          <div class="resourceOccupy-top-right-one" style="display: flex;"
            v-if="dateChaneValue === 'year' && yearData.length === 0">
            <!-- 年 -->
            <!-- 判断宽度是不是比taskWidth要宽 -->
            <!-- 如果小于taskWidth -->
            <span style="width: 100%;">{{ thisYearData.year }}</span>
          </div>
          <!--**************************** 第一行年 end *****************************-->

          <!--**************************** 第一行月 start *****************************-->
          <!-- 固定 -->
          <div class="resourceOccupy-top-right-one"
            v-if="taskWidth < (dayData.length * itemWidth + dayData.length) && dateChaneValue === 'month' && monthDataToOne.length !== 0">
            <span :style="{ 'width': (item.num * itemWidth) + 'px' }" v-for="(item, index) in monthDataToOne"
              :key="index">{{
                `${item.name.split('-')[0]}年${item.name.split('-')[1]}月`
              }}</span>
          </div>
          <!-- 自适应 -->
          <div class="resourceOccupy-top-right-one" style="display: flex;"
            v-if="taskWidth >= (dayData.length * itemWidth + dayData.length) && dateChaneValue === 'month' && monthDataToOne.length !== 0">
            <span :style="{ 'width': (item.num * (taskWidth / monthDataToOne.length)) + 'px' }"
              v-for="(item, index) in monthDataToOne" :key="index">{{
                `${item.name.split('-')[0]}年${item.name.split('-')[1]}月`
              }}</span>
          </div>
          <!-- monthDataToOne为空的 -->
          <div class="resourceOccupy-top-right-one" v-if="dateChaneValue === 'month' && monthDataToOne.length === 0">
            <span style="width: 100%;">{{ thisMonthData.month }}</span>
          </div>
          <!--**************************** 第一行月 end *****************************-->

          <!--**************************** 第二行年 start *****************************-->
          <!-- 固定 -->
          <div class="resourceOccupy-top-right-two"
            v-if="taskWidth < (monthData.length * itemWidth + monthData.length) && dateChaneValue === 'year' && monthData.length !== 0">
            <span :style="{ width: itemWidth + 'px' }" v-for="(item, index) in monthData" :key="index">{{
              item.slice(-2) }}</span>
          </div>
          <!-- 自适应的 -->
          <div class="resourceOccupy-top-right-two" style="display: flex;"
            v-if="taskWidth >= (monthData.length * itemWidth + monthData.length) && dateChaneValue === 'year' && monthData.length !== 0">
            <!-- 月 -->
            <span :style="{ 'width': (taskWidth / monthData.length) + 'px' }" v-for="(item, index) in monthData"
              :key="index">{{
                item.slice(-2) }}</span>
          </div>
          <!-- monthData为空 -->
          <div class="resourceOccupy-top-right-two" style="display: flex;"
            v-if="dateChaneValue === 'year' && monthData.length === 0">
            <!-- 月 -->
            <span style="width: 100%;" v-for="(item, index) in thisYearData.month" :key="index">{{
              item }}</span>
          </div>
          <!--**************************** 第二行年 end *****************************-->

          <!--**************************** 第二行月 start *****************************-->
          <!-- 固定 -->
          <div class="resourceOccupy-top-right-two"
            v-if="taskWidth < (dayData.length * itemWidth + dayData.length) && dateChaneValue === 'month' && dayData.length !== 0">
            <span :style="{ width: itemWidth + 'px' }" v-for="(item, index) in dayData" :key="index">{{ item }}</span>
          </div>
          <!-- 自适应 -->
          <div class="resourceOccupy-top-right-two" style="display: flex;"
            v-if="taskWidth >= (dayData.length * itemWidth + dayData.length) && dateChaneValue === 'month' && dayData.length !== 0">
            <span :style="{ 'width': (taskWidth / dayData.length) + 'px' }" v-for="(item, index) in dayData"
              :key="index">{{
                item }}</span>
          </div>
          <!-- dayData为空 -->
          <div class="resourceOccupy-top-right-two" style="display: flex;"
            v-if="dateChaneValue === 'month' && dayData.length === 0">
            <span style="width: 100%;" v-for="(item, index) in thisMonthData.day" :key="index">{{
              item }}</span>
          </div>
          <!--**************************** 第二行月 end *****************************-->
        </div>
      </div>
      <!-- 表格数据 -->
      <div class="resourceOccupy-bottom">
        <!-- 只有一层的树没啥难度 -->
        <div class="resourceOccupy-bottom-left" ref="verticalLeft" @scroll="sysHandleScroll()">
          <div class="resourceOccupy-bottom-left-item" v-for="(item, index) in TaskData" :key="item.id">
            <div class="item-father"><span v-if="!item.open" @click="_imgChange(index)"><i
                  class="el-icon-plus"></i></span> <span v-if="item.open" @click="_imgChange(index)"><i
                  class="el-icon-minus"></i></span> <span :title="item.label">{{ item.label }}</span></div>
            <div v-if="item.open">
              <div class="item-son" :style="{ 'background': i.user === currentPeople.user ? '#ededed' : 'none' }"
                @click="_clickSon(i)" v-for="i in item.children" :key="i.user">
                <div :title="i.user">{{ i.user }}</div>
                <div>{{ i.label }}</div>
              </div>
            </div>
          </div>
        </div>
        <div class="resourceOccupy-bottom-right" ref="verticalRight" @scroll="sysHandleScrol2()">
          <div class="resourceOccupy-bottom-right-item" v-for="(item, index) in TaskData" :key="item.id">
            <!--**************************** 年 start *****************************-->
            <!-- 固定 年 -->
            <div class="item-father"
              v-if="taskWidth < (monthData.length * itemWidth + monthData.length) && dateChaneValue === 'year'">
              <span :style="{ width: itemWidth + 'px' }" v-for="(item, index) in monthData" :key="index">
              </span>
            </div>
            <!-- 自适应的 年-->
            <div class="item-father" style="display: flex;"
              v-if="taskWidth >= (monthData.length * itemWidth + monthData.length) && dateChaneValue === 'year'">
              <span :style="{ 'width': (taskWidth / monthData.length) + 'px' }" v-for="(item, index) in monthData"
                :key="index"></span>
            </div>
            <!--***************************** 年 end ******************************-->
            <!--**************************** 月 start ****************************-->
            <!-- 固定 月 -->
            <div class="item-father"
              v-if="taskWidth < (dayData.length * itemWidth + dayData.length) && dateChaneValue === 'month'">
              <span :style="{ width: itemWidth + 'px' }" v-for="(item, index) in dayData" :key="index">
              </span>
            </div>
            <!-- 自适应的 月-->
            <div class="item-father" style="display: flex;"
              v-if="taskWidth >= (dayData.length * itemWidth + dayData.length) && dateChaneValue === 'month'">
              <span :style="{ 'width': (taskWidth / dayData.length) + 'px' }" v-for="(item, index) in dayData"
                :key="index"></span>
            </div>
            <!-- *****************************月 end ****************************-->
            <div v-if="item.open" class="item-son" v-for="i in item.children" :key="i.user">
              <!--***************************** 年 start ****************************-->
              <!-- 固定 -->
              <div v-if="taskWidth < (monthData.length * itemWidth + monthData.length) && dateChaneValue === 'year'">
                <span :style="{ width: itemWidth + 'px' }" v-for="(item, index) in monthData" :key="index"></span>
              </div>

              <!-- 自适应的 -->
              <div style="display: flex;"
                v-if="taskWidth >= (monthData.length * itemWidth + monthData.length) && dateChaneValue === 'year'">
                <span :style="{ 'width': (taskWidth / monthData.length) + 'px' }" v-for="(item, index) in monthData"
                  :key="index"></span>
              </div>
              <!--***************************** 年 end ******************************-->
              <!--***************************** 月 start ****************************-->
              <!-- 固定 -->
              <div v-if="taskWidth < (dayData.length * itemWidth + dayData.length) && dateChaneValue === 'month'">
                <span :style="{ width: itemWidth + 'px' }" v-for="(item, index) in dayData" :key="index"></span>
              </div>

              <!-- 自适应的 -->
              <div style="display: flex;"
                v-if="taskWidth >= (dayData.length * itemWidth + dayData.length) && dateChaneValue === 'month'">
                <span :style="{ 'width': (taskWidth / dayData.length) + 'px' }" v-for="(item, index) in dayData"
                  :key="index"></span>
              </div>
              <!--***************************** 月 end ******************************-->
              <!-- 进度条 需要判断是否有时间 然后判断是年还是 月  -->
              <!-- 分两个写吧,太乱了 -->
              <!--***************************** 年 start ******************************-->
              <div class="item-son-progress" v-if="i.start !== '' && dateChaneValue === 'year'"
                :title="`${i.start}--${i.end}`" :style="{
                  width: _handleProgressWidth('year', taskWidth < (monthData.length * itemWidth + monthData.length) ? itemWidth : taskWidth / monthData.length, i.start, i.end) + 'px',
                  left: _handleToLeftWidth('year', taskWidth < (monthData.length * itemWidth + monthData.length) ? itemWidth : taskWidth / monthData.length, i.start, i.end) + 'px'
                }"></div>
              <!--***************************** 年 end ******************************-->
              <!--***************************** 月 start ******************************-->
              <div class="item-son-progress" v-if="i.start !== '' && dateChaneValue === 'month'"
                :title="`${i.start}--${i.end}`" :style="{
                  width: _handleProgressWidth('month', taskWidth < (dayData.length * itemWidth + dayData.length) ? itemWidth : taskWidth / dayData.length, i.start, i.end) + 'px',
                  left: _handleToLeftWidth('month', taskWidth < (dayData.length * itemWidth + dayData.length) ? itemWidth : taskWidth / dayData.length, i.start, i.end) + 'px'
                }"></div>
              <!--***************************** 月 end ******************************-->
            </div>
          </div>
        </div>
      </div>
    </div>
    <!-- 详情弹窗 -->
    <el-dialog custom-class="dialog" :visible.sync="dialogVisible" :append-to-body="true" :destroy-on-close="false"
      width="862px" heigh="520px" :before-close="handleClose">
      <div slot="title" class="dialog-title">
        详情
      </div>
      <!-- body -->
      <div class="dialog-body" v-if="dialogVisible && detailData.length !== 0">
        <div class="dialog-body-detail">
          <div>
            <span :style="{ 'background-color': _idToColor(currentPeople.user, currentPeople.label) }">{{
              currentPeople.label.slice(-2) }}</span><span>{{ currentPeople.label }}</span><span>{{
    currentPeople.user }}</span>
          </div>
          <div>
            <span>项目总周期:</span>
            <span>{{ detailData[0].totalProjectPeriods }}</span>
            <!-- <span>xxx天</span> -->
            <span>项目总天数:</span>
            <span> {{ detailData[0].totalProjectDays }}天</span>
            <!-- <span> xxx天</span> -->
          </div>
          <div>
            <span>参与项目数:</span>
            <span>{{ detailData.length }}</span>
            <!-- <span>xx</span> -->
            <span>项目实际天数:</span>
            <span> {{ detailData[0].actualProjectDays }}天</span>
            <!-- <span> xxx天</span> -->
          </div>
        </div>
        <div class="dialog-body-table">
          <div class="dialog-body-table-left">
            <div class="dialog-body-table-left-title">
              项目名称
            </div>
            <div class="dialog-body-table-left-table" ref="detailScrollLeft">
              <div class="dialog-body-table-left-table-item" v-for="(item, index) in detailData" :key="index">
                {{ item.label }}
              </div>
            </div>
          </div>
          <div class="dialog-body-table-right">
            <div class="dialog-body-table-right-title" ref="detailScrollTop" @scroll="_handleDetailScroll()">
              <span v-for="(item, index) in detailDays" :key="index">{{ item.slice(-2) }}</span>
            </div>
            <div class="dialog-body-table-right-table" ref="detailScrollRightAndBottom" @scroll="_handleDetailScroll()">
              <div class="dialog-body-table-right-table-item" v-for="(item, index) in detailData" :key="index">
                <span v-for="(i, num) in detailDays" :key="num"></span>
                <div class="dialog-body-table-right-table-item-progress" :style="{
                  width: _handleDetailProWidth(item.start, item.end) + 'px',
                  left: _handleDetailLeftWidth(item.start, item.end) + 'px'
                }">
                  <div><i class="el-icon-time" style="color: #52C41A;margin-right: 5px;"></i>{{ item.start.replace(/-/g,
                    ".") }} - {{ item.end.replace(/-/g, ".") }}</div>
                  <div>工期:{{ _getTimeTwo(item.start, item.end).length }}天</div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </el-dialog>
  </div>
</template>

<script>

export default {
  data() {
    return {
      screenWidth: "",
      screenHeight: "",
      itemWidth: null,
      dateChaneValue: 'month',
      loading: false,
      deadLineEnd: "",// 最后截止时间结束范围
      deadLineStart: "",// 最后截止时间开始范围
      TaskData: [],  // 列表数据当前数据
      allData: [], // 列表全部数据
      orderOrgLimit: null,
      taskWidth: null,
      datalist: [], // 搜索树用的
      date: null,
      yearData: [],
      monthData: [],
      monthDataToOne: [],
      dayData: [],
      earliestStart: '', // 最早时间
      latestEnd: '', // 最晚时间
      currentPeople: {},
      // ***********详情弹窗相关************
      dialogVisible: false,
      detailData: [],
      detailDays: [],
      detailStartTime: '', // 弹窗内最早时间
      detailEndTime: '', // 弹窗内最晚时间
      // ***********详情弹窗相关************

      pageNum: 1, // 第几页 
      pageSize: 20, // 每页x条数据
      thisYearData: {},
      thisMonthData: {},
    };
  },

  components: {
  },

  computed: {
  },

  mounted() {
    this._search()
    this.taskWidth = this.$refs.taskDiv.offsetWidth;
    this.itemWidth = this.taskWidth / 24;

    // 监听页面变化
    this.screenWidth = document.body.clientWidth; //监听页面缩放
    window.onresize = () => {
      return (() => {
        // 要适配浏览器的放大,那么每个单元格的宽度就要动态起来,那么这个值怎么确定呢????
        // this.taskWidth / 24先这样试试
        this.screenWidth = document.body.clientWidth;
        this.taskWidth = this.$refs.taskDiv.offsetWidth;
        this.itemWidth = this.taskWidth / 24;
      })();
    };
    this.$refs.verticalLeft.addEventListener('scroll', this._isScrollToBottom)

    this.thisYearData = this._creatThisYearData();
    this.thisMonthData = this._creatThisMonthData();

  },
  watch: {
    screenWidth() {
      // console.log('this.screenWidth',this.screenWidth); 
    },
    taskWidth() {
      // console.log('this.taskWidth',this.taskWidth / 24);
    },
    itemWidth() {
      // console.log('this.itemWidth',this.itemWidth);
    }
  },
  methods: {
    /**
    * 代码描述: 滚动加载
    * 作者:lizibin
    * 创建时间:2023/12/29 14:17:55
    */
    _isScrollToBottom() {
      const scrollTop = this.$refs.verticalLeft.scrollTop
      // 获取可视区的高度
      const windowHeight = this.$refs.verticalLeft.clientHeight
      // 获取滚动条的总高度
      const scrollHeight = this.$refs.verticalLeft.scrollHeight

      // 滚动到最底部
      if (scrollTop + windowHeight >= scrollHeight) {
        // 把距离顶部的距离加上可视区域的高度 等于或者大于滚动条的总高度就是到达底部
        if (this.TaskData.length < this.allData.length) {
          this.loading = true;
          this.pageNum += 1;
          let nextData = this._getDataForPage(this.pageNum, this.pageSize);
          console.log('+1后的数据', nextData);
          this.TaskData = [...this.TaskData, ...nextData];
          console.log('到底了');
          setTimeout(() => {
            this.loading = false;
          }, 2000)
        }
      }

    },
    /**
     * 获取指定页数的数据数组
     * @param {number} page 要获取的页数
     * @param {number} pageSize 每页多少条数据
     * @returns {Array} 返回对应页数的数据数组
     */
    _getDataForPage(page, pageSize) {
      // 假设这是你的总数据
      const totalData = this.allData;
      // 计算起始索引和结束索引
      const startIndex = (page - 1) * pageSize;
      const endIndex = startIndex + pageSize;

      // 使用 slice 方法截取对应页数的数据
      const pageData = totalData.slice(startIndex, endIndex);
      return pageData;
    },

    /**
    * 代码描述: 获取项目详情
    * 作者:lizibin
    * 创建时间:2023/12/26 11:17:59
    */
    _getProjectResourceDetail(userId) {
      getProjectResourceDetail({ userId }).then((res) => {
        if (res.data.code == "S") {
          this.detailData = res.data.data;
          if (this.detailData.length > 0) {
            this.dialogVisible = true;
          } else {
            this.dialogVisible = false;
          }
          let earliestStart = '';  // 最早
          let latestEnd = '';  // 最晚
          // 循环数组找到最早的 start 和最晚的 end
          for (const item of res.data.data) {
            if (item.start) {
              if (earliestStart === '') {
                earliestStart = item.start
              }
              const startDate = item.start;
              if (new Date(startDate) < new Date(earliestStart)) {
                earliestStart = startDate;
              }
            }

            if (item.end) {
              if (latestEnd === '') {
                latestEnd = item.end
              }
              const endDate = item.end;
              if (new Date(endDate) > new Date(latestEnd)) {
                latestEnd = endDate;
              }
            }
          }
          this.detailStartTime = earliestStart;
          this.detailEndTime = latestEnd;
          this.detailDays = this._getTimeTwo(this.detailStartTime, this.detailEndTime);
        }
      })
    },
    _search() {
      // getProjectResourceGantList(params).then(res => {
      // if (res.data.code = 'S') {
      this.TaskData = [];
      this.yearData = [];
      this.monthData = [];
      this.monthDataToOne = [];
      this.dayData = [];
      this.pageNum = 1;
      let backData = [
        {
          "id": "c4cd08201b6e4be7a8f8e4b6a05a62ce",
          "attribute": "",
          "parentId": "",
          "label": "老年web开发组",
          "user": "",
          "start": "",
          "end": "",
          "duration": "",
          "percent": "",
          "type": "",
          "status": ""
        },
        {
          "id": "",
          "attribute": "",
          "parentId": "c4cd08201b6e4be7a8f8e4b6a05a62ce",
          "label": "张一",
          "user": "11111",
          "start": "",
          "end": "",
          "duration": "",
          "percent": "",
          "type": "",
          "status": ""
        },
        {
          "id": "",
          "attribute": "",
          "parentId": "c4cd08201b6e4be7a8f8e4b6a05a62ce",
          "label": "张二",
          "user": "22222",
          "start": "2022-05-07",
          "end": "2022-12-31",
          "duration": "",
          "percent": "",
          "type": "",
          "status": ""
        },
        {
          "id": "",
          "attribute": "",
          "parentId": "c4cd08201b6e4be7a8f8e4b6a05a62ce",
          "label": "张二",
          "user": "3333333",
          "start": "",
          "end": "",
          "duration": "",
          "percent": "",
          "type": "",
          "status": ""
        },
        {
          "id": "",
          "attribute": "",
          "parentId": "c4cd08201b6e4be7a8f8e4b6a05a62ce",
          "label": "张二",
          "user": "44444",
          "start": "2023-11-14",
          "end": "2023-11-29",
          "duration": "",
          "percent": "",
          "type": "",
          "status": ""
        },
        {
          "id": "",
          "attribute": "",
          "parentId": "c4cd08201b6e4be7a8f8e4b6a05a62ce",
          "label": "张二",
          "user": "555555",
          "start": "",
          "end": "",
          "duration": "",
          "percent": "",
          "type": "",
          "status": ""
        },
        {
          "id": "",
          "attribute": "",
          "parentId": "c4cd08201b6e4be7a8f8e4b6a05a62ce",
          "label": "66666",
          "user": "66666",
          "start": "2022-05-05",
          "end": "2022-12-31",
          "duration": "",
          "percent": "",
          "type": "",
          "status": ""
        },

      ];

      let earliestStart = '';  // 最早
      let latestEnd = '';  // 最晚
      // 循环数组找到最早的 start 和最晚的 end
      for (const item of backData) {
        if (item.start) {
          if (earliestStart === '') {
            earliestStart = item.start
          }
          const startDate = item.start;
          if (new Date(startDate) < new Date(earliestStart)) {
            earliestStart = startDate;
          }
        }

        if (item.end) {
          if (latestEnd === '') {
            latestEnd = item.end
          }
          const endDate = item.end;
          if (new Date(endDate) > new Date(latestEnd)) {
            latestEnd = endDate;
          }
        }
      }
      console.log('最早时间', earliestStart);
      console.log('最晚时间', latestEnd);
      this.earliestStart = earliestStart;
      this.latestEnd = latestEnd;

      let year = this._getYearBetween(earliestStart, latestEnd);
      // 拿到年数据和月数据
      this.monthData = this._getMonthBetween(earliestStart, latestEnd);
      for (let a = 0; a < year.length; a++) {
        let num = this._countElementsStarting(year[a], this.monthData);
        let obj = {
          name: year[a],
          num
        }
        this.yearData.push(obj)
      }

      for (let b = 0; b < this.monthData.length; b++) {
        let obj = {
          name: this.monthData[b],
          num: this._getMonthDays(this.monthData[b].split('-')[0], this.monthData[b].split('-')[1])
        }
        let days = this._generateArray(obj.num);
        this.dayData = [...this.dayData, ...days];
        this.monthDataToOne.push(obj);
      }
      console.log('年数据', this.yearData);
      console.log('月数据', this.monthData);

      console.log('月数据', this.monthDataToOne);
      console.log('天数据', this.dayData);


      // 重新组织下结构 -- 展示树
      let newData = [];
      for (let i = 0; i < backData.length; i++) {
        if (backData[i].parentId === '') {
          backData[i].children = [];
          backData[i].open = true;
          backData[i].children = backData.filter((item) => {
            return item.parentId === backData[i].id
          })
          newData.push(backData[i]);
        }
      }
      console.log('总数据', newData);
      this.allData = newData;
      this.TaskData = this._getDataForPage(this.pageNum, this.pageSize);
      this.loading = false;
      // } else {
      //   this.$message.error(res.data.info);
      // }
      // })
    },
    /**
    * 代码描述: 生成今年12个月
    * 作者:lizibin
    * 创建时间:2024/01/02 10:44:35
    */
    _creatThisYearData() {
      const currentYear = new Date().getFullYear();
      return {
        year: currentYear,
        month: this._generateArray(12)
      }
    },
    /**
    * 代码描述: 生成当月多少天
    * 作者:lizibin
    * 创建时间:2024/01/02 10:44:35
    */
    _creatThisMonthData() {
      const currentYear = new Date().getFullYear();
      let currentMonth = new Date().getMonth() + 1;
      currentMonth = currentMonth.length === 1 ? '0' + currentMonth : currentMonth;
      return {
        month: `${currentYear}${currentMonth}月`,
        day: this._generateArray(this._getMonthDays(currentYear, currentMonth))
      }
    },
    /**
    * 代码描述: 生成1-n的数组
    * 作者:lizibin
    * 创建时间:2023/12/25 17:03:03
    */
    _generateArray(n) {
      const arr = [];

      for (let i = 1; i <= n; i++) {
        arr.push(i);
      }

      return arr;
    },
    /**
    * 代码描述: 年月切换
    * 作者:lizibin
    * 创建时间:2023/12/20 16:06:26
    */
    _dateChange(val) {
      this.loading = true;
      setTimeout(() => {
        this.loading = false;
      }, 1500)
    },
    /**
    * 代码描述: 搜索日期框改变
    * 作者:lizibin
    * 创建时间:2023/12/20 16:14:20
    */
    changeList(val) {
      if (val == null) {
        this.deadLineStart = '';
        this.deadLineEnd = '';
      } else {
        this.deadLineStart = val[0];
        this.deadLineEnd = val[1];
      }
    },

    /**
     * 获取两日期之间日期列表函数
     * 返回两个时间之间所有的日期
     * 参数示例 ('2021-05-31','2021-06-30')
     * **/
    _getTimeTwo(start, end) {
      //初始化日期列表,数组
      let diffdate = new Array();
      let arr = []
      let i = 0;
      //开始日期小于等于结束日期,并循环
      while (start <= end) {
        diffdate[i] = start;
        //获取开始日期时间戳
        let stime_ts = new Date(start).getTime();
        //增加一天时间戳后的日期
        let next_date = stime_ts + (24 * 60 * 60 * 1000);
        //拼接年月日,这里的月份会返回(0-11),所以要+1
        let next_dates_y = new Date(next_date).getFullYear() + '-';
        let next_dates_m = (new Date(next_date).getMonth() + 1 < 10) ? '0' + (new Date(next_date).getMonth() + 1) + '-' : (new Date(next_date).getMonth() + 1) + '-';
        let next_dates_d = (new Date(next_date).getDate() < 10) ? '0' + new Date(next_date).getDate() : new Date(next_date).getDate();
        start = next_dates_y + next_dates_m + next_dates_d;
        //增加数组key
        i++;
      }
      return diffdate;
    },

    /**
     * 获取两个日期中所有的月份
     * 返回两个时间之间所有的月份
     * 参数示例 ('2021-01-01','2021-06-01')
     * **/
    _getMonthBetween(start1, end1) {
      const start = new Date(start1);
      const end = new Date(end1);
      const months = [];

      // 设置开始时间为每月的第一天
      start.setDate(1);

      while (start <= end) {
        const year = start.getFullYear();
        const month = String(start.getMonth() + 1).padStart(2, '0'); // 补全月份,例如 "01"
        months.push(`${year}-${month}`);

        // 增加一个月
        start.setMonth(start.getMonth() + 1);
      }

      return months;
    },
    /**
     * 获取两个日期中所有的年份
     * 返回两个时间之间所有的年份
     * 参数示例 ('2021-01-01','2021-01-01')
     * **/
    _getYearBetween(start, end) {
      let result = [];
      let min = new Date(start).getFullYear();
      let max = new Date(end).getFullYear();
      while (min <= max) {
        result.push(min);
        min = (Number(min) + 1)
      }
      return result;
    },
    /**
    * 代码描述:编写函数统计以 "2023" 开头的元素数量
    * 作者:lizibin
    * 创建时间:2023/12/22 13:13:45
    */
    _countElementsStarting(prefix, array) {
      return array.filter(element => element.startsWith(prefix)).length;
    },
    /**
    * 代码描述: 改变下拉
    * 作者:lizibin
    * 创建时间:2023/12/22 14:48:20
    */
    _imgChange(index) {
      this.TaskData[index].open = !this.TaskData[index].open;
    },


    // 垂直滚动条滚动同步
    sysHandleScroll() {
      this.$nextTick(() => {
        this.$refs.verticalRight.scrollTop = this.$refs.verticalLeft.scrollTop;

        this.$refs.taskDiv.scrollLeft = this.$refs.verticalRight.scrollLeft;
      })
    },
    sysHandleScrol2() {
      this.$nextTick(() => {
        this.$refs.verticalLeft.scrollTop = this.$refs.verticalRight.scrollTop;

        this.$refs.taskDiv.scrollLeft = this.$refs.verticalRight.scrollLeft;
      })
    },
    _handleDetailScroll() {
      this.$refs.detailScrollTop.scrollLeft = this.$refs.detailScrollRightAndBottom.scrollLeft;
    },
    /**
    * 代码描述: 传入年份和月份 获取该年对应月份的天数
    * 作者:lizibin
    * 创建时间:2023/12/25 16:55:36
    */
    _getMonthDays(year, month) {
      var thisDate = new Date(year, month, 0);  //当天数为0 js自动处理为上一月的最后一天
      return thisDate.getDate();
    },

    /**
    * 代码描述: 计算进度条的宽度
    * 作者:lizibin
    * 创建时间:2023/12/25 09:29:10
    * @ model 年模式还是月模式
    * @ itemWidth 每个进度块的宽度
    * @ startTime
    * @ endTime
    */
    _handleProgressWidth(model, itemWidth, startTime, endTime) {
      // 年模式
      if (model === 'year') {
        // 历时几个月
        let monthArr = this._getMonthBetween(startTime, endTime)
        let monthWidth = monthArr.length;
        // 如果是同一个月
        if (monthWidth === 1) {
          let startWidth = +startTime.split('-')[2];
          let endWidth = +endTime.split('-')[2];
          if (endWidth === 31) endWidth = 30;

          return (itemWidth / 30) * (endWidth - startWidth);
        }
        if (monthWidth === 2) monthWidth = 0;
        if (monthWidth > 2) monthWidth -= 2;

        let startWidth = +startTime.split('-')[2];
        // 31天  统一按30计算 月份不用这么精确
        if (startWidth === 31) startWidth = 30;

        startWidth = 30 - startWidth === 0 ? 1 : 30 - startWidth;

        let endWidth = +endTime.split('-')[2];
        // 31天  统一按30计算 月份不用这么精确
        if (endWidth === 31) endWidth = 30;

        return monthWidth * itemWidth + (itemWidth / 30) * startWidth + (itemWidth / 30) * endWidth;
      } else if (model === 'month') {
        // 月模式
        let days = this._getTimeTwo(startTime, endTime).length; // 两个日期经过的天数
        return days * itemWidth;
      }
    },

    /**
    * 代码描述: 计算进度条左侧跨度
    * 作者:lizibin
    * 创建时间:2023/12/25 10:24:10
    * @ model 年模式还是月模式
    * @ itemWidth 每个进度块的宽度
    * @ endTime 进度条的开始时间作为结束时间,真正的开始时间是这个数据里面最早的时间
    */
    _handleToLeftWidth(model, itemWidth, start, end) {
      let earliestStart = this.earliestStart; // 所有数据中最早的
      // 年模式
      if (model === 'year') {
        // 历时几个月
        let monthWidth = this._getMonthBetween(earliestStart, start).length;
        if (monthWidth === 1 || monthWidth === 2) monthWidth = 0;
        if (monthWidth > 2) monthWidth -= 1;
        let endWidth = +start.split('-')[2];
        // 31天 28 29 统一按30计算 月份不用这么精确
        if (endWidth === 31) endWidth = 30;
        if (endWidth === 30) endWidth -= 1;
        if (endWidth === 1) endWidth = 0;

        return monthWidth * itemWidth + endWidth * (itemWidth / 30);
      } else if (model === 'month') {
        // 进度条的开始时间与最早时间经过几天就是左侧宽度
        let days = this._getTimeTwo(earliestStart.slice(0, -2) + '01', start).length - 1; // 两个日期经过的天数
        let allWidth = days * itemWidth
        if (allWidth > 20000) {
          allWidth = allWidth - 5
        }
        return allWidth;
      }
    },

    /**
    * 代码描述: 计算详情的进度宽度
    * 作者:lizibin
    * 创建时间:2023/12/26 15:16:50
    */
    _handleDetailProWidth(start, end) {
      let days = this._getTimeTwo(start, end).length;
      return days * 31;
    },
    /**
   * 代码描述: 计算详情的进度距离左侧的宽度
   * 作者:lizibin
   * 创建时间:2023/12/26 15:16:50
   */
    _handleDetailLeftWidth(start, end) {
      let days = this._getTimeTwo(this.detailStartTime, start).length - 1;
      return days * 31;
    },
    /**
    * 代码描述: 点击子弹窗并变色
    * 作者:lizibin
    * 创建时间:2023/12/26 10:08:26
    */
    _clickSon(i) {
      this.currentPeople = i;
      let width = 0;
      if (this.dateChaneValue === 'year' && i.start !== '') {
        width = this._handleToLeftWidth('year', this.taskWidth < (this.monthData.length * 60 + this.monthData.length) ? 61 : this.taskWidth / this.monthData.length, i.start, i.end)
      } else if (this.dateChaneValue === 'month' && i.start !== '') {
        width = this._handleToLeftWidth('month', this.taskWidth < (this.dayData.length * 60 + this.dayData.length) ? 61 : this.taskWidth / this.dayData.length, i.start, i.end)
      }
      this._getProjectResourceDetail(i.user);
      this.$nextTick(() => {
        this.$refs.taskDiv.scrollLeft = width;
        this.$refs.verticalRight.scrollLeft = width;

      })
    },
    _getPositionOfLetter(letter) {
      // 将字母转换为对应的ASCII码值
      const asciiValue = letter.charCodeAt(0);

      // 计算字母在26个字母中的位置(a为1,b为2,依此类推)
      const position = asciiValue - 96;

      return position;
    },
    _isLowerCase(char) {
      return /^[a-z]$/.test(char);
    },
    // 根据id生成固定颜色
    //  生成0-9 10个颜色 根据最后一位去匹配颜色
    _idToColor(id) {
      let end = id[id.length - 1]
      if (this._isLowerCase(end)) {
        // 是字母
        end = this._getPositionOfLetter(end)
      }
      let colorArr = [
        '#F39898',
        '#CFD181',
        '#FAAD14',
        '#52C41A',
        '#C58BD0',
        '#5bb3db',
        '#7de2cc',
        '#bfec77',
        '#f7c387',
        '#ec81db',
        '#f58ce3',
      ]
      return colorArr[end]
    },
    /**
    * 代码描述: 详情弹窗关闭时
    * 作者:lizibin
    * 创建时间:2024/01/04 13:43:41
    */
    handleClose(done) {
      this.dialogVisible = false;
      this.detailData = [];
      done()
    }
  }
};
</script>
<style scoped lang="less">
.resourceOccupy {
  width: 100%;
  background: #FFFFFF;
  height: 100%;
  padding: 16px;
  overflow: hidden;

  span {
    box-sizing: border-box;
  }

  &-search {
    height: 36px;
    display: flex;
    justify-content: space-between;
    align-items: center;

    &-left {
      display: flex;
    }

    &-right {}
  }

  &-top {
    margin-top: 20px;
    height: 54px;
    display: flex;

    &-left {
      width: 180px;
      margin-right: 20px;

      &-title {
        display: flex;
        justify-content: space-evenly;
        align-items: center;
        height: 54px;
        border: 1px solid #EDEDED;
        background: #F4F6FD;
        font-size: 12px;
        font-family: PingFangSC, PingFang SC;
        font-weight: 400;
        color: #606266;
      }
    }

    &-right {
      border-top: 1px solid #EDEDED;
      border-left: 1px solid #EDEDED;
      // border-right: 1px solid #EDEDED;

      &::-webkit-scrollbar {
        width: 0px;
        height: 0px;
      }

      width: calc(100% - 200px);
      overflow-x: auto;
      white-space: nowrap;
      overflow-y: hidden;
      background: #F4F6FD;

      &-one {
        height: 28px;

        span {
          display: inline-block;
          height: 28px;
          line-height: 28px;
          text-align: center;
          border-right: 1px solid #EDEDED;
          font-size: 14px;
          font-family: PingFangSC, PingFang SC;
          font-weight: 500;
          color: #303133;
          border-bottom: 1px solid #EDEDED;
        }
      }

      &-two {
        // display: flex;
        // align-items: center;
        height: 26px;

        span {
          display: inline-block;
          height: 26px;
          line-height: 26px;
          text-align: center;
          font-size: 12px;
          font-family: PingFangSC, PingFang SC;
          font-weight: 400;
          color: #606266;
          border-right: 1px solid #EDEDED;
        }
      }
    }
  }

  &-bottom {
    height: calc(100% - 155px);
    display: flex;

    // background-color: red;
    &-left {
      &::-webkit-scrollbar {
        width: 10px;
        height: 0px;
      }

      border-left: 1px solid #EDEDED;
      border-right: 1px solid #EDEDED;
      border-bottom: 1px solid #EDEDED;
      width: 180px;
      margin-right: 20px;
      overflow-y: auto;
      overflow-x: hidden;

      .item-father {
        display: flex;
        align-items: center;
        border-bottom: 1px solid #EDEDED;
        // border-left: 1px solid #EDEDED;
        // border-right: 1px solid #EDEDED;
        width: 180px;
        height: 32px;
        font-size: 12px;
        font-family: PingFangSC, PingFang SC;
        font-weight: 400;
        color: #010000;

        span:nth-child(1) {
          display: flex;
          align-items: center;
          justify-content: center;
          width: 12px;
          height: 12px;
          color: #2675FB;
          border: 1px solid #2675FB;
          margin-left: 20px;
          margin-right: 5px;
          cursor: pointer;
        }

        span:nth-child(2) {
          width: 140px;
          white-space: nowrap; //禁止换行
          overflow: hidden;
          text-overflow: ellipsis; //...
          text-align: left;
          margin-left: 5px;
        }
      }

      .item-son {
        display: flex;
        align-items: center;
        width: 180px;
        height: 32px;
        border-bottom: 1px solid #EDEDED;
        // border-left: 1px solid #EDEDED;
        // border-right: 1px solid #EDEDED;
        font-size: 12px;
        font-family: PingFangSC, PingFang SC;
        font-weight: 400;
        color: #606266;
        justify-content: space-evenly;
        cursor: pointer;

        &>div:nth-child(1) {
          width: 43px;
          text-overflow: ellipsis;
          overflow: hidden;
          word-break: break-all;
          white-space: nowrap;
        }

        &>div:nth-child(2) {
          text-align: center;
          width: 38px;
          text-overflow: ellipsis;
          overflow: hidden;
          word-break: break-all;
          white-space: nowrap;
        }
      }
    }

    &-right {
      width: calc(100% - 200px);
      overflow: auto;
      white-space: nowrap;
      border-left: 1px solid #EDEDED;

      &::-webkit-scrollbar {
        width: 0px;
        height: 10px;
      }

      // border-right: 1px solid #EDEDED;
      // &-item :last-child {
      //   border-bottom: 1px solid #EDEDED;

      // }
      &-item {}

      .item-father {
        height: 32px;

        // span:nth-child(1) {
        //   border-left: 1px solid #EDEDED;
        // }

        span {
          display: inline-block;
          height: 32px;
          border-right: 1px solid #EDEDED;
          background: rgba(38, 117, 251, 0.12);
        }
      }

      .item-son {
        height: 32px;
        position: relative;

        // span:nth-child(1) {
        //   border-left: 1px solid #EDEDED;
        // }

        span {
          display: inline-block;
          height: 32px;
          border-right: 1px solid #EDEDED;
        }

        &-progress {
          position: absolute;
          top: 15px;
          height: 4px;
          background: #2675FB;
          border-radius: 3px;
          // width: 100px;
        }
      }
    }
  }

}

.dialog {
  &-title {
    // width: 112px;
    // height: 22px;
    font-size: 16px;
    font-family: PingFangSC, PingFang SC;
    font-weight: 500;
    color: #303133;
  }

  &-body {
    border-top: 1px solid #E9E9E9;
    padding-top: 20px;

    &-detail {
      &>div:nth-child(1) {
        height: 32px;
        display: flex;
        align-items: center;

        &>span:nth-child(1) {
          width: 32px;
          height: 32px;
          border-radius: 50%;
          background-color: #FAAD14;
          font-size: 12px;
          font-family: PingFangSC, PingFang SC;
          font-weight: 400;
          color: #FFFFFF;
          line-height: 32px;
          text-align: center;
        }

        &>span:nth-child(2) {
          margin: 0 10px;
          font-size: 14px;
          font-family: PingFangSC, PingFang SC;
          font-weight: 500;
          color: #181818;
        }

        &>span:nth-child(3) {
          font-size: 12px;
          font-family: PingFangSC, PingFang SC;
          font-weight: 400;
          color: #909399;
        }
      }

      &>div:nth-child(2),
      &>div:nth-child(3) {
        display: flex;
        margin-top: 20px;

        &>span:nth-child(1),
        &>span:nth-child(3) {
          width: 98px;
          font-size: 14px;
          font-family: PingFangSC, PingFang SC;
          font-weight: 400;
          color: #606266;
        }

        &>span:nth-child(2),
        &>span:nth-child(4) {
          width: 170px;
          font-size: 14px;
          font-family: PingFangSC, PingFang SC;
          font-weight: 400;
          color: #010000;
        }

        &>span:nth-child(3) {
          margin-left: 100px;
        }
      }
    }

    &-table {
      margin-top: 20px;
      margin-bottom: 30px;
      display: flex;

      &-left {
        width: 160px;
        // height: 360px;

        &-title {
          height: 32px;
          line-height: 32px;
          text-align: center;
          background: #F4F6FD;
        }

        &-table {
          &-item {
            height: 80px;
            border-bottom: 1px solid #E9E9E9;
            border-left: 1px solid #E9E9E9;
            border-right: 1px solid #E9E9E9;
            line-height: 80px;
            text-align: center;
          }
        }
      }

      &-right {
        margin-left: 20px;
        width: calc(100% - 180px);
        // height: 360px;
        border-left: 1px solid #EDEDED;

        &-title {
          &::-webkit-scrollbar {
            width: 0px;
            height: 0px;
          }

          height: 32px;
          width: 100%;

          line-height: 32px;
          text-align: center;
          background: #F4F6FD;
          overflow-x: auto;
          white-space: nowrap;
          overflow-y: hidden;

          span {
            display: inline-block;
            height: 32px;
            width: 30px;
            border-right: 1px solid #EDEDED;
            font-size: 12px;
            font-family: PingFangSC, PingFang SC;
            font-weight: 400;
            // color: #606266;
          }
        }

        &-table {
          width: 100%;
          overflow-x: auto;
          white-space: nowrap;
          overflow-y: hidden;

          &::-webkit-scrollbar {
            width: 0px;
            height: 5px;
          }

          &-item {
            height: 80px;
            width: 100%;
            position: relative;

            span {
              display: inline-block;
              height: 80px;
              width: 30px;
              border-right: 1px solid #EDEDED;
            }

            &-progress {
              position: absolute;
              height: 56px;
              top: 12px;
              background: #FFFFFF;
              box-shadow: 0px 0px 6px 0px rgba(227, 227, 227, 0.5);
              border-radius: 4px;
              padding: 10px;
              border-bottom: 2px solid #52C41A;

              &>div:nth-child(1) {
                font-size: 12px;
                font-family: PingFangSC, PingFang SC;
                font-weight: 400;
                color: #52C41A;
                margin-bottom: 5px;
              }

              &>div:nth-child(2) {
                font-size: 12px;
                font-family: PingFangSC, PingFang SC;
                font-weight: 400;
                color: #606266;
              }
            }
          }
        }
      }
    }
  }
}
</style>