一、效果展示

二、功能介绍

左侧表格部分:

1、分为一级菜单,二级菜单......。

2、进度条部分,右侧甘特图上的图表也会显示进度。

3、新增任务,增加的是一级菜单,直接在表格上出现一条一级的空白数据,可添加数据,其中‘任务名称’为必填,点击保存可保存数据。

4.选中删除,顾名思义就是选中删除。

5.上移下移,会改变菜单在表格上的位置,若一级菜单下还有菜单,移动时会带着下面的菜单一起移动;

6、升级降级,二级菜单可升级为一级菜单,一级菜单不可升级;

7、操作中的‘编辑’,会直接在行数据中进行修改,如图;

8、操作中的‘新增’,点击一级菜单的新增,会出现二级菜单,点击二级菜单的新增,会出现三级菜单,以此类推,

右侧甘特图部分:

1、今天按钮,可直接定位今天的日程,我添加红线,更明确。

2、可拖拽表图,可直接在图上修改日期,右侧的表格内的时间也会随着修改而修改。

3.左侧数据上移或者下移,右侧对应的图表也会跟着移动,同步修改!

三、直接上代码

<template>
  <div class="gantt-container">
    <!-- 顶部操作区 -->
    <div class="operation-area">
      <el-button type="primary" @click="addTask" icon="el-icon-plus">新增任务</el-button>
      <el-button @click="deleteSelected" icon="el-icon-delete">删除选中</el-button>
      <el-button-group>
        <el-button @click="moveUp" icon="el-icon-top">上移</el-button>
        <el-button @click="moveDown" icon="el-icon-bottom">下移</el-button>
      </el-button-group>
      <el-button-group>
        <el-button @click="promoteLevel" icon="el-icon-arrow-up" :loading="promoteLoading">升级</el-button>
        <el-button @click="demoteLevel" icon="el-icon-arrow-down" :loading="demoteLoading">降级</el-button>
      </el-button-group>
      <el-button @click="scrollToToday" icon="el-icon-position">今天</el-button>
      <el-input v-model="searchText" placeholder="搜索任务" prefix-icon="el-icon-search"
        style="width: 200px; margin-left: 10px;"></el-input>
    </div>

    <!-- 表格和甘特图区域 -->
    <div class="content-area">
      <!-- 左侧表格 -->
      <div class="table-area">
        <el-table ref="taskTable" :data="filteredTasks" border style="width: 100%" height="100%"
          @selection-change="handleSelectionChange" @row-click="handleRowClick" row-key="id"
          :tree-props="{ children: 'children', hasChildren: 'hasChildren' }">
          <el-table-column type="selection" width="40" fixed="left"></el-table-column>
          <el-table-column prop="name" label="任务名称" min-width="140" fixed="left">
            <template slot-scope="scope">
              <el-input v-if="scope.row._isNew || scope.row._isEditing" v-model="scope.row.name" placeholder="请输入任务名称"
                size="mini"></el-input>
              <span v-else :style="{
                'padding-left': `${(scope.row.level - 1) * 20}px`,
                'font-weight': scope.row.level === 1 ? 'bold' : 'normal',
                'display': 'flex',
                'align-items': 'center'
              }">
                <!-- 动态显示层级图标 -->
                <i v-if="scope.row.level === 1" class="el-icon-folder-opened"></i>
                <i v-else-if="scope.row.level === 2" class="el-icon-document"></i>
                <i v-else class="el-icon-tickets"></i>

                <!-- 任务名称 + 层级标记(调试用) -->
                {{ scope.row.name }}
                <span v-if="showDebug" style="color:#999;margin-left:5px">(Lv.{{ scope.row.level }})</span>
              </span>
            </template>
          </el-table-column>
          <el-table-column prop="startDate" label="开始时间" min-width="120">
            <template slot-scope="scope">
              <el-date-picker v-if="scope.row._isNew || scope.row._isEditing" v-model="scope.row.startDate" type="date"
                placeholder="选择日期" size="mini" value-format="yyyy-MM-dd" style="width: 100%;"></el-date-picker>
              <span v-else>{{ scope.row.startDate }}</span>
            </template>
          </el-table-column>
          <el-table-column prop="endDate" label="结束时间" min-width="120">
            <template slot-scope="scope">
              <el-date-picker v-if="scope.row._isNew || scope.row._isEditing" v-model="scope.row.endDate" type="date"
                placeholder="选择日期" size="mini" value-format="yyyy-MM-dd" style="width: 100%;"></el-date-picker>
              <span v-else>{{ scope.row.endDate }}</span>
            </template>
          </el-table-column>
          <el-table-column prop="progress" label="进度" min-width="180">
            <template slot-scope="scope">
              <div style="display: flex; align-items: center;">
                <el-progress :percentage="scope.row.progress" :color="getProgressColor(scope.row.progress)"
                  style="flex-grow: 1; margin-right: 10px;"></el-progress>
                <el-input-number v-if="scope.row._isNew || scope.row._isEditing" v-model="scope.row.progress" :min="0"
                  :max="100" size="mini" style="width: 100px;"></el-input-number>
              </div>
            </template>
          </el-table-column>
          <el-table-column prop="owner" label="负责人" width=" min-80">
            <template slot-scope="scope">
              <el-select v-if="scope.row._isNew || scope.row._isEditing" v-model="scope.row.owner" placeholder="负责人"
                size="mini">
                <el-option v-for="person in teamMembers" :key="person" :label="person" :value="person"></el-option>
              </el-select>
              <span v-else>{{ scope.row.owner }}</span>
            </template>
          </el-table-column>
          <el-table-column label="操作" min-width="120" fixed="right">
            <template slot-scope="scope">
              <div v-if="scope.row._isNew || scope.row._isEditing">
                <el-button @click.stop="saveInlineTask(scope.row)" type="text" size="small">保存</el-button>
                <el-button @click.stop="cancelInlineTask(scope.row)" type="text" size="small">取消</el-button>
              </div>
              <div v-else>
                <el-button @click.stop="editTask(scope.row)" type="text" size="small">编辑</el-button>
                <el-button @click.stop="addChildTask(scope.row)" type="text" size="small">新增</el-button>
                <el-button type="text" size="small" @click.stop="deleteTask(scope.row)">删除</el-button>
              </div>
            </template>
          </el-table-column>
        </el-table>
      </div>

      <!-- 右侧甘特图 -->
      <div class="gantt-area">
        <div class="gantt-header" ref="ganttHeader">
          <div class="gantt-time-scale-header" :style="{ width: ganttTotalWidth + 'px' }">
            <div class="gantt-time-scale-row">
              <div v-for="month in timeScaleHeaderMonths" :key="month.key" class="time-scale-month-item"
                :style="{ width: `${month.width}px` }">
                {{ month.label }}
              </div>
            </div>
            <div class="gantt-time-scale-row">
              <div v-for="date in timeScale" :key="date.format('YYYY-MM-DD')" class="time-scale-day-item"
                :class="{ 'is-today': isToday(date) }" :style="{ width: `${dayWidth}px` }">
                <span>{{ date.format('D') }}</span>
              </div>
            </div>
          </div>
        </div>
        <div class="gantt-body" ref="ganttBody" @scroll="handleGanttScroll">
          <div class="gantt-grid-container" :style="{ width: ganttTotalWidth + 'px' }">
            <div class="today-marker" :style="todayMarkerStyle"></div>
            <div v-for="i in timeScale.length" :key="'gl' + i" class="grid-line"
              :style="{ left: `${(i - 1) * dayWidth}px` }">
            </div>
            <div v-for="task in filteredTasks" :key="task.id" class="gantt-row" :style="{ height: `${rowHeight}px` }">
              <div class="gantt-bar" :style="getGanttBarStyle(task)" @mousedown="startDrag(task, $event)"
                @dblclick="editTask(task)">
                <div class="gantt-bar-progress" :style="{ width: `${task.progress}%` }"></div>
                <div class="gantt-bar-label">{{ task.name }}</div>
                <div class="gantt-bar-handle left" @mousedown.stop="startResize(task, $event, 'start')"></div>
                <div class="gantt-bar-handle right" @mousedown.stop="startResize(task, $event, 'end')"></div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import moment from 'moment';

export default {
  data() {
    return {
      tasks: [
        {
          id: 1,
          name: '项目启动',
          startDate: '2025-06-16',
          endDate: '2025-06-28',
          progress: 100,
          owner: '张三',
          level: 1,
          parentId: null
        },
        {
          id: 2,
          name: '需求分析',
          startDate: '2025-06-05',
          endDate: '2025-06-20',
          progress: 80,
          owner: '李四',
          level: 1,
          parentId: null
        },
        {
          id: 3,
          name: '用户调研',
          startDate: '2025-06-05',
          endDate: '2025-06-15',
          progress: 100,
          owner: '王五',
          level: 2,
          parentId: 2
        },
        {
          id: 4,
          name: '需求文档',
          startDate: '2025-06-15',
          endDate: '2025-06-27',
          progress: 60,
          owner: '李四',
          level: 2,
          parentId: 2
        },
        {
          id: 5,
          name: '系统设计',
          startDate: '2025-06-18',
          endDate: '2025-06-27',
          progress: 30,
          owner: '赵六',
          level: 1,
          parentId: null
        }
      ],
      selectedTasks: [],
      searchText: '',
      originalTaskData: null,
      teamMembers: ['张三', '李四', '王五', '赵六', '钱七'],
      isDragging: false,
      isResizing: false,
      resizeType: null,
      dragStartX: 0,
      dragStartY: 0,
      isReallyDragging: false,
      dragThreshold: 5,
      draggingTaskProxy: null,
      originalStartDate: null,
      originalEndDate: null,
      rowHeight: 40,
      dayWidth: 40,
      startDate: null,
      endDate: null,
      currentYear: null,
      indentPerLevel: 20,
      showDebug: false,
      promoteLoading: false,
      demoteLoading: false
    };
  },
  computed: {
    filteredTasks() {
      if (!this.searchText) return this.tasks;
      return this.tasks.filter(task =>
        task.name.toLowerCase().includes(this.searchText.toLowerCase()) ||
        task.owner.toLowerCase().includes(this.searchText.toLowerCase())
      );
    },
    topLevelTasks() {
      return this.tasks.filter(task => task.level === 1);
    },
    timeScale() {
      const start = moment(this.startDate);
      const end = moment(this.endDate);
      const days = end.diff(start, 'days');
      const dates = [];

      for (let i = 0; i <= days; i++) {
        dates.push(moment(start).add(i, 'days'));
      }

      return dates;
    },
    ganttTotalWidth() {
      return this.timeScale.length * this.dayWidth;
    },
    timeScaleHeaderMonths() {
      if (!this.timeScale.length) return [];
      const months = [];
      let currentMonth = {
        label: this.timeScale[0].format('YYYY年MM月'),
        width: 0,
        key: this.timeScale[0].format('YYYY-MM'),
      };
      this.timeScale.forEach(date => {
        if (date.format('YYYY-MM') !== currentMonth.key) {
          months.push(currentMonth);
          currentMonth = {
            label: date.format('YYYY年MM月'),
            width: 0,
            key: date.format('YYYY-MM'),
          };
        }
        currentMonth.width += this.dayWidth;
      });
      months.push(currentMonth);
      return months;
    },
    todayMarkerStyle() {
      const today = moment();
      const start = moment(this.startDate);
      const end = moment(this.endDate);
      if (today.isBefore(start, 'day') || today.isAfter(end, 'day')) {
        return { display: 'none' };
      }
      const leftDays = today.diff(start, 'days');
      return {
        left: `${leftDays * this.dayWidth + this.dayWidth / 2}px`,
      };
    },
    projectStartDate() {
      if (this.tasks.length === 0) return moment().startOf('month');
      return moment.min(this.tasks.map(task => moment(task.startDate)));
    },
    projectEndDate() {
      if (this.tasks.length === 0) return moment().endOf('month');
      return moment.max(this.tasks.map(task => moment(task.endDate)));
    }
  },
  watch: {
    projectStartDate() {
      this.updateDateRange();
    },
    projectEndDate() {
      this.updateDateRange();
    }
  },
  methods: {
    updateDateRange() {
      if (this.tasks.length === 0) {
        this.startDate = moment().startOf('month').format('YYYY-MM-DD');
        this.endDate = moment().endOf('month').format('YYYY-MM-DD');
        return;
      }
      const newStartDate = this.projectStartDate.clone();
      const newEndDate = this.projectEndDate.clone();
      this.startDate = newStartDate.format('YYYY-MM-DD');
      this.endDate = newEndDate.format('YYYY-MM-DD');
    },
    getEmptyTask() {
      return {
        id: null,
        name: '',
        startDate: moment().format('YYYY-MM-DD'),
        endDate: moment().add(3, 'days').format('YYYY-MM-DD'),
        progress: 0,
        owner: '',
        level: 1,
        parentId: null
      };
    },
    addTask() {
      const newTask = {
        id: 'new_' + Date.now(), // 使用临时唯一ID
        name: '',
        startDate: moment().format('YYYY-MM-DD'),
        endDate: moment().add(1, 'days').format('YYYY-MM-DD'),
        progress: 0,
        owner: '',
        level: 1,
        parentId: null,
        _isNew: true, // 标记为新行
      };
      this.tasks.push(newTask);
    },
    addChildTask(parentTask) {
      const newTask = {
        id: 'new_' + Date.now(), // 使用临时唯一ID
        name: '',
        startDate: moment().format('YYYY-MM-DD'),
        endDate: moment().add(1, 'days').format('YYYY-MM-DD'),
        progress: 0,
        owner: '',
        level: parentTask.level + 1,
        parentId: parentTask.id,
        _isNew: true, // 标记为新行
      };
      const parentIndex = this.tasks.findIndex(t => t.id === parentTask.id);
      const children = this.getAllChildren(parentTask);
      this.tasks.splice(parentIndex + children.length + 1, 0, newTask);
    },
    editTask(task) {
      // 如果已经有其他行在编辑,先取消它
      const currentlyEditing = this.tasks.find(t => t._isEditing);
      if (currentlyEditing) {
        this.cancelInlineTask(currentlyEditing);
      }
      this.originalTaskData = JSON.parse(JSON.stringify(task));
      this.$set(task, '_isEditing', true);
    },
    deleteTask(task) {
      this.$confirm('确定要删除这个任务吗?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        const index = this.tasks.findIndex(t => t.id === task.id);
        if (index !== -1) {
          // 如果有子任务,也一并删除
          const children = this.getAllChildren(task);
          const childIds = children.map(c => c.id);
          this.tasks = this.tasks.filter(t => t.id !== task.id && !childIds.includes(t.id));
          this.selectedTasks = [];
          this.$message.success('删除成功');
          this.updateDateRange();
        }
      }).catch(() => { });
    },
    deleteSelected() {
      if (this.selectedTasks.length === 0) {
        this.$message.warning('请先选择要删除的任务');
        return;
      }

      this.$confirm(`确定要删除选中的${this.selectedTasks.length}个任务吗?`, '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        const ids = this.selectedTasks.map(task => task.id);
        let allIdsToDelete = [...ids];
        this.selectedTasks.forEach(task => {
          const children = this.getAllChildren(task);
          allIdsToDelete = allIdsToDelete.concat(children.map(c => c.id));
        });

        this.tasks = this.tasks.filter(task => !allIdsToDelete.includes(task.id));
        this.selectedTasks = [];
        this.$message.success('删除成功');
        this.updateDateRange();
      }).catch(() => { });
    },
    handleSelectionChange(val) {
      this.selectedTasks = val;
    },
    handleRowClick(row) {
      this.$refs.taskTable.toggleRowSelection(row);
    },
    getProgressColor(progress) {
      if (progress < 30) return '#f56c6c';
      if (progress < 70) return '#e6a23c';
      return '#67c23a';
    },
    moveUp() {
      if (this.selectedTasks.length !== 1) {
        this.$message.warning('请选择一条要移动的任务');
        return;
      }

      const task = this.selectedTasks[0];
      const currentIndex = this.tasks.findIndex(t => t.id === task.id);

      if (currentIndex === 0) {
        this.$message.warning('已经是第一个任务');
        return;
      }

      const descendants = this.getAllChildren(task);
      const movedItems = [task, ...descendants];

      let targetIndex = -1;
      for (let i = currentIndex - 1; i >= 0; i--) {
        const prevTask = this.tasks[i];
        if (prevTask.level === task.level) {
          targetIndex = i;
          break;
        }
        if (prevTask.level < task.level) {
          break;
        }
      }

      if (targetIndex !== -1) {
        const prevTask = this.tasks[targetIndex];
        this.tasks.splice(currentIndex, movedItems.length);
        const newTargetIndex = this.tasks.findIndex(t => t.id === prevTask.id);
        this.tasks.splice(newTargetIndex, 0, ...movedItems);
        this.$message.success('上移成功');
      } else {
        this.$message.warning('前面没有符合条件的同级任务可以上移');
      }
    },
    moveDown() {
      if (this.selectedTasks.length !== 1) {
        this.$message.warning('请选择一条要移动的任务');
        return;
      }

      const task = this.selectedTasks[0];
      const currentIndex = this.tasks.findIndex(t => t.id === task.id);
      const descendants = this.getAllChildren(task);
      const movedItems = [task, ...descendants];

      let targetIndex = -1;
      for (let i = currentIndex + movedItems.length; i < this.tasks.length; i++) {
        const nextTask = this.tasks[i];
        if (nextTask.level === task.level) {
          targetIndex = i;
          break;
        }
        if (nextTask.level < task.level) {
          break;
        }
      }

      if (targetIndex !== -1) {
        const nextTask = this.tasks[targetIndex];
        const nextTaskDescendants = this.getAllChildren(nextTask);
        this.tasks.splice(currentIndex, movedItems.length);
        const newTargetIndex = this.tasks.findIndex(t => t.id === nextTask.id);
        this.tasks.splice(newTargetIndex + nextTaskDescendants.length + 1, 0, ...movedItems);
        this.$message.success('下移成功');
      } else {
        this.$message.warning('后面没有符合条件的同级任务可以下移');
      }
    },
    getAllChildren(task) {
      const children = this.tasks.filter(t => t.parentId === task.id);
      let allChildren = [...children];
      for (const child of children) {
        allChildren = allChildren.concat(this.getAllChildren(child));
      }
      return allChildren;
    },
    promoteLevel() {
      if (this.promoteLoading) return;
      if (this.selectedTasks.length === 0) {
        this.$message.warning('请选择要升级的任务');
        return;
      }

      // 统一规则:一级任务不能升级
      if (this.selectedTasks.some(t => t.level === 1)) {
        this.$message.warning('一级任务不能升级');
        return;
      }

      // 批量操作检查:必须同级
      if (this.selectedTasks.length > 1) {
        const firstLevel = this.selectedTasks[0].level;
        if (this.selectedTasks.some(t => t.level !== firstLevel)) {
          this.$message.warning('批量升级必须选择同级任务');
          return;
        }
      }

      this.promoteLoading = true;
      try {
        this.selectedTasks.forEach(task => {
          const parent = this.tasks.find(t => t.id === task.parentId);
          const oldLevel = task.level;
          task.parentId = parent ? parent.parentId : null;
          task.level--;

          const levelChange = task.level - oldLevel;
          const descendants = this.getAllChildren(task);
          descendants.forEach(d => d.level += levelChange);
        });
        this.$message.success('升级成功');
      } finally {
        setTimeout(() => {
          this.promoteLoading = false;
        }, 300);
      }
    },
    demoteLevel() {
      if (this.demoteLoading) return;
      if (this.selectedTasks.length === 0) {
        this.$message.warning('请选择要降级的任务');
        return;
      }

      // 批量操作检查:必须同级
      if (this.selectedTasks.length > 1) {
        const firstLevel = this.selectedTasks[0].level;
        if (this.selectedTasks.some(t => t.level !== firstLevel)) {
          this.$message.warning('批量降级必须选择同级任务');
          return;
        }
      }

      this.demoteLoading = true;
      try {
        // 排序选中的任务,确保我们按它们在数组中的顺序处理
        const sortedSelectedTasks = [...this.selectedTasks].sort((a, b) => this.tasks.findIndex(t => t.id === a.id) - this.tasks.findIndex(t => t.id === b.id));
        const firstSelectedTask = sortedSelectedTasks[0];
        const firstSelectedIndex = this.tasks.findIndex(t => t.id === firstSelectedTask.id);

        if (firstSelectedIndex === 0) {
          this.$message.warning('不能降级列表中的第一个任务');
          return;
        }

        const potentialParent = this.tasks[firstSelectedIndex - 1];

        if (!potentialParent) {
          this.$message.warning('没有可用的父任务');
          return;
        }

        // 检查循环依赖
        const isCircular = this.selectedTasks.some(task => this.isDescendant(task, potentialParent));
        if (isCircular) {
          this.$message.warning('不能将任务降级到其后代任务下');
          return;
        }

        const newParentId = potentialParent.id;
        const newLevel = potentialParent.level + 1;
        const oldLevel = firstSelectedTask.level;

        if (newLevel <= oldLevel) {
          this.$message.warning('无法降级,该操作不会增加任务层级');
        } else {
          const levelChange = newLevel - oldLevel;
          this.selectedTasks.forEach(task => {
            task.parentId = newParentId;
            task.level = newLevel;

            const descendants = this.getAllChildren(task);
            descendants.forEach(d => {
              d.level += levelChange;
            });
          });
          this.$message.success('降级成功');
        }
      } finally {
        setTimeout(() => {
          this.demoteLoading = false;
        }, 300);
      }
    },
    isDescendant(parent, child) {
      if (!child.parentId) {
        return false;
      }
      if (child.parentId === parent.id) {
        return true;
      }
      const parentTask = this.tasks.find(t => t.id === child.parentId);
      if (parentTask) {
        return this.isDescendant(parent, parentTask);
      }
      return false;
    },
    getGanttBarStyle(task) {
      // 拖拽时,使用proxy的样式以获得流畅体验
      if (this.draggingTaskProxy && this.draggingTaskProxy.id === task.id) {
        return {
          left: `${this.draggingTaskProxy.left}px`,
          width: `${this.draggingTaskProxy.width}px`,
          backgroundColor: this.getTaskColor(task.level),
          borderColor: this.getTaskBorderColor(task.level),
        };
      }

      const start = moment(task.startDate);
      const end = moment(task.endDate);
      const duration = end.diff(start, 'days') + 1;
      const leftDays = start.diff(moment(this.startDate), 'days');

      return {
        left: `${leftDays * this.dayWidth}px`,
        width: `${duration * this.dayWidth}px`,
        backgroundColor: this.getTaskColor(task.level),
        borderColor: this.getTaskBorderColor(task.level)
      };
    },
    getTaskColor(level) {
      return level === 1 ? 'rgba(64, 158, 255, 0.7)' : 'rgba(103, 194, 58, 0.7)';
    },
    getTaskBorderColor(level) {
      return level === 1 ? 'rgba(64, 158, 255, 1)' : 'rgba(103, 194, 58, 1)';
    },
    isToday(date) {
      return moment().isSame(date, 'day');
    },
    handleGanttScroll(event) {
      this.$refs.ganttHeader.scrollLeft = event.target.scrollLeft;
    },
    scrollToToday() {
      const today = moment();
      const start = moment(this.startDate);
      const end = moment(this.endDate);

      if (today.isBetween(start, end, 'day', '[]')) {
        const leftDays = today.diff(start, 'days');
        const scrollPosition = (leftDays * this.dayWidth) - (this.$refs.ganttBody.clientWidth / 2) + (this.dayWidth / 2);
        this.$refs.ganttBody.scrollLeft = scrollPosition;
      } else {
        this.$message('今天不在当前显示的时间范围内');
      }
    },
    startDrag(task, event) {
      event.preventDefault();
      this.isDragging = true;
      this.isReallyDragging = false;
      this.draggingTask = task;
      this.dragStartX = event.clientX;
      this.dragStartY = event.clientY;

      const initialStyle = this.getGanttBarStyle(task);
      this.draggingTaskProxy = {
        id: task.id,
        initialLeft: parseFloat(initialStyle.left),
        left: parseFloat(initialStyle.left),
        width: parseFloat(initialStyle.width),
      };

      document.addEventListener('mousemove', this.handleDrag);
      document.addEventListener('mouseup', this.stopDrag);
    },
    handleDrag(event) {
      if (!this.isDragging) return;

      const dx = event.clientX - this.dragStartX;
      const dy = event.clientY - this.dragStartY;

      if (!this.isReallyDragging) {
        if (Math.sqrt(dx * dx + dy * dy) < this.dragThreshold) {
          return;
        }
        this.isReallyDragging = true;
      }

      this.draggingTaskProxy.left = this.draggingTaskProxy.initialLeft + dx;
    },
    stopDrag() {
      if (this.isDragging && this.isReallyDragging) {
        const newLeft = this.draggingTaskProxy.left;
        const days = Math.round(newLeft / this.dayWidth);
        const newStartDate = moment(this.startDate).add(days, 'days');

        const duration = moment(this.draggingTask.endDate).diff(moment(this.draggingTask.startDate), 'days');
        const newEndDate = newStartDate.clone().add(duration, 'days');

        this.draggingTask.startDate = newStartDate.format('YYYY-MM-DD');
        this.draggingTask.endDate = newEndDate.format('YYYY-MM-DD');
      }

      this.isDragging = false;
      this.isReallyDragging = false;
      this.draggingTask = null;
      this.draggingTaskProxy = null;
      document.removeEventListener('mousemove', this.handleDrag);
      document.removeEventListener('mouseup', this.stopDrag);
    },
    startResize(task, event, type) {
      event.preventDefault();
      event.stopPropagation();
      this.isResizing = true;
      this.isReallyDragging = false;
      this.resizingTask = task;
      this.resizeType = type;
      this.dragStartX = event.clientX;
      this.dragStartY = event.clientY;

      const initialStyle = this.getGanttBarStyle(task);
      this.draggingTaskProxy = {
        id: task.id,
        initialLeft: parseFloat(initialStyle.left),
        initialWidth: parseFloat(initialStyle.width),
        left: parseFloat(initialStyle.left),
        width: parseFloat(initialStyle.width),
      };

      document.addEventListener('mousemove', this.handleResize);
      document.addEventListener('mouseup', this.stopResize);
    },
    handleResize(event) {
      if (!this.isResizing) return;

      const dx = event.clientX - this.dragStartX;
      const dy = event.clientY - this.dragStartY;

      if (!this.isReallyDragging) {
        if (Math.sqrt(dx * dx + dy * dy) < this.dragThreshold) {
          return;
        }
        this.isReallyDragging = true;
      }

      const { initialLeft, initialWidth } = this.draggingTaskProxy;
      if (this.resizeType === 'start') {
        const newLeft = initialLeft + dx;
        const newWidth = initialWidth - dx;
        if (newWidth > this.dayWidth / 2) {
          this.draggingTaskProxy.left = newLeft;
          this.draggingTaskProxy.width = newWidth;
        }
      } else { // end
        const newWidth = initialWidth + dx;
        if (newWidth > this.dayWidth / 2) {
          this.draggingTaskProxy.width = newWidth;
        }
      }
    },
    stopResize() {
      if (this.isResizing && this.isReallyDragging) {
        const { left, width } = this.draggingTaskProxy;
        const startDays = Math.round(left / this.dayWidth);
        const durationDays = Math.round(width / this.dayWidth);

        const newStartDate = moment(this.startDate).add(startDays, 'days');
        const newEndDate = newStartDate.clone().add(durationDays - 1, 'days');

        this.resizingTask.startDate = newStartDate.format('YYYY-MM-DD');
        this.resizingTask.endDate = newEndDate.format('YYYY-MM-DD');
      }

      this.isResizing = false;
      this.isReallyDragging = false;
      this.resizingTask = null;
      this.draggingTaskProxy = null;
      document.removeEventListener('mousemove', this.handleResize);
      document.removeEventListener('mouseup', this.stopResize);
    },
    saveInlineTask(task) {
      if (!task.name) {
        this.$message.warning('任务名称不能为空');
        return;
      }

      if (task._isNew) {
        // 从有效的数字ID中找出最大值
        const numericIds = this.tasks
          .map(t => t.id)
          .filter(id => typeof id === 'number' && !isNaN(id));
        const maxId = numericIds.length > 0 ? Math.max(...numericIds) : 0;
        task.id = maxId + 1;
        delete task._isNew;
        this.$message.success('任务已新增');
      } else if (task._isEditing) {
        // 编辑保存
        delete task._isEditing;
        this.originalTaskData = null; // 清除缓存
        this.$message.success('任务已更新');
      }
      this.updateDateRange(); // 更新日期范围
    },
    cancelInlineTask(task) {
      if (task._isNew) {
        this.tasks = this.tasks.filter(t => t.id !== task.id);
      } else if (task._isEditing) {
        // 恢复原始数据
        Object.assign(task, this.originalTaskData);
        delete task._isEditing;
        this.originalTaskData = null;
      }
    }
  },
  mounted() {
    this.updateDateRange();
    this.$nextTick(() => {
      this.scrollToToday();
    });
  }
};
</script>
<style scoped>
.gantt-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  padding: 16px;
  background-color: #ffffff;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}

.operation-area {
  display: flex;
  align-items: center;
  padding: 12px 16px;
  margin-bottom: 16px;
  background-color: #ffffff;
  border-radius: 8px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
  gap: 8px;
}

.content-area {
  display: flex;
  flex: 1;
  min-height: 0;
  overflow: hidden;
}

.table-area {
  box-shadow: 0px 0px 6px 16px rgb(159 156 156 / 10%) !important;
  z-index: 10;
  width: 52%;
  min-width: 400px;
  max-width: 52%;
  background-color: #ffffff;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.gantt-area {
  flex: 1;
  display: flex;
  flex-direction: column;
  background-color: #ffffff;

  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
  overflow: hidden;
  position: relative;
  min-width: 0;
  /* Allow shrinking */
}

/* 表格样式优化 */
.el-table {
  border: none;
  flex: 1;
}

.el-table::before {
  display: none;
}

.el-table /deep/ .el-table__header-wrapper {
  background-color: #fafafa;
}

.el-table /deep/ .el-table__body tr:hover>td {
  background-color: #f5f7fa !important;
}

.el-table /deep/ .el-table__row--level-1 td {
  background-color: #fafafa;
  font-weight: 500;
}

.el-table /deep/ .el-table__row--level-2 td {
  background-color: #ffffff;
}

/* 甘特图头部样式 */
.gantt-header {
  height: 44px;
  border-bottom: 1px solid #e8e8e8;
  background-color: #fafafa;
  display: flex;
  align-items: center;
  font-weight: 500;
  color: #333;
  flex-shrink: 0;
  user-select: none;
  overflow: hidden;
}

.gantt-time-scale-header {
  display: flex;
  flex-direction: column;
  border-left: 1px solid #e8e8e8;
}

.gantt-time-scale-row {
  display: flex;
  text-align: center;
  font-size: 12px;
  font-weight: 500;
  color: #333;
  flex: 1;
  border-bottom: 1px solid #e8e8e8;
  position: relative;
  min-height: 0;
  background-color: #fafafa;
}

.gantt-time-scale-row:last-child {
  border-bottom: none;
}

.time-scale-month-item,
.time-scale-day-item {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 28px;
  border-right: 1px solid #e8e8e8;
  flex-shrink: 0;
  box-sizing: border-box;
}

.time-scale-day-item {
  color: #666;
  font-weight: normal;
}

.time-scale-day-item.is-today span {
  background-color: #409eff;
  color: #fff;
  border-radius: 50%;
  width: 22px;
  height: 22px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.gantt-time-scale {
  display: flex;
  height: 100%;
  overflow: hidden;
}

.time-scale-item {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  color: #666;
  border-right: 1px solid #f0f0f0;
  min-width: 60px;
  padding: 0 4px;
  flex-shrink: 0;
}

/* 甘特图主体样式 */
.gantt-body {
  flex: 1;
  overflow: auto;
  position: relative;
  min-height: 0;
  background-color: #fafafa;
}

.gantt-grid-container {
  position: relative;
  height: 100%;
}

.grid-line {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 1px;
  background-color: #e8e8e8;
}

.today-marker {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  background-color: #f56c6c;
  z-index: 10;
  pointer-events: none;
}

.gantt-row {
  position: relative;
  /* height: 53px !important;  Let's rely on rowHeight prop */
  border-bottom: 1px solid #f0f0f0;
  display: flex;
  align-items: center;
  box-sizing: border-box;
  height: 52px !important;
}

.gantt-bar {
  position: absolute;
  height: 28px;
  top: 10px;
  border-radius: 4px;
  border: 1px solid;
  cursor: move;
  user-select: none;
  overflow: hidden;
  transition: all 0.2s;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}

.gantt-bar:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  transform: translateY(-1px);
}

.gantt-bar-progress {
  height: 100%;
  background-color: rgba(255, 255, 255, 0.4);
}

.gantt-bar-label {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-size: 12px;
  padding: 0 8px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
}

.gantt-bar-handle {
  position: absolute;
  width: 8px;
  height: 100%;
  top: 0;
  cursor: ew-resize;
  opacity: 0;
  transition: opacity 0.2s;
  background-color: rgba(255, 255, 255, 0.5);
}

.gantt-bar:hover .gantt-bar-handle {
  opacity: 1;
}

.gantt-bar-handle.left {
  left: 0;
  border-radius: 4px 0 0 4px;
}

.gantt-bar-handle.right {
  right: 0;
  border-radius: 0 4px 4px 0;
}

/* 对话框样式优化 */
.el-dialog {
  border-radius: 8px;
}

.el-dialog__header {
  border-bottom: 1px solid #f0f0f0;
  padding: 16px 24px;
}

.el-dialog__body {
  padding: 24px;
}

.el-dialog__footer {
  border-top: 1px solid #f0f0f0;
  padding: 16px 24px;
}

/* 进度条颜色 */
.el-progress-bar {
  padding-right: 50px;
}

/* 滚动条样式 */
.gantt-body::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}

.gantt-body::-webkit-scrollbar-thumb {
  background-color: rgba(0, 0, 0, 0.2);
  border-radius: 4px;
}

.gantt-body::-webkit-scrollbar-track {
  background-color: rgba(0, 0, 0, 0.05);
}

/* 响应式调整 */
@media (max-width: 1200px) {
  .table-area {
    width: 300px;
    min-width: 300px;
  }

  .time-scale-item {
    min-width: 50px;
    font-size: 11px;
  }
}

@media (max-width: 992px) {
  .content-area {
    flex-direction: column;
  }

  .table-area {
    width: 100%;
    min-width: auto;
    max-width: none;
  }
}

.el-table .cell {
  display: flex;
  align-items: center;
}

/* 层级图标颜色区分 */
.el-icon-folder-opened {
  color: #409EFF;
  margin-right: 5px;
}

.el-icon-document {
  color: #67C23A;
  margin-right: 5px;
}

.el-icon-tickets {
  color: #E6A23C;
  margin-right: 5px;
}
</style>

四、具体解析

1.宝宝们,不要忘了下插件哦~

npm install dhtmlx-gant
npm install moment  //下载时间插件

2.不到还说啥了,代码太多了,有问题直接私信我好吗,我指定耐心解答(看见指定回)!

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐