vue+element-ui表格实现甘特图效果(可增删改查,可升级降级,上移下移)
甘特图组件实现方案 摘要:本文展示了一个基于Vue.js的甘特图组件实现方案。组件分为左侧任务表格和右侧甘特图两部分,支持多级任务管理(1-3级)、进度条显示、日期拖拽调整等功能。主要特性包括:1)表格支持新增/删除/编辑任务,可上下移动和升降级;2)甘特图实时同步表格数据,支持任务条拖拽调整日期;3)提供"今天"定位功能,显示当前日期红线;4)响应式设计适应不同屏幕尺寸。组件
一、效果展示

二、功能介绍
左侧表格部分:
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.不到还说啥了,代码太多了,有问题直接私信我好吗,我指定耐心解答(看见指定回)!
魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。
更多推荐

所有评论(0)