Browse Source

甘特图优化

lbj
sd 1 month ago
parent
commit
561a373e40
  1. 6
      ruoyi-ui/babel.config.js
  2. 6
      ruoyi-ui/package.json
  3. 669
      ruoyi-ui/src/views/ganttChart/index.vue

6
ruoyi-ui/babel.config.js

@ -1,12 +1,12 @@
module.exports = {
presets: [
// https://github.com/vuejs/vue-cli/tree/master/packages/@vue/babel-preset-app
'@vue/cli-plugin-babel/preset'
],
plugins: [
'@babel/plugin-proposal-object-rest-spread'
],
'env': {
'development': {
// babel-plugin-dynamic-import-node plugin only does one thing by converting all import() to require().
// This plugin can significantly increase the speed of hot updates, when you have a large number of pages.
'plugins': ['dynamic-import-node']
}
}

6
ruoyi-ui/package.json

@ -35,9 +35,11 @@
"file-saver": "2.0.5",
"fuse.js": "6.4.3",
"highlight.js": "9.18.5",
"html2canvas": "^1.4.1",
"js-beautify": "1.13.0",
"js-cookie": "3.0.1",
"jsencrypt": "3.0.0-rc.1",
"jspdf": "^2.5.1",
"mammoth": "^1.11.0",
"nprogress": "0.2.0",
"quill": "2.0.2",
@ -50,9 +52,11 @@
"vue-i18n": "^8.28.2",
"vue-router": "3.4.9",
"vuedraggable": "2.24.3",
"vuex": "3.6.0"
"vuex": "3.6.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@babel/plugin-proposal-object-rest-spread": "^7.20.7",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
"@babel/plugin-transform-optional-chaining": "^7.28.6",
"@open-wc/webpack-import-meta-loader": "^0.4.7",

669
ruoyi-ui/src/views/ganttChart/index.vue

@ -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;

Loading…
Cancel
Save