Frappe-Gantt 甘特图组件实战教程

发布于:2025-05-12 ⋅ 阅读:(7) ⋅ 点赞:(0)

1. 项目背景

在实际项目中,我们需要一个功能完整的甘特图来展示和管理任务进度。经过对比,选择了 Frappe-Gantt 作为解决方案,主要考虑其:

  • 轻量级,无依赖
  • 支持任务依赖关系
  • 支持中文
  • 可定制性强
  • 社区活跃

2. 环境准备

2.1 引入必要资源

<!-- Element UI 样式 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/theme-chalk/index.css">
<!-- Frappe-Gantt 样式 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/frappe-gantt@0.6.1/dist/frappe-gantt.css">
<!-- Vue.js -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<!-- Element UI -->
<script src="https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/index.js"></script>
<!-- Frappe-Gantt -->
<script src="https://cdn.jsdelivr.net/npm/frappe-gantt@0.6.1/dist/frappe-gantt.min.js"></script>

2.2 基础样式设置

:root {
  --background-color: #f5f7fa;
  --text-color: #303133;
  --container-bg-color: #ffffff;
  --border-color: #ebeef5;
  --primary-color: #409EFF;
  --secondary-color: #909399;
}

#gantt-container {
  width: 95%;
  max-width: 1800px;
  margin: 20px auto;
  background-color: var(--container-bg-color);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}

3. 核心功能实现

3.1 数据获取与转换

async fetchTasks() {
  try {
    const response = await axios.get('/api/tasks/all', {
      headers: { 'Authorization': `Bearer ${token}` }
    });

    this.tasks = response.data.map(task => {
      // 处理日期格式
      const startDate = new Date(task.startDate);
      const dueDate = new Date(task.dueDate);
      
      // 调整结束日期以适应 Frappe-Gantt 的不包含约定
      const adjustedEndDate = new Date(dueDate);
      adjustedEndDate.setDate(adjustedEndDate.getDate() + 1);

      return {
        id: String(task.id),
        name: task.title,
        start: startDate.toISOString().split('T')[0],
        end: adjustedEndDate.toISOString().split('T')[0],
        progress: task.progress || 0,
        dependencies: task.ganttDependencies || '',
        custom_class: task.customClass || ''
      };
    });
  } catch (error) {
    this.$message.error('加载任务数据失败');
  }
}

3.2 甘特图初始化

initGantt() {
  this.gantt = new Gantt("#gantt", this.tasks, {
    header_height: 50,
    column_width: 30,
    step: 24,
    view_modes: ['Day', 'Week', 'Month'],
    bar_height: 20,
    bar_corner_radius: 4,
    arrow_curve: 5,
    padding: 18,
    view_mode: this.currentViewMode,
    date_format: 'YYYY-MM-DD',
    language: 'zh',
    show_popup: true,
    show_arrows: true,
    custom_popup_html: this.customPopupHtml,
    on_date_change: this.handleDateChange,
    on_progress_change: this.handleProgressChange
  });
}

3.3 自定义弹窗实现

customPopupHtml(task) {
  const startDate = new Date(task._start).toLocaleDateString("zh-CN");
  const endDate = new Date(task._end - 86400000).toLocaleDateString("zh-CN");
  const duration = Math.round((task._end - task._start) / 86400000);
  
  return `
    <div class="popup-content">
      <div class="title">${task.name}</div>
      <div class="subtitle">${startDate} - ${endDate} (${duration}天)</div>
      <p><strong>进度:</strong> ${task.progress}%</p>
      ${task.dependencies ? `<p><strong>依赖:</strong> ${task.dependencies}</p>` : ''}
      <div style="margin-top: 10px;">
        <button class='gantt-popup-edit-deps-btn' data-task-id='${task.id}'>编辑依赖</button>
      </div>
    </div>
  `;
}

3.4 依赖关系管理

// 打开依赖编辑对话框
openDependencyDialog(task) {
  this.currentTaskForDeps = task;
  this.selectedDependencies = task.dependencies ? 
    task.dependencies.split(',').filter(id => id !== '') : [];
  
  // 过滤可选的前置任务
  this.availablePredecessors = this.tasks
    .filter(t => {
      if (t.id === task.id) return false;
      const tDeps = t.dependencies ? t.dependencies.split(',') : [];
      return !tDeps.includes(task.id);
    })
    .map(t => ({ id: t.id, name: t.name }));

  this.dependencyDialogVisible = true;
}

// 保存依赖关系
async handleSaveDependenciesClick() {
  if (!this.currentTaskForDeps) return;
  
  try {
    await axios.post('/api/tasks/updateDependencies', {
      id: this.currentTaskForDeps.id,
      dependencies: this.selectedDependencies.join(',')
    });

    // 更新本地数据
    const taskIndex = this.tasks.findIndex(t => t.id === this.currentTaskForDeps.id);
    if (taskIndex !== -1) {
      this.$set(this.tasks, taskIndex, {
        ...this.tasks[taskIndex],
        dependencies: this.selectedDependencies.join(',')
      });
    }

    // 刷新甘特图
    this.gantt.refresh(this.tasks);
    this.calculateCriticalPath();
    this.applyCriticalPathStyling();
  } catch (error) {
    this.$message.error('保存依赖关系失败');
  }
}

3.5 关键路径计算

calculateCriticalPath() {
  const tasksCopy = JSON.parse(JSON.stringify(this.tasks));
  const taskMap = {};
  const DAY_IN_MS = 86400000;

  // 初始化任务属性
  tasksCopy.forEach(task => {
    const startDate = new Date(task.start);
    const endDate = new Date(task.end);
    
    task.duration = Math.round((endDate - startDate) / DAY_IN_MS);
    task.earliestStart = 0;
    task.earliestFinish = task.duration;
    task.latestStart = Infinity;
    task.latestFinish = Infinity;
    task.slack = Infinity;
    taskMap[task.id] = task;
  });

  // 计算最早开始和完成时间
  tasksCopy.forEach(task => {
    if (task.dependencies) {
      task.dependencies.split(',').forEach(depId => {
        const predecessor = taskMap[depId];
        if (predecessor) {
          task.earliestStart = Math.max(task.earliestStart, predecessor.earliestFinish);
          task.earliestFinish = task.earliestStart + task.duration;
        }
      });
    }
  });

  // 计算最晚开始和完成时间
  const projectDuration = Math.max(...tasksCopy.map(t => t.earliestFinish));
  tasksCopy.forEach(task => {
    task.latestFinish = projectDuration;
    task.latestStart = task.latestFinish - task.duration;
  });

  // 标记关键路径
  this.criticalPathTasks = tasksCopy
    .filter(task => task.latestStart - task.earliestStart <= 0.01)
    .map(task => task.id);
}

4. 样式优化

4.1 关键路径样式

.gantt .bar-wrapper.critical-path .bar {
  fill: #cf5c5c !important;
  stroke: #dcb6b6;
  stroke-width: 1px;
}

.gantt .bar-wrapper.critical-path .bar-progress {
  fill: #f30404 !important;
}

.gantt .arrow.critical-arrow {
  stroke: #FF4949 !important;
  stroke-width: 2px !important;
  stroke-dasharray: 5,5 !important;
}

4.2 弹窗样式

.gantt-container .popup-wrapper {
  background: rgba(0, 0, 0, 0.75) !important;
  color: #ffffff !important;
  border-radius: 6px !important;
  padding: 12px 18px !important;
  font-size: 13px !important;
  box-shadow: 0 5px 15px rgba(0,0,0,0.3) !important;
}

5. 实际使用中的注意事项

  1. 日期处理
  • Frappe-Gantt 使用不包含的结束日期
  • 需要将后端返回的包含性日期转换为不包含性日期
  • 注意时区问题
  1. 依赖关系
  • 避免循环依赖
  • 依赖关系字符串格式为逗号分隔的ID
  • 保存依赖时需要同时更新前端和后端数据
  1. 性能优化
  • 大量数据时考虑分页加载
  • 使用 Vue 的 $nextTick 确保 DOM 更新
  • 合理使用 setTimeout 处理异步渲染
  1. 样式覆盖
  • 使用 !important 确保样式优先级
  • 注意 SVG 元素的样式特殊性
  • 使用 CSS 变量方便主题定制

6. 常见问题解决

  1. 箭头不显示
// 确保箭头容器存在
if (this.gantt && this.gantt.svg) {
  this.gantt.svg.querySelector('.arrows').style.display = 'block';
}
  1. 日期显示错误
// 统一日期处理
function formatDate(date) {
  const d = new Date(date);
  d.setHours(0, 0, 0, 0);
  return d.toISOString().split('T')[0];
}
  1. 依赖关系更新后不刷新
// 强制刷新甘特图
this.$nextTick(() => {
  this.gantt.refresh(this.tasks);
  this.redrawArrows();
  this.calculateCriticalPath();
});

7. 效果展示

在这里插入图片描述

8. 总结

通过实际项目经验,我们发现 Frappe-Gantt 虽然轻量,但功能强大。通过合理的配置和扩展,可以满足大多数项目管理场景的需求。关键是要注意:

  • 正确处理日期格式
  • 合理管理依赖关系
  • 优化性能表现
  • 注意样式覆盖
  • 做好错误处理

希望这篇实战教程对大家有所帮助。如果有任何问题,欢迎在评论区讨论。


网站公告

今日签到

点亮在社区的每一天
去签到