从零开始:用Vue.js打造一个功能完整的甘特图组件
甘特图作为项目管理中最重要的可视化工具之一,能够直观地展示任务的时间安排、进度和依赖关系。本文将详细介绍如何使用Vue.js从零开始构建一个功能完整、性能优秀的甘特图组件。
🏗️ 架构设计
在开始编码之前,我们需要先设计好整体架构。一个好的甘特图组件应该包含以下几个核心部分:
1. 数据层设计
首先定义清晰的数据结构,这是整个组件的基础:
javascript
// 一级任务数据结构(父任务)
parentTask: {
taskId: String, // 任务ID
taskName: String, // 任务名称
projectId: String, // 所属项目ID
projectName: String, // 所属项目名称
createTimeMills: Number, // 创建时间戳
deadlineMills: Number, // 截止时间戳
status: Number, // 任务状态 (1:进行中, 2:已完成, 3:已取消)
progress: Number, // 整体进度百分比
isParent: true, // 标识为父任务
children: Array<Task>, // 子任务列表
expanded: Boolean // 是否展开显示子任务
}
// 二级任务数据结构(子任务)
childTask: {
taskId: String, // 任务ID
taskName: String, // 任务名称
parentTaskId: String, // 父任务ID
documentName: String, // 文档名称
createTimeMills: Number, // 创建时间戳
deadlineMills: Number, // 截止时间戳
status: Number, // 任务状态 (1:进行中, 2:已完成, 3:已取消)
stageName: String, // 阶段名称
progress: Number, // 进度百分比
assignee: String, // 负责人
isParent: false, // 标识为子任务
level: 2 // 任务层级
}
// 层级任务树结构
taskTree: {
projectId: String,
projectName: String,
tasks: Array<ParentTask> // 一级任务列表(包含子任务)
}
2. 时间轴算法
时间轴是甘特图的核心,我们需要实现灵活的时间范围计算:
🕐 多时间周期支持
甘特图支持三种时间周期,满足不同的查看需求(也可自行修改为时间范围):
- 本周视图: 从周一到周日,适合查看短期任务
- 本月视图: 从月初到月末,适合月度规划
- 本季视图: 从季度初到季度末,适合长期项目管理
javascript
// 时间范围计算逻辑
switch (this.timePeriod) {
case 'week':
startDate = today.startOf('week').add(1, 'day')
endDate = today.endOf('week').subtract(1, 'day')
break
case 'month':
startDate = today.startOf('month')
endDate = today.endOf('month')
break
case 'quarter':
startDate = today.startOf('quarter')
endDate = today.endOf('quarter')
break
}
📅 日期生成
javascript
// 生成时间线日期数组
const dates = []
let current = startDate
while (current.isBefore(endDate) || current.isSame(endDate, 'day')) {
dates.push(current.format('YYYY-MM-DD'))
current = current.add(1, 'day')
}
3. 核心:任务条定位
这是甘特图最核心的算法,决定了每个任务条在时间轴上的精确位置:
javascript
getTaskBarStyle(task) {
// 标准化任务开始和结束日期
const taskStartDate = dayjs(task.createTimeMills).startOf('day')
const taskEndDate = dayjs(task.deadlineMills).startOf('day')
// 获取时间线范围
const timelineStartDate = dayjs(this.localFilters.timeRange[0]).startOf('day')
const timelineEndDate = dayjs(this.localFilters.timeRange[1]).startOf('day')
// 计算有效显示范围(任务与时间线的交集)
const effectiveStartDate = taskStartDate.isBefore(timelineStartDate)
? timelineStartDate : taskStartDate
const effectiveEndDate = taskEndDate.isAfter(timelineEndDate)
? timelineEndDate : taskEndDate
// 计算位置百分比
const totalTimelineDays = timelineEndDate.diff(timelineStartDate, 'day') + 1
const taskStartOffset = effectiveStartDate.diff(timelineStartDate, 'day')
const taskDisplayDays = effectiveEndDate.diff(effectiveStartDate, 'day') + 1
const leftPercent = (taskStartOffset / totalTimelineDays) * 100
const widthPercent = (taskDisplayDays / totalTimelineDays) * 100
return {
left: `${leftPercent}%`,
width: `${widthPercent}%`
}
}
🎨 HTML部分
1. 组件结构设计
html
<div class="task-gantt-chart">
<!-- 筛选器区域 -->
<div class="gantt-header">
<div class="gantt-filters">...</div>
</div>
<!-- 甘特图主体 -->
<div class="gantt-container">
<div class="gantt-timeline">
<!-- 时间线头部 -->
<div class="timeline-header">...</div>
<!-- 甘特图主体内容 -->
<div class="gantt-body">...</div>
</div>
</div>
<!-- 分页组件 -->
<div class="table-pagination">...</div>
</div>
2. 时间轴头部设计
html
<div class="timeline-header">
<div class="timeline-left-title">任务名称</div>
<div class="timeline-dates">
<div v-for="date in timelineDates" :key="date" class="date-cell">
{{ formatDate(date) }}
</div>
</div>
</div>
3. 层级任务行布局
支持父子任务的层级显示和展开/折叠功能:
html
<!-- 递归渲染任务树 -->
<template v-for="task in taskTree" :key="task.taskId">
<!-- 父任务行 -->
<div class="task-row parent-task" :class="{ 'has-children': task.children.length }">
<!-- 左侧任务信息 -->
<div class="task-info">
<div class="task-name">
<!-- 展开/折叠按钮 -->
<span
v-if="task.children.length"
class="expand-btn"
:class="{ 'expanded': task.expanded }"
@click="toggleTaskExpand(task)">
<i class="el-icon-arrow-right"></i>
</span>
<span class="task-title">{{ task.taskName }}</span>
<span class="task-count" v-if="task.children.length">
({{ task.children.length }}个子任务)
</span>
</div>
<div class="task-meta">
<span class="task-progress">{{ task.progress }}%</span>
</div>
</div>
<!-- 右侧时间线区域 -->
<div class="task-timeline">
<div class="task-bar parent-bar" :style="getTaskBarStyle(task)">
<span class="task-bar-text">{{ getTaskDuration(task) }}天</span>
</div>
</div>
</div>
<!-- 子任务行(可折叠) -->
<template v-if="task.expanded && task.children.length">
<div
v-for="childTask in task.children"
:key="childTask.taskId"
class="task-row child-task">
<!-- 左侧任务信息(带缩进) -->
<div class="task-info indented">
<div class="task-name">
<span class="indent-line"></span>
<span class="task-title">{{ childTask.documentName }}</span>
</div>
<div class="task-meta">
<span class="task-stage">{{ childTask.stageName }}</span>
<span class="task-assignee">{{ childTask.assignee }}</span>
</div>
</div>
<!-- 右侧时间线区域 -->
<div class="task-timeline">
<div class="task-bar child-bar" :style="getTaskBarStyle(childTask)">
<span class="task-bar-text">{{ getTaskDuration(childTask) }}天</span>
</div>
</div>
</div>
</template>
</template>
⚡ 交互体验优化
良好的交互体验是优秀组件的重要标志。
1. 智能日期提示
当用户在时间轴上移动鼠标时,实时显示对应的日期:
javascript
handleMouseMove(event) {
const timelineElement = event.currentTarget
const rect = timelineElement.getBoundingClientRect()
const mouseX = event.clientX - rect.left
const timelineWidth = rect.width
// 计算鼠标位置对应的日期
const positionPercent = (mouseX / timelineWidth) * 100
const dayIndex = Math.floor((positionPercent / 100) * this.timelineDates.length)
if (dayIndex >= 0 && dayIndex < this.timelineDates.length) {
this.hoverLine.visible = true
this.hoverLine.position = positionPercent
this.hoverLine.date = this.timelineDates[dayIndex]
}
}
2. 层级任务交互功能
实现展开/折叠和层级状态管理:
javascript
// 展开/折叠任务
toggleTaskExpand(task) {
task.expanded = !task.expanded
// 可选:保存展开状态到本地存储
this.saveExpandState(task.taskId, task.expanded)
},
// 批量展开/折叠所有任务
toggleAllTasks(expanded = true) {
const toggleTask = (task) => {
if (task.children && task.children.length) {
task.expanded = expanded
task.children.forEach(child => {
if (child.children) {
toggleTask(child)
}
})
}
}
this.taskTree.forEach(toggleTask)
},
// 任务状态样式计算
getTaskBarClass(task) {
const classes = ['task-bar']
// 添加层级类型
if (task.isParent) {
classes.push('parent-bar')
} else {
classes.push('child-bar')
}
// 添加状态类
if (task.status === 1) {
classes.push('status-ongoing')
if (task.isExpired) {
classes.push('status-expired')
}
} else if (task.status === 2) {
classes.push('status-completed')
} else if (task.status === 3) {
classes.push('status-canceled')
}
return classes.join(' ')
},
// 计算任务的可见性(考虑父任务折叠状态)
isTaskVisible(task, parentTask = null) {
if (!parentTask) return true
return parentTask.expanded && this.isTaskVisible(parentTask, parentTask.parent)
}
🎨 样式系统设计
现代化的样式设计让甘特图既美观又实用。
1. 响应式网格系统
使用CSS Grid创建灵活的时间轴布局:
css
.timeline-dates {
display: grid;
grid-template-columns: repeat(var(--timeline-days), 1fr);
border-left: 1px solid #e0e0e0;
}
.date-cell {
padding: 8px 4px;
text-align: center;
border-right: 1px solid #e0e0e0;
font-size: 12px;
}
2. 层级任务样式设计
为父子任务设计不同的视觉样式,增强层级感:
css
/* 基础任务条样式 */
.task-bar {
height: 20px;
border-radius: 4px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: white;
transition: all 0.3s ease;
}
/* 父任务条样式 - 更粗更突出 */
.parent-bar {
height: 24px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* 子任务条样式 - 相对较细 */
.child-bar {
height: 18px;
opacity: 0.9;
}
/* 任务状态颜色 */
.status-ongoing {
background: linear-gradient(90deg, #409EFF 0%, #66B3FF 100%);
}
.status-completed {
background: linear-gradient(90deg, #67C23A 0%, #85CE61 100%);
}
.status-expired {
background: linear-gradient(90deg, #F56C6C 0%, #F78989 100%);
}
/* 层级缩进样式 */
.task-info.indented {
padding-left: 30px;
position: relative;
}
.indent-line {
position: absolute;
left: 15px;
top: 50%;
width: 12px;
height: 1px;
background: #ddd;
transform: translateY(-50%);
}
.indent-line::before {
content: '';
position: absolute;
left: -15px;
top: -10px;
width: 1px;
height: 20px;
background: #ddd;
}
/* 展开/折叠按钮样式 */
.expand-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-right: 8px;
cursor: pointer;
border-radius: 2px;
transition: all 0.2s ease;
color: #666;
}
.expand-btn:hover {
background: #f0f0f0;
color: #409EFF;
}
.expand-btn.expanded {
transform: rotate(90deg);
}
/* 父任务行样式 */
.parent-task {
background: #fafafa;
border-left: 3px solid #409EFF;
}
.parent-task .task-name {
font-weight: 600;
color: #303133;
}
/* 子任务行样式 */
.child-task {
background: #fff;
border-left: 3px solid transparent;
}
.child-task:hover {
background: #f8f9fa;
}
/* 任务计数标签 */
.task-count {
font-size: 12px;
color: #909399;
margin-left: 8px;
}
/* 任务进度显示 */
.task-progress {
font-size: 12px;
color: #67C23A;
font-weight: 500;
}