<template>
<div class="gantt-container">
<!-- 顶部工具栏 -->
<div class="gantt-toolbar">
<div class="scale-controls">
<button @click="switchScale('year')" :class="{ 'active': currentScale === 'year' }">年</button>
<button @click="switchScale('month')" :class="{ 'active': currentScale === 'month' }">月</button>
<button @click="switchScale('week')" :class="{ 'active': currentScale === 'week' }">周</button>
<button @click="switchScale('day')" :class="{ 'active': currentScale === 'day' }">日</button>
</div>
</div>
<!-- 甘特图容器:动态ID避免状态复用 -->
<div class="gantt-box" :id="ganttContainerId" ref="ganttRef"></div>
</div>
</template>
<script>
import { gantt } from 'dhtmlx-gantt';
import 'dhtmlx-gantt/codebase/dhtmlxgantt.css';
export default {
name: 'GanttChart',
props: {
param: {
type: Object,
required: false,
default: () => ({})
}
},
data() {
return {
ganttContainerId: `gantt-container-${Date.now()}`,
columns: [
// 列配置:name → text
{ name: 'text', label: '任务名称', tree: true, min_width: 140 },
{ name: 'start_date', label: '开始时间', min_width: 100 },
{ name: 'end_date', label: '结束时间', min_width: 100 },
{ name: 'duration', label: '计划工期' },
{
name: 'state',
label: '状态',
min_width: 100,
template: function (task) {
const statusMap = {
'pending': { text: '未开始', color: '#999' },
'progress': { text: '进行中', color: '#4299e1' },
'completed': { text: '已完成', color: '#48bb78' },
'delayed': { text: '已延期', color: '#f56565' }
};
const state = task.state || 'pending';
const { text, color } = statusMap[state] || statusMap['pending'];
return `<span font-weight: 500;">${text}</span>`;
}
},
{ name: 'add', label: '' }
],
currentScale: 'month',
scaleConfig: {
year: [{ unit: 'year', step: 1, format: '%Y年' }],
month: [{ unit: 'year', step: 1, format: '%Y年' }, { unit: 'month', step: 1, format: '%m月' }],
week: [{ unit: 'month', step: 1, format: '%Y-%m' }, { unit: 'week', step: 1, format: '第%W周' }],
day: [{ unit: 'month', step: 1, format: '%Y-%m' }, { unit: 'day', step: 1, format: '%d日' }]
},
defaultLightboxSections: [
// 弹框字段:name → text
{ name: 'text', height: 36, map_to: 'text', type: 'textarea', focus: true, label: '任务名称' },
{ name: "上级任务", type: "parent", allow_root: true, root_label: "无" },
{ name: 'progress', type: 'textarea', map_to: 'progress', height: 36, label: '进度' },
{
name: 'state', type: 'select', map_to: 'state', label: '状态', options: [
{ key: 'pending', label: '未开始' },
{ key: 'progress', label: '进行中' },
{ key: 'completed', label: '已完成' },
{ key: 'delayed', label: '已延期' }
]
},
{ name: 'time', type: 'duration', map_to: 'auto' }
],
resizeObserver: null,
currentQuickInfoTask: null,
selectedTaskId: null
};
},
computed: {
mergedData() {
return this.param.data && Array.isArray(this.param.data) && this.param.data.length > 0
? this.param.data.map(task => ({
id: task.id || Date.now() + Math.floor(Math.random() * 1000),
parent: task.parent || 0,
start_date: task.start_date || new Date().toISOString().split('T')[0],
end_date: task.end_date || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
state: task.state || 'pending',
// 数据层:name → text
text: task.text || task.name || `未命名任务-${Date.now()}`,
progress: task.progress || 0,
...task
}))
: [];
},
mergedLinks() {
return this.param.links && Array.isArray(this.param.links) && this.param.links.length > 0
? this.param.links
: [];
},
mergedLightboxSections() {
return this.param.form && Array.isArray(this.param.form) && this.param.form.length > 0
? this.processLightboxForm(this.param.form)
: this.defaultLightboxSections;
},
mergedSkin() {
return this.param.skin || "dark";
}
},
watch: {
param: {
handler() {
this.ganttContainerId = `gantt-container-${Date.now()}`;
this.$nextTick(() => this.initGantt());
},
deep: true
}
},
mounted() {
this.$nextTick(() => {
this.initGantt();
this.setupResizeObserver();
this.handleResize();
});
},
beforeDestroy() {
this.destroyGantt();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
},
methods: {
initGantt() {
// this.resetGanttState();
const container = document.getElementById(this.ganttContainerId);
if (!container) {
console.error('甘特图 DOM 容器未找到!');
return;
}
try {
gantt.config = {
...gantt.config,
date_format: '%d-%m-%Y',
readonly: false,
drag_mode: 'both',
drag_progress: true,
resize_step: 1,
snap_to_grid: true,
grid_step: 1,
order_branch: true,
order_branch_free: true,
drag_project: true,
tree_resize: true,
drag_limit: true,
scale_height: 60,
row_height: 40,
bar_height: 30,
show_links: true,
min_column_width: 80,
tree_start_cell: 0,
open_tree_initially: true,
lightbox: { sections: this.mergedLightboxSections },
quick_info_enable: true
};
gantt.plugins({
tooltip: true,
quick_info: true,
multiselect: true,
grid_resize: true,
zoom: true,
select: true,
tree: true,
drag_and_drop: true,
resize: true,
keyboard_nav: true
});
gantt.setSkin(this.mergedSkin);
gantt.i18n.setLocale('cn');
// 模板渲染:name → text
gantt.templates.quick_info_content = function (start, end, task) {
const safeTask = task || {};
const taskText = safeTask.text || '未命名任务';
const progress = Math.round((safeTask.progress || 0) * 100);
return `
<div>
${taskText}<br/>
计划开始 : ${gantt.templates.tooltip_date_format(start)}<br/>
计划结束: ${gantt.templates.tooltip_date_format(end)}<br/>
进度 : ${progress + '%'}
</div>
`;
};
gantt.templates.quick_info_title = function (start, end, task) {
const safeTask = task || {};
return `<span>${safeTask.text || '未命名任务'}</span>`;
};
gantt.templates.task_text = function (start, end, task) {
return `<span >${task.text || '未命名任务'}</span>`;
};
gantt.templates.tooltip_text = function (start, end, task) {
const stateMap = {
'pending': '未开始',
'progress': '进行中',
'completed': '已完成',
'delayed': '已延期'
};
const state = stateMap[task.state] || '未开始';
return `<span >${task.text || '未命名任务'}</span><br/>
<span >状态:</span> ${state}<br/>
<span >开始:</span> ${gantt.templates.tooltip_date_format(start)}<br/>
<span >结束:</span> ${gantt.templates.tooltip_date_format(end)}<br/>
<span >进度:</span> ${Math.round((task.progress || 0) * 100)}%`;
};
gantt.config.columns = this.columns;
gantt.config.drag_progress = true;
gantt.config.drag_move = true;
gantt.config.drag_resize = true;
gantt.config.drag_project = true;
this.updateScaleConfig(true);
gantt.init(container);
gantt.parse({
data: this.mergedData,
links: this.mergedLinks
});
this.bindGanttEvents();
this.bindZoomEvent();
this.handleEmptyState();
} catch (error) {
console.error('甘特图初始化失败:', error);
this.destroyGantt();
}
},
// 以下所有方法保持不变
resetGanttState() {
if (gantt.isInitialized && gantt.isInitialized()) {
try {
gantt.destructor();
} catch (e) {
console.warn('销毁旧实例失败,强制重置:', e);
}
}
if (gantt._events) gantt._events = {};
if (gantt.dataProcessor) gantt.dataProcessor = null;
gantt._tasksStore = null;
gantt._linksStore = null;
const oldContainer = document.querySelector(`#${this.ganttContainerId} > div`);
if (oldContainer) oldContainer.remove();
},
destroyGantt() {
if (gantt.isInitialized && gantt.isInitialized()) {
gantt.destructor();
}
const container = document.getElementById(this.ganttContainerId);
if (container) container.innerHTML = '';
},
handleEmptyState() {
const container = document.getElementById(this.ganttContainerId);
if (!container) return;
if (this.mergedData.length === 0) {
const emptyDiv = document.createElement('div');
emptyDiv.id = 'gantt-empty-state';
emptyDiv.style.position = 'absolute';
emptyDiv.style.top = '50%';
emptyDiv.style.left = '50%';
emptyDiv.style.transform = 'translate(-50%, -50%)';
emptyDiv.style.color = '#999';
emptyDiv.style.fontSize = '16px';
emptyDiv.style.textAlign = 'center';
emptyDiv.textContent = '暂无数据';
container.appendChild(emptyDiv);
} else {
const emptyDiv = container.querySelector('#gantt-empty-state');
if (emptyDiv) container.removeChild(emptyDiv);
}
},
processLightboxForm(formConfig) {
const processedForm = formConfig.map(item => {
const section = {
name: item.name,
label: item.label,
map_to: item.map_to,
type: item.type.toLowerCase(),
height: item.height,
focus: item.focus,
options: item.options || []
};
if (section.type === 'parent') {
section.allow_root = true;
section.root_label = "无";
section.filter = task => task.open !== false;
}
if (section.type === 'duration') {
section.map_to = 'auto';
}
if (section.type === 'select' && !section.options.length) {
section.options = [{ key: '', label: '请选择' }];
}
return section;
});
if (!processedForm.some(item => item.map_to === 'text')) {
processedForm.unshift({
name: 'text',
height: 36,
map_to: 'text',
type: 'textarea',
focus: true,
label: '任务名称'
});
}
if (!processedForm.some(item => item.map_to === 'state')) {
processedForm.splice(3, 0, {
name: 'state',
type: 'select',
map_to: 'state',
label: '状态',
options: [
{ key: 'pending', label: '未开始' },
{ key: 'progress', label: '进行中' },
{ key: 'completed', label: '已完成' },
{ key: 'delayed', label: '已延期' }
]
});
}
return processedForm;
},
adjustColumnsForParamData() {
const firstItem = this.mergedData[0];
if (firstItem && 'text' in firstItem) {
this.columns = [...this.columns];
const textColumn = this.columns.find(col => col.label === '任务名称');
if (textColumn) textColumn.name = 'text';
}
},
updateScaleConfig(isInit = false) {
gantt.config.scales = this.scaleConfig[this.currentScale];
if (isInit) {
gantt.render();
} else {
setTimeout(() => {
gantt.refreshData();
gantt.render();
}, 50);
}
},
switchScale(scaleType) {
this.currentScale = scaleType;
this.$forceUpdate();
this.updateScaleConfig();
},
bindZoomEvent() {
gantt.attachEvent('onMouseWheel', (event) => {
event.preventDefault();
if (event.deltaY < 0) {
const scaleOrder = ['year', 'month', 'week', 'day'];
const currentIndex = scaleOrder.indexOf(this.currentScale);
if (currentIndex < scaleOrder.length - 1) {
this.switchScale(scaleOrder[currentIndex + 1]);
}
} else {
const scaleOrder = ['year', 'month', 'week', 'day'];
const currentIndex = scaleOrder.indexOf(this.currentScale);
if (currentIndex > 0) {
this.switchScale(scaleOrder[currentIndex - 1]);
}
}
return false;
});
},
setupResizeObserver() {
const container = document.getElementById(this.ganttContainerId);
if (!container) return;
this.resizeObserver = new ResizeObserver(() => {
this.handleResize();
});
this.resizeObserver.observe(container);
},
handleResize() {
const container = document.getElementById(this.ganttContainerId);
if (container) {
const height = window.innerHeight - 70;
container.style.height = `${height}px`;
container.style.minHeight = '500px';
const emptyDiv = container.querySelector('#gantt-empty-state');
if (emptyDiv) emptyDiv.style.top = '50%';
if (gantt.isInitialized && gantt.isInitialized()) {
gantt.render();
}
}
},
bindGanttEvents() {
gantt.attachEvent('onBeforeTaskAdd', (id, task) => {
task.id = task.id || Date.now() + Math.floor(Math.random() * 1000);
task.parent = task.parent || 0;
task.start_date = task.start_date || new Date().toISOString().split('T')[0];
task.end_date = task.end_date || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
task.state = task.state || 'pending';
// 任务添加时:name → text
task.text = task.text || task.name || `未命名任务-${Date.now()}`;
task.progress = task.progress || 0;
return true;
});
gantt.attachEvent('onAfterTaskDrag', (id, mode, e) => {
const task = gantt.getTask(id);
console.log('任务拖拽更新:', {
id,
mode: mode === 'move' ? '整体移动' : '拉伸调整',
start: task.start_date,
end: task.end_date
});
});
gantt.attachEvent('onAfterTaskMove', (id, parentId, tindex) => {
const task = gantt.getTask(id);
console.log('任务行拖拽更新:', {
id,
newParent: parentId,
newIndex: tindex,
taskText: task.text
});
});
gantt.attachEvent('onAfterTaskAdd', (id, task) => {
const allTasks = gantt.serialize().data;
const allLinks = gantt.serialize().links;
console.log('新增任务:', task, allTasks, allLinks);
this.handleEmptyState();
});
gantt.attachEvent('onAfterTaskDelete', (id) => {
const allTasks = gantt.serialize().data;
const allLinks = gantt.serialize().links;
console.log('删除任务ID:', id, '删除后数据:', allTasks, allLinks);
if (gantt.getTaskCount() === 0) {
this.handleEmptyState();
}
});
gantt.attachEvent('onAfterTaskUpdate', (id, task) => {
console.log('任务更新:', task);
});
}
}
};
</script>
<style scoped>
.gantt-container {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
position: relative;
}
.gantt-toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 10px 20px;
background-color: #2d3748;
border-bottom: 1px solid #4a5568;
}
.scale-controls {
display: flex;
gap: 8px;
}
.scale-controls button {
background-color: #4a5568;
color: #e2e8f0;
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}
.scale-controls button:hover {
background-color: #718096;
}
.scale-controls button.active {
background-color: #4299e1;
color: white;
}
.gantt-box {
flex: 1;
width: 100%;
overflow: hidden;
position: relative;
min-height: 500px;
}
/deep/ .gantt_tree_icon {
filter: invert(0.8);
cursor: pointer;
}
/deep/ .dhtmlx_list_item:hover {
background-color: #4a5568 !important;
}
/deep/ .gantt_cell {
color: #e2e8f0 !important;
}
/deep/ .gantt_task_resize {
border-color: #ffffff !important;
}
/deep/ .gantt_scale_bar {
transition: all 0.1s ease;
}
</style>拖拽功能开启了但是拖拽不动
最新发布