文章目录
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. 实际使用中的注意事项
- 日期处理
- Frappe-Gantt 使用不包含的结束日期
- 需要将后端返回的包含性日期转换为不包含性日期
- 注意时区问题
- 依赖关系
- 避免循环依赖
- 依赖关系字符串格式为逗号分隔的ID
- 保存依赖时需要同时更新前端和后端数据
- 性能优化
- 大量数据时考虑分页加载
- 使用 Vue 的 $nextTick 确保 DOM 更新
- 合理使用 setTimeout 处理异步渲染
- 样式覆盖
- 使用 !important 确保样式优先级
- 注意 SVG 元素的样式特殊性
- 使用 CSS 变量方便主题定制
6. 常见问题解决
- 箭头不显示
// 确保箭头容器存在
if (this.gantt && this.gantt.svg) {
this.gantt.svg.querySelector('.arrows').style.display = 'block';
}
- 日期显示错误
// 统一日期处理
function formatDate(date) {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d.toISOString().split('T')[0];
}
- 依赖关系更新后不刷新
// 强制刷新甘特图
this.$nextTick(() => {
this.gantt.refresh(this.tasks);
this.redrawArrows();
this.calculateCriticalPath();
});
7. 效果展示
8. 总结
通过实际项目经验,我们发现 Frappe-Gantt 虽然轻量,但功能强大。通过合理的配置和扩展,可以满足大多数项目管理场景的需求。关键是要注意:
- 正确处理日期格式
- 合理管理依赖关系
- 优化性能表现
- 注意样式覆盖
- 做好错误处理
希望这篇实战教程对大家有所帮助。如果有任何问题,欢迎在评论区讨论。