在实际项目中,我们需要一个功能完整的甘特图来展示和管理任务进度。经过对比,选择了 Frappe-Gantt 作为解决方案,主要考虑其:
<!-- Element UI 样式 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/lib/theme-chalk/index.css">
<!-- Frappe-Gantt 样式 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/frappe-gantt.css">
<!-- Vue.js -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.js"></script>
<!-- Element UI -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/index.js"></script>
<!-- Frappe-Gantt -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/frappe-gantt.min.js"></script>
: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);
}
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('加载任务数据失败');
}
}
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
});
}
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 `
${task.name}
${startDate} - ${endDate} (${duration}天)
进度: ${task.progress}%
${task.dependencies ? `依赖:
${task.dependencies}` : ''}
`;
}
// 打开依赖编辑对话框
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('保存依赖关系失败');
}
}
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);
}
.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;
}
.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;
}
// 确保箭头容器存在
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();
});
通过实际项目经验,我们发现 Frappe-Gantt 虽然轻量,但功能强大。通过合理的配置和扩展,可以满足大多数项目管理场景的需求。关键是要注意:
希望这篇实战教程对大家有所帮助。如果有任何问题,欢迎在评论区讨论。