|
|
|
@ -1,10 +1,19 @@ |
|
|
|
<template> |
|
|
|
<div class="gantt-chart-container"> |
|
|
|
<div class="gantt-chart-container" ref="ganttContainer"> |
|
|
|
<div class="gantt-header"> |
|
|
|
<h1 class="gantt-title">甘特图</h1> |
|
|
|
<div class="gantt-actions"> |
|
|
|
<el-button type="primary" size="small" @click="refreshData">刷新</el-button> |
|
|
|
<el-button type="default" size="small" @click="exportData">导出</el-button> |
|
|
|
<el-dropdown @command="handleExportCommand" trigger="click"> |
|
|
|
<el-button type="default" size="small"> |
|
|
|
导出 <i class="el-icon-arrow-down el-icon--right"></i> |
|
|
|
</el-button> |
|
|
|
<el-dropdown-menu slot="dropdown"> |
|
|
|
<el-dropdown-item command="png">导出 PNG</el-dropdown-item> |
|
|
|
<el-dropdown-item command="pdf">导出 PDF</el-dropdown-item> |
|
|
|
<el-dropdown-item command="xlsx">导出 Excel</el-dropdown-item> |
|
|
|
</el-dropdown-menu> |
|
|
|
</el-dropdown> |
|
|
|
<el-button type="default" size="small" @click="closePage">关闭</el-button> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
@ -16,6 +25,7 @@ |
|
|
|
<el-radio-button label="hour">小时视图</el-radio-button> |
|
|
|
<el-radio-button label="day">日视图</el-radio-button> |
|
|
|
</el-radio-group> |
|
|
|
<el-checkbox v-model="showCriticalPath" style="margin-left: 15px;">显示关键路径</el-checkbox> |
|
|
|
</div> |
|
|
|
<div class="toolbar-right"> |
|
|
|
<el-button-group size="small"> |
|
|
|
@ -27,38 +37,101 @@ |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div class="gantt-body"> |
|
|
|
<div class="gantt-body" ref="ganttBody"> |
|
|
|
<div class="gantt-sidebar"> |
|
|
|
<div class="sidebar-header">任务名称</div> |
|
|
|
<div class="sidebar-content"> |
|
|
|
<div v-for="item in ganttData" :key="item.id" class="sidebar-item"> |
|
|
|
<div |
|
|
|
v-for="item in processedGanttData" |
|
|
|
:key="item.id" |
|
|
|
class="sidebar-item" |
|
|
|
:class="{ 'critical-item': isCriticalTask(item.id) }" |
|
|
|
> |
|
|
|
<div class="item-icon" :style="{ backgroundColor: item.color }"></div> |
|
|
|
<div class="item-name">{{ item.name }}</div> |
|
|
|
<div class="item-name"> |
|
|
|
{{ item.name }} |
|
|
|
<el-tag v-if="isCriticalTask(item.id)" type="danger" size="mini" style="margin-left: 5px;">关键</el-tag> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div class="gantt-timeline"> |
|
|
|
<div class="gantt-timeline" ref="ganttTimeline"> |
|
|
|
<div class="timeline-header"> |
|
|
|
<div v-for="(time, index) in timelineTimes" :key="index" class="timeline-time"> |
|
|
|
<div class="time-label">{{ time }}</div> |
|
|
|
<div class="timeline-header-inner"> |
|
|
|
<div |
|
|
|
v-for="(time, index) in timelineTimes" |
|
|
|
:key="index" |
|
|
|
class="timeline-time" |
|
|
|
:style="{ left: time.position + '%' }" |
|
|
|
> |
|
|
|
<div class="time-label">{{ time.label }}</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div class="timeline-content"> |
|
|
|
<div v-for="item in ganttData" :key="item.id" class="timeline-row"> |
|
|
|
<div class="timeline-content" ref="timelineContent"> |
|
|
|
<div class="timeline-grid"> |
|
|
|
<div |
|
|
|
v-for="(time, index) in timelineTimes" |
|
|
|
:key="'grid-' + index" |
|
|
|
class="grid-line" |
|
|
|
:style="{ left: time.position + '%' }" |
|
|
|
></div> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div |
|
|
|
v-for="item in processedGanttData" |
|
|
|
:key="item.id" |
|
|
|
class="timeline-row" |
|
|
|
> |
|
|
|
<div |
|
|
|
class="timeline-bar" |
|
|
|
:class="{ |
|
|
|
'critical-bar': isCriticalTask(item.id), |
|
|
|
'dragging': draggingTask === item.id |
|
|
|
}" |
|
|
|
:style="{ |
|
|
|
left: item.startPercent + '%', |
|
|
|
width: item.durationPercent + '%', |
|
|
|
backgroundColor: item.color |
|
|
|
}" |
|
|
|
@mousedown="startDrag($event, item, 'move')" |
|
|
|
> |
|
|
|
<div class="bar-label">{{ item.name }}</div> |
|
|
|
<div class="bar-time">{{ formatTime(item.startTime) }} - {{ formatTime(item.endTime) }}</div> |
|
|
|
<div |
|
|
|
class="resize-handle resize-handle-left" |
|
|
|
@mousedown.stop="startDrag($event, item, 'resize-start')" |
|
|
|
></div> |
|
|
|
<div class="bar-content"> |
|
|
|
<div class="bar-label">{{ item.name }}</div> |
|
|
|
<div class="bar-time">{{ formatTime(item.startTime) }} - {{ formatTime(item.endTime) }}</div> |
|
|
|
</div> |
|
|
|
<div |
|
|
|
class="resize-handle resize-handle-right" |
|
|
|
@mousedown.stop="startDrag($event, item, 'resize-end')" |
|
|
|
></div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
<svg class="dependency-lines" v-if="showCriticalPath"> |
|
|
|
<defs> |
|
|
|
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto"> |
|
|
|
<polygon points="0 0, 10 3.5, 0 7" fill="#f56c6c" /> |
|
|
|
</marker> |
|
|
|
</defs> |
|
|
|
<line |
|
|
|
v-for="(line, index) in criticalPathLines" |
|
|
|
:key="'line-' + index" |
|
|
|
:x1="line.x1" |
|
|
|
:y1="line.y1" |
|
|
|
:x2="line.x2" |
|
|
|
:y2="line.y2" |
|
|
|
stroke="#f56c6c" |
|
|
|
stroke-width="2" |
|
|
|
stroke-dasharray="5,5" |
|
|
|
marker-end="url(#arrowhead)" |
|
|
|
/> |
|
|
|
</svg> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
@ -70,6 +143,10 @@ |
|
|
|
<div class="legend-color" :style="{ backgroundColor: legend.color }"></div> |
|
|
|
<div class="legend-text">{{ legend.label }}</div> |
|
|
|
</div> |
|
|
|
<div class="legend-item" v-if="showCriticalPath"> |
|
|
|
<div class="legend-color" :style="{ backgroundColor: '#f56c6c', border: '2px solid #f56c6c' }"></div> |
|
|
|
<div class="legend-text">关键路径</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
@ -77,12 +154,27 @@ |
|
|
|
</template> |
|
|
|
|
|
|
|
<script> |
|
|
|
import html2canvas from 'html2canvas' |
|
|
|
import jsPDF from 'jspdf' |
|
|
|
import { saveAs } from 'file-saver' |
|
|
|
|
|
|
|
const XLSX = require('xlsx') |
|
|
|
|
|
|
|
export default { |
|
|
|
name: 'GanttChart', |
|
|
|
data() { |
|
|
|
return { |
|
|
|
viewMode: 'day', |
|
|
|
currentDate: new Date(), |
|
|
|
timelineStartHour: 6, |
|
|
|
timelineEndHour: 20, |
|
|
|
showCriticalPath: false, |
|
|
|
draggingTask: null, |
|
|
|
dragType: null, |
|
|
|
dragStartX: 0, |
|
|
|
dragStartTime: null, |
|
|
|
dragEndTime: null, |
|
|
|
timelineWidth: 0, |
|
|
|
ganttData: [ |
|
|
|
{ |
|
|
|
id: 1, |
|
|
|
@ -91,8 +183,7 @@ export default { |
|
|
|
color: '#409EFF', |
|
|
|
startTime: new Date(2026, 1, 20, 6, 30), |
|
|
|
endTime: new Date(2026, 1, 20, 8, 30), |
|
|
|
startPercent: 3.33, |
|
|
|
durationPercent: 13.33 |
|
|
|
dependencies: [] |
|
|
|
}, |
|
|
|
{ |
|
|
|
id: 2, |
|
|
|
@ -101,8 +192,7 @@ export default { |
|
|
|
color: '#67C23A', |
|
|
|
startTime: new Date(2026, 1, 20, 8, 45), |
|
|
|
endTime: new Date(2026, 1, 20, 10, 45), |
|
|
|
startPercent: 21.67, |
|
|
|
durationPercent: 13.33 |
|
|
|
dependencies: [1] |
|
|
|
}, |
|
|
|
{ |
|
|
|
id: 3, |
|
|
|
@ -111,8 +201,7 @@ export default { |
|
|
|
color: '#E6A23C', |
|
|
|
startTime: new Date(2026, 1, 20, 11, 0), |
|
|
|
endTime: new Date(2026, 1, 20, 13, 0), |
|
|
|
startPercent: 40, |
|
|
|
durationPercent: 13.33 |
|
|
|
dependencies: [2] |
|
|
|
}, |
|
|
|
{ |
|
|
|
id: 4, |
|
|
|
@ -121,8 +210,7 @@ export default { |
|
|
|
color: '#F56C6C', |
|
|
|
startTime: new Date(2026, 1, 20, 13, 15), |
|
|
|
endTime: new Date(2026, 1, 20, 14, 15), |
|
|
|
startPercent: 47.5, |
|
|
|
durationPercent: 6.67 |
|
|
|
dependencies: [3] |
|
|
|
}, |
|
|
|
{ |
|
|
|
id: 5, |
|
|
|
@ -131,8 +219,7 @@ export default { |
|
|
|
color: '#909399', |
|
|
|
startTime: new Date(2026, 1, 20, 14, 30), |
|
|
|
endTime: new Date(2026, 1, 20, 16, 0), |
|
|
|
startPercent: 55, |
|
|
|
durationPercent: 10 |
|
|
|
dependencies: [4] |
|
|
|
}, |
|
|
|
{ |
|
|
|
id: 6, |
|
|
|
@ -141,8 +228,7 @@ export default { |
|
|
|
color: '#409EFF', |
|
|
|
startTime: new Date(2026, 1, 20, 16, 15), |
|
|
|
endTime: new Date(2026, 1, 20, 18, 15), |
|
|
|
startPercent: 63.33, |
|
|
|
durationPercent: 13.33 |
|
|
|
dependencies: [5] |
|
|
|
}, |
|
|
|
{ |
|
|
|
id: 7, |
|
|
|
@ -151,8 +237,7 @@ export default { |
|
|
|
color: '#67C23A', |
|
|
|
startTime: new Date(2026, 1, 20, 18, 30), |
|
|
|
endTime: new Date(2026, 1, 20, 20, 0), |
|
|
|
startPercent: 81.67, |
|
|
|
durationPercent: 10 |
|
|
|
dependencies: [6] |
|
|
|
}, |
|
|
|
{ |
|
|
|
id: 8, |
|
|
|
@ -161,8 +246,7 @@ export default { |
|
|
|
color: '#E6A23C', |
|
|
|
startTime: new Date(2026, 1, 20, 9, 30), |
|
|
|
endTime: new Date(2026, 1, 20, 11, 30), |
|
|
|
startPercent: 30, |
|
|
|
durationPercent: 13.33 |
|
|
|
dependencies: [1] |
|
|
|
} |
|
|
|
], |
|
|
|
legends: [ |
|
|
|
@ -170,7 +254,9 @@ export default { |
|
|
|
{ type: 'missile', label: '导弹发射', color: '#F56C6C' }, |
|
|
|
{ type: 'refuel', label: '空中加油', color: '#909399' }, |
|
|
|
{ type: 'reconnaissance', label: '侦察任务', color: '#E6A23C' } |
|
|
|
] |
|
|
|
], |
|
|
|
criticalPath: [], |
|
|
|
criticalPathLines: [] |
|
|
|
} |
|
|
|
}, |
|
|
|
computed: { |
|
|
|
@ -189,21 +275,356 @@ export default { |
|
|
|
}, |
|
|
|
timelineTimes() { |
|
|
|
const times = [] |
|
|
|
const startHour = 6 |
|
|
|
const endHour = 20 |
|
|
|
|
|
|
|
for (let hour = startHour; hour <= endHour; hour++) { |
|
|
|
times.push(`${hour.toString().padStart(2, '0')}:00`) |
|
|
|
for (let hour = this.timelineStartHour; hour <= this.timelineEndHour; hour++) { |
|
|
|
times.push({ |
|
|
|
label: `${hour.toString().padStart(2, '0')}:00`, |
|
|
|
position: this.getTimePosition(hour, 0) |
|
|
|
}) |
|
|
|
} |
|
|
|
return times |
|
|
|
}, |
|
|
|
totalHours() { |
|
|
|
return this.timelineEndHour - this.timelineStartHour |
|
|
|
}, |
|
|
|
totalMinutes() { |
|
|
|
return this.totalHours * 60 |
|
|
|
}, |
|
|
|
processedGanttData() { |
|
|
|
return this.ganttData.map(item => ({ |
|
|
|
...item, |
|
|
|
startPercent: this.getTimePosition(item.startTime.getHours(), item.startTime.getMinutes()), |
|
|
|
durationPercent: this.getDurationPercent(item.startTime, item.endTime) |
|
|
|
})) |
|
|
|
} |
|
|
|
}, |
|
|
|
watch: { |
|
|
|
showCriticalPath() { |
|
|
|
this.calculateCriticalPath() |
|
|
|
}, |
|
|
|
processedGanttData: { |
|
|
|
handler() { |
|
|
|
if (this.showCriticalPath) { |
|
|
|
this.$nextTick(() => { |
|
|
|
this.calculateCriticalPath() |
|
|
|
}) |
|
|
|
} |
|
|
|
}, |
|
|
|
deep: true |
|
|
|
} |
|
|
|
}, |
|
|
|
mounted() { |
|
|
|
this.updateTimelineWidth() |
|
|
|
window.addEventListener('resize', this.updateTimelineWidth) |
|
|
|
document.addEventListener('mousemove', this.onDrag) |
|
|
|
document.addEventListener('mouseup', this.endDrag) |
|
|
|
}, |
|
|
|
beforeDestroy() { |
|
|
|
window.removeEventListener('resize', this.updateTimelineWidth) |
|
|
|
document.removeEventListener('mousemove', this.onDrag) |
|
|
|
document.removeEventListener('mouseup', this.endDrag) |
|
|
|
}, |
|
|
|
methods: { |
|
|
|
updateTimelineWidth() { |
|
|
|
if (this.$refs.ganttTimeline) { |
|
|
|
this.timelineWidth = this.$refs.ganttTimeline.clientWidth |
|
|
|
} |
|
|
|
}, |
|
|
|
getTimePosition(hour, minute) { |
|
|
|
const totalMinutes = (hour - this.timelineStartHour) * 60 + minute |
|
|
|
return (totalMinutes / this.totalMinutes) * 100 |
|
|
|
}, |
|
|
|
getDurationPercent(startTime, endTime) { |
|
|
|
const durationMinutes = (endTime.getHours() * 60 + endTime.getMinutes()) - |
|
|
|
(startTime.getHours() * 60 + startTime.getMinutes()) |
|
|
|
return (durationMinutes / this.totalMinutes) * 100 |
|
|
|
}, |
|
|
|
positionToTime(percent) { |
|
|
|
const totalMinutes = (percent / 100) * this.totalMinutes |
|
|
|
const hours = Math.floor(totalMinutes / 60) + this.timelineStartHour |
|
|
|
const minutes = Math.round(totalMinutes % 60) |
|
|
|
return { hours, minutes } |
|
|
|
}, |
|
|
|
startDrag(event, item, type) { |
|
|
|
event.preventDefault() |
|
|
|
this.draggingTask = item.id |
|
|
|
this.dragType = type |
|
|
|
this.dragStartX = event.clientX |
|
|
|
this.dragStartTime = new Date(item.startTime) |
|
|
|
this.dragEndTime = new Date(item.endTime) |
|
|
|
}, |
|
|
|
onDrag(event) { |
|
|
|
if (!this.draggingTask) return |
|
|
|
|
|
|
|
const deltaX = event.clientX - this.dragStartX |
|
|
|
const deltaPercent = (deltaX / this.timelineWidth) * 100 |
|
|
|
const deltaMinutes = (deltaPercent / 100) * this.totalMinutes |
|
|
|
|
|
|
|
const task = this.ganttData.find(t => t.id === this.draggingTask) |
|
|
|
if (!task) return |
|
|
|
|
|
|
|
if (this.dragType === 'move') { |
|
|
|
const newStart = new Date(this.dragStartTime) |
|
|
|
newStart.setMinutes(newStart.getMinutes() + deltaMinutes) |
|
|
|
const newEnd = new Date(this.dragEndTime) |
|
|
|
newEnd.setMinutes(newEnd.getMinutes() + deltaMinutes) |
|
|
|
|
|
|
|
if (this.isValidTime(newStart) && this.isValidTime(newEnd)) { |
|
|
|
task.startTime = newStart |
|
|
|
task.endTime = newEnd |
|
|
|
} |
|
|
|
} else if (this.dragType === 'resize-start') { |
|
|
|
const newStart = new Date(this.dragStartTime) |
|
|
|
newStart.setMinutes(newStart.getMinutes() + deltaMinutes) |
|
|
|
|
|
|
|
if (this.isValidTime(newStart) && newStart < task.endTime) { |
|
|
|
task.startTime = newStart |
|
|
|
} |
|
|
|
} else if (this.dragType === 'resize-end') { |
|
|
|
const newEnd = new Date(this.dragEndTime) |
|
|
|
newEnd.setMinutes(newEnd.getMinutes() + deltaMinutes) |
|
|
|
|
|
|
|
if (this.isValidTime(newEnd) && newEnd > task.startTime) { |
|
|
|
task.endTime = newEnd |
|
|
|
} |
|
|
|
} |
|
|
|
}, |
|
|
|
endDrag() { |
|
|
|
if (this.draggingTask) { |
|
|
|
this.$message.success('任务时间已更新') |
|
|
|
} |
|
|
|
this.draggingTask = null |
|
|
|
this.dragType = null |
|
|
|
}, |
|
|
|
isValidTime(date) { |
|
|
|
const hours = date.getHours() |
|
|
|
const minutes = date.getMinutes() |
|
|
|
const totalMins = hours * 60 + minutes |
|
|
|
const startMins = this.timelineStartHour * 60 |
|
|
|
const endMins = this.timelineEndHour * 60 |
|
|
|
return totalMins >= startMins && totalMins <= endMins |
|
|
|
}, |
|
|
|
calculateCriticalPath() { |
|
|
|
const tasks = this.ganttData |
|
|
|
const taskMap = new Map(tasks.map(t => [t.id, t])) |
|
|
|
|
|
|
|
const earliestStart = new Map() |
|
|
|
const earliestFinish = new Map() |
|
|
|
const latestStart = new Map() |
|
|
|
const latestFinish = new Map() |
|
|
|
|
|
|
|
const topologicalOrder = [] |
|
|
|
const visited = new Set() |
|
|
|
|
|
|
|
const visit = (taskId) => { |
|
|
|
if (visited.has(taskId)) return |
|
|
|
visited.add(taskId) |
|
|
|
const task = taskMap.get(taskId) |
|
|
|
if (task.dependencies) { |
|
|
|
task.dependencies.forEach(depId => visit(depId)) |
|
|
|
} |
|
|
|
topologicalOrder.push(taskId) |
|
|
|
} |
|
|
|
|
|
|
|
tasks.forEach(t => visit(t.id)) |
|
|
|
|
|
|
|
topologicalOrder.forEach(taskId => { |
|
|
|
const task = taskMap.get(taskId) |
|
|
|
let maxEarliestStart = 0 |
|
|
|
|
|
|
|
if (task.dependencies && task.dependencies.length > 0) { |
|
|
|
task.dependencies.forEach(depId => { |
|
|
|
const depFinish = earliestFinish.get(depId) || 0 |
|
|
|
maxEarliestStart = Math.max(maxEarliestStart, depFinish) |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
const duration = this.getTaskDuration(task) |
|
|
|
earliestStart.set(taskId, maxEarliestStart) |
|
|
|
earliestFinish.set(taskId, maxEarliestStart + duration) |
|
|
|
}) |
|
|
|
|
|
|
|
const projectDuration = Math.max.apply(null, Array.from(earliestFinish.values())) |
|
|
|
|
|
|
|
const reversedOrder = topologicalOrder.slice().reverse() |
|
|
|
reversedOrder.forEach(taskId => { |
|
|
|
const task = taskMap.get(taskId) |
|
|
|
let minLatestFinish = projectDuration |
|
|
|
|
|
|
|
tasks.forEach(t => { |
|
|
|
if (t.dependencies && t.dependencies.includes(taskId)) { |
|
|
|
const depStart = latestStart.get(t.id) |
|
|
|
if (depStart !== undefined) { |
|
|
|
minLatestFinish = Math.min(minLatestFinish, depStart) |
|
|
|
} |
|
|
|
} |
|
|
|
}) |
|
|
|
|
|
|
|
const duration = this.getTaskDuration(task) |
|
|
|
latestFinish.set(taskId, minLatestFinish) |
|
|
|
latestStart.set(taskId, minLatestFinish - duration) |
|
|
|
}) |
|
|
|
|
|
|
|
this.criticalPath = tasks |
|
|
|
.filter(t => { |
|
|
|
const es = earliestStart.get(t.id) |
|
|
|
const ls = latestStart.get(t.id) |
|
|
|
return Math.abs(es - ls) < 0.001 |
|
|
|
}) |
|
|
|
.map(t => t.id) |
|
|
|
|
|
|
|
this.calculateCriticalPathLines() |
|
|
|
}, |
|
|
|
getTaskDuration(task) { |
|
|
|
const start = task.startTime.getHours() * 60 + task.startTime.getMinutes() |
|
|
|
const end = task.endTime.getHours() * 60 + task.endTime.getMinutes() |
|
|
|
return end - start |
|
|
|
}, |
|
|
|
calculateCriticalPathLines() { |
|
|
|
this.criticalPathLines = [] |
|
|
|
|
|
|
|
if (!this.$refs.timelineContent) return |
|
|
|
|
|
|
|
this.ganttData.forEach(task => { |
|
|
|
if (task.dependencies && task.dependencies.length > 0) { |
|
|
|
task.dependencies.forEach(depId => { |
|
|
|
if (this.isCriticalTask(task.id) && this.isCriticalTask(depId)) { |
|
|
|
const depTask = this.processedGanttData.find(t => t.id === depId) |
|
|
|
const currentTask = this.processedGanttData.find(t => t.id === task.id) |
|
|
|
|
|
|
|
if (depTask && currentTask) { |
|
|
|
const containerRect = this.$refs.timelineContent.getBoundingClientRect() |
|
|
|
const containerWidth = containerRect.width |
|
|
|
|
|
|
|
const x1 = (depTask.startPercent + depTask.durationPercent) / 100 * containerWidth |
|
|
|
const x2 = currentTask.startPercent / 100 * containerWidth |
|
|
|
|
|
|
|
const depIndex = this.ganttData.findIndex(t => t.id === depId) |
|
|
|
const curIndex = this.ganttData.findIndex(t => t.id === task.id) |
|
|
|
|
|
|
|
const y1 = depIndex * 50 + 26 |
|
|
|
const y2 = curIndex * 50 + 26 |
|
|
|
|
|
|
|
this.criticalPathLines.push({ x1, y1, x2, y2 }) |
|
|
|
} |
|
|
|
} |
|
|
|
}) |
|
|
|
} |
|
|
|
}) |
|
|
|
}, |
|
|
|
isCriticalTask(taskId) { |
|
|
|
return this.showCriticalPath && this.criticalPath.includes(taskId) |
|
|
|
}, |
|
|
|
refreshData() { |
|
|
|
this.$message.success('数据已刷新') |
|
|
|
}, |
|
|
|
exportData() { |
|
|
|
this.$message.success('数据导出中...') |
|
|
|
handleExportCommand(command) { |
|
|
|
switch (command) { |
|
|
|
case 'png': |
|
|
|
this.exportToPNG() |
|
|
|
break |
|
|
|
case 'pdf': |
|
|
|
this.exportToPDF() |
|
|
|
break |
|
|
|
case 'xlsx': |
|
|
|
this.exportToXLSX() |
|
|
|
break |
|
|
|
} |
|
|
|
}, |
|
|
|
async exportToPNG() { |
|
|
|
try { |
|
|
|
this.$message.info('正在生成PNG...') |
|
|
|
|
|
|
|
const element = this.$refs.ganttBody |
|
|
|
const canvas = await html2canvas(element, { |
|
|
|
backgroundColor: '#ffffff', |
|
|
|
scale: 2, |
|
|
|
useCORS: true |
|
|
|
}) |
|
|
|
|
|
|
|
canvas.toBlob((blob) => { |
|
|
|
saveAs(blob, `甘特图_${this.formatDateForFile()}.png`) |
|
|
|
this.$message.success('PNG导出成功') |
|
|
|
}) |
|
|
|
} catch (error) { |
|
|
|
console.error('PNG导出失败:', error) |
|
|
|
this.$message.error('PNG导出失败') |
|
|
|
} |
|
|
|
}, |
|
|
|
async exportToPDF() { |
|
|
|
try { |
|
|
|
this.$message.info('正在生成PDF...') |
|
|
|
|
|
|
|
const element = this.$refs.ganttBody |
|
|
|
const canvas = await html2canvas(element, { |
|
|
|
backgroundColor: '#ffffff', |
|
|
|
scale: 2, |
|
|
|
useCORS: true |
|
|
|
}) |
|
|
|
|
|
|
|
const imgData = canvas.toDataURL('image/png') |
|
|
|
const pdf = new jsPDF({ |
|
|
|
orientation: 'landscape', |
|
|
|
unit: 'mm', |
|
|
|
format: 'a4' |
|
|
|
}) |
|
|
|
|
|
|
|
const pdfWidth = pdf.internal.pageSize.getWidth() |
|
|
|
const pdfHeight = pdf.internal.pageSize.getHeight() |
|
|
|
const imgWidth = canvas.width |
|
|
|
const imgHeight = canvas.height |
|
|
|
const ratio = Math.min(pdfWidth / imgWidth, pdfHeight / imgHeight) |
|
|
|
|
|
|
|
const scaledWidth = imgWidth * ratio |
|
|
|
const scaledHeight = imgHeight * ratio |
|
|
|
const x = (pdfWidth - scaledWidth) / 2 |
|
|
|
const y = (pdfHeight - scaledHeight) / 2 |
|
|
|
|
|
|
|
pdf.addImage(imgData, 'PNG', x, y, scaledWidth, scaledHeight) |
|
|
|
pdf.save(`甘特图_${this.formatDateForFile()}.pdf`) |
|
|
|
|
|
|
|
this.$message.success('PDF导出成功') |
|
|
|
} catch (error) { |
|
|
|
console.error('PDF导出失败:', error) |
|
|
|
this.$message.error('PDF导出失败') |
|
|
|
} |
|
|
|
}, |
|
|
|
exportToXLSX() { |
|
|
|
try { |
|
|
|
this.$message.info('正在生成Excel...') |
|
|
|
|
|
|
|
const data = this.ganttData.map(task => ({ |
|
|
|
'任务名称': task.name, |
|
|
|
'任务类型': this.getTaskTypeName(task.type), |
|
|
|
'开始时间': this.formatDateTime(task.startTime), |
|
|
|
'结束时间': this.formatDateTime(task.endTime), |
|
|
|
'持续时间(分钟)': this.getTaskDuration(task), |
|
|
|
'是否关键路径': this.isCriticalTask(task.id) ? '是' : '否', |
|
|
|
'前置任务': task.dependencies && task.dependencies.length > 0 |
|
|
|
? task.dependencies.map(id => this.ganttData.find(t => t.id === id)?.name).join(', ') |
|
|
|
: '无' |
|
|
|
})) |
|
|
|
|
|
|
|
const ws = XLSX.utils.json_to_sheet(data) |
|
|
|
const wb = XLSX.utils.book_new() |
|
|
|
XLSX.utils.book_append_sheet(wb, ws, '甘特图数据') |
|
|
|
|
|
|
|
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) |
|
|
|
const blob = new Blob([wbout], { type: 'application/octet-stream' }) |
|
|
|
saveAs(blob, `甘特图_${this.formatDateForFile()}.xlsx`) |
|
|
|
|
|
|
|
this.$message.success('Excel导出成功') |
|
|
|
} catch (error) { |
|
|
|
console.error('Excel导出失败:', error) |
|
|
|
this.$message.error('Excel导出失败') |
|
|
|
} |
|
|
|
}, |
|
|
|
getTaskTypeName(type) { |
|
|
|
const legend = this.legends.find(l => l.type === type) |
|
|
|
return legend ? legend.label : type |
|
|
|
}, |
|
|
|
formatDateForFile() { |
|
|
|
const now = new Date() |
|
|
|
return `${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}_${now.getHours().toString().padStart(2, '0')}${now.getMinutes().toString().padStart(2, '0')}` |
|
|
|
}, |
|
|
|
closePage() { |
|
|
|
window.close() |
|
|
|
@ -229,6 +650,14 @@ export default { |
|
|
|
const hours = date.getHours().toString().padStart(2, '0') |
|
|
|
const minutes = date.getMinutes().toString().padStart(2, '0') |
|
|
|
return `${hours}:${minutes}` |
|
|
|
}, |
|
|
|
formatDateTime(date) { |
|
|
|
const year = date.getFullYear() |
|
|
|
const month = (date.getMonth() + 1).toString().padStart(2, '0') |
|
|
|
const day = date.getDate().toString().padStart(2, '0') |
|
|
|
const hours = date.getHours().toString().padStart(2, '0') |
|
|
|
const minutes = date.getMinutes().toString().padStart(2, '0') |
|
|
|
return `${year}-${month}-${day} ${hours}:${minutes}` |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
@ -348,6 +777,10 @@ export default { |
|
|
|
background-color: #f5f7fa; |
|
|
|
} |
|
|
|
|
|
|
|
.sidebar-item.critical-item { |
|
|
|
background-color: #fef0f0; |
|
|
|
} |
|
|
|
|
|
|
|
.item-icon { |
|
|
|
width: 12px; |
|
|
|
height: 12px; |
|
|
|
@ -362,6 +795,8 @@ export default { |
|
|
|
overflow: hidden; |
|
|
|
text-overflow: ellipsis; |
|
|
|
white-space: nowrap; |
|
|
|
display: flex; |
|
|
|
align-items: center; |
|
|
|
} |
|
|
|
|
|
|
|
.gantt-timeline { |
|
|
|
@ -373,17 +808,34 @@ export default { |
|
|
|
|
|
|
|
.timeline-header { |
|
|
|
height: 50px; |
|
|
|
display: flex; |
|
|
|
position: relative; |
|
|
|
background: #f5f7fa; |
|
|
|
border-bottom: 1px solid #e4e7ed; |
|
|
|
} |
|
|
|
|
|
|
|
.timeline-header-inner { |
|
|
|
position: relative; |
|
|
|
width: 100%; |
|
|
|
height: 100%; |
|
|
|
} |
|
|
|
|
|
|
|
.timeline-time { |
|
|
|
flex: 1; |
|
|
|
position: absolute; |
|
|
|
transform: translateX(-50%); |
|
|
|
display: flex; |
|
|
|
justify-content: center; |
|
|
|
align-items: center; |
|
|
|
border-right: 1px solid #e4e7ed; |
|
|
|
height: 100%; |
|
|
|
} |
|
|
|
|
|
|
|
.timeline-time::after { |
|
|
|
content: ''; |
|
|
|
position: absolute; |
|
|
|
bottom: 0; |
|
|
|
left: 50%; |
|
|
|
width: 1px; |
|
|
|
height: 8px; |
|
|
|
background: #dcdfe6; |
|
|
|
} |
|
|
|
|
|
|
|
.time-label { |
|
|
|
@ -396,66 +848,23 @@ export default { |
|
|
|
flex: 1; |
|
|
|
position: relative; |
|
|
|
overflow-y: auto; |
|
|
|
background: linear-gradient(to right, |
|
|
|
transparent 0%, |
|
|
|
transparent 6.667%, |
|
|
|
#f0f0f0 6.667%, |
|
|
|
#f0f0f0 6.677%, |
|
|
|
transparent 6.677%, |
|
|
|
transparent 13.333%, |
|
|
|
#f0f0f0 13.333%, |
|
|
|
#f0f0f0 13.343%, |
|
|
|
transparent 13.343%, |
|
|
|
transparent 20%, |
|
|
|
#f0f0f0 20%, |
|
|
|
#f0f0f0 20.01%, |
|
|
|
transparent 20.01%, |
|
|
|
transparent 26.667%, |
|
|
|
#f0f0f0 26.667%, |
|
|
|
#f0f0f0 26.677%, |
|
|
|
transparent 26.677%, |
|
|
|
transparent 33.333%, |
|
|
|
#f0f0f0 33.333%, |
|
|
|
#f0f0f0 33.343%, |
|
|
|
transparent 33.343%, |
|
|
|
transparent 40%, |
|
|
|
#f0f0f0 40%, |
|
|
|
#f0f0f0 40.01%, |
|
|
|
transparent 40.01%, |
|
|
|
transparent 46.667%, |
|
|
|
#f0f0f0 46.667%, |
|
|
|
#f0f0f0 46.677%, |
|
|
|
transparent 46.677%, |
|
|
|
transparent 53.333%, |
|
|
|
#f0f0f0 53.333%, |
|
|
|
#f0f0f0 53.343%, |
|
|
|
transparent 53.343%, |
|
|
|
transparent 60%, |
|
|
|
#f0f0f0 60%, |
|
|
|
#f0f0f0 60.01%, |
|
|
|
transparent 60.01%, |
|
|
|
transparent 66.667%, |
|
|
|
#f0f0f0 66.667%, |
|
|
|
#f0f0f0 66.677%, |
|
|
|
transparent 66.677%, |
|
|
|
transparent 73.333%, |
|
|
|
#f0f0f0 73.333%, |
|
|
|
#f0f0f0 73.343%, |
|
|
|
transparent 73.343%, |
|
|
|
transparent 80%, |
|
|
|
#f0f0f0 80%, |
|
|
|
#f0f0f0 80.01%, |
|
|
|
transparent 80.01%, |
|
|
|
transparent 86.667%, |
|
|
|
#f0f0f0 86.667%, |
|
|
|
#f0f0f0 86.677%, |
|
|
|
transparent 86.677%, |
|
|
|
transparent 93.333%, |
|
|
|
#f0f0f0 93.333%, |
|
|
|
#f0f0f0 93.343%, |
|
|
|
transparent 93.343%, |
|
|
|
transparent 100% |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
.timeline-grid { |
|
|
|
position: absolute; |
|
|
|
top: 0; |
|
|
|
left: 0; |
|
|
|
right: 0; |
|
|
|
bottom: 0; |
|
|
|
pointer-events: none; |
|
|
|
} |
|
|
|
|
|
|
|
.grid-line { |
|
|
|
position: absolute; |
|
|
|
top: 0; |
|
|
|
bottom: 0; |
|
|
|
width: 1px; |
|
|
|
background: #ebeef5; |
|
|
|
} |
|
|
|
|
|
|
|
.timeline-row { |
|
|
|
@ -470,11 +879,11 @@ export default { |
|
|
|
height: 36px; |
|
|
|
border-radius: 4px; |
|
|
|
display: flex; |
|
|
|
flex-direction: column; |
|
|
|
align-items: center; |
|
|
|
justify-content: center; |
|
|
|
cursor: pointer; |
|
|
|
cursor: move; |
|
|
|
transition: transform 0.2s, box-shadow 0.2s; |
|
|
|
user-select: none; |
|
|
|
} |
|
|
|
|
|
|
|
.timeline-bar:hover { |
|
|
|
@ -482,6 +891,53 @@ export default { |
|
|
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); |
|
|
|
} |
|
|
|
|
|
|
|
.timeline-bar.dragging { |
|
|
|
opacity: 0.8; |
|
|
|
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2); |
|
|
|
} |
|
|
|
|
|
|
|
.timeline-bar.critical-bar { |
|
|
|
border: 3px solid #f56c6c; |
|
|
|
box-shadow: 0 0 10px rgba(245, 108, 108, 0.5); |
|
|
|
} |
|
|
|
|
|
|
|
.timeline-bar.critical-bar:hover { |
|
|
|
box-shadow: 0 4px 12px rgba(245, 108, 108, 0.6); |
|
|
|
} |
|
|
|
|
|
|
|
.resize-handle { |
|
|
|
position: absolute; |
|
|
|
top: 0; |
|
|
|
bottom: 0; |
|
|
|
width: 8px; |
|
|
|
cursor: ew-resize; |
|
|
|
z-index: 10; |
|
|
|
} |
|
|
|
|
|
|
|
.resize-handle-left { |
|
|
|
left: 0; |
|
|
|
border-radius: 4px 0 0 4px; |
|
|
|
} |
|
|
|
|
|
|
|
.resize-handle-right { |
|
|
|
right: 0; |
|
|
|
border-radius: 0 4px 4px 0; |
|
|
|
} |
|
|
|
|
|
|
|
.resize-handle:hover { |
|
|
|
background: rgba(255, 255, 255, 0.3); |
|
|
|
} |
|
|
|
|
|
|
|
.bar-content { |
|
|
|
flex: 1; |
|
|
|
display: flex; |
|
|
|
flex-direction: column; |
|
|
|
align-items: center; |
|
|
|
justify-content: center; |
|
|
|
overflow: hidden; |
|
|
|
padding: 0 12px; |
|
|
|
} |
|
|
|
|
|
|
|
.bar-label { |
|
|
|
font-size: 12px; |
|
|
|
color: #fff; |
|
|
|
@ -489,7 +945,6 @@ export default { |
|
|
|
overflow: hidden; |
|
|
|
text-overflow: ellipsis; |
|
|
|
white-space: nowrap; |
|
|
|
padding: 0 8px; |
|
|
|
} |
|
|
|
|
|
|
|
.bar-time { |
|
|
|
@ -498,6 +953,16 @@ export default { |
|
|
|
margin-top: 2px; |
|
|
|
} |
|
|
|
|
|
|
|
.dependency-lines { |
|
|
|
position: absolute; |
|
|
|
top: 0; |
|
|
|
left: 0; |
|
|
|
width: 100%; |
|
|
|
height: 100%; |
|
|
|
pointer-events: none; |
|
|
|
z-index: 5; |
|
|
|
} |
|
|
|
|
|
|
|
.gantt-legend { |
|
|
|
display: flex; |
|
|
|
align-items: center; |
|
|
|
|