@mx-sose-front/mx-sose-graph 1.1.8 → 1.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/edgeWorker-b57ca007.js +2 -0
- package/dist/assets/edgeWorker-b57ca007.js.map +1 -0
- package/dist/index.d.ts +633 -30
- package/dist/index.esm.js +8728 -4734
- package/dist/index.esm.js.map +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/index.umd.js.map +1 -1
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/Common/Tree.vue +451 -0
- package/src/components/Common/index.ts +2 -0
- package/src/components/DiagramListTooltip/DiagramListTooltip.vue +1 -2
- package/src/components/Edge/Edge.vue +172 -169
- package/src/components/Gantt/Gantt.vue +1544 -0
- package/src/components/GanttContextMenu/GanttContextMenu.vue +304 -0
- package/src/components/InteractionLayer.vue +343 -147
- package/src/components/Matrix/Matrix.vue +828 -0
- package/src/components/Matrix/index.ts +168 -0
- package/src/components/Shape/ConceptualRole.vue +2 -34
- package/src/components/Table/Table.vue +970 -0
- package/src/constants/edgeShapeKeys.ts +8 -5
- package/src/constants/index.ts +259 -45
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useChartRowSelection.ts +456 -0
- package/src/hooks/useResize.ts +2 -2
- package/src/hooks/useVirtualScroll.ts +258 -0
- package/src/index.ts +1 -1
- package/src/render/shape-renderer.ts +62 -2
- package/src/statics/icons/childIcons//345/221/275/344/273/244@3x.png +0 -0
- package/src/statics/icons/childIcons//346/210/230/347/225/245/346/246/202/345/277/265/350/241/250@3x.png +0 -0
- package/src/statics/icons/childIcons//346/216/247/345/210/266@3x.png +0 -0
- package/src/statics/icons/createMenu/down.png +0 -0
- package/src/statics/icons/createMenu/remove.png +0 -0
- package/src/statics/icons/createMenu/up.png +0 -0
- package/src/store/graphStore.ts +217 -44
- package/src/types/index.ts +86 -4
- package/src/utils/batchAutoExpand.ts +9 -10
- package/src/utils/containers.ts +72 -17
- package/src/utils/contextMenuUtils.ts +7 -7
- package/src/utils/dateUtils.ts +160 -0
- package/src/utils/diagram.ts +10 -8
- package/src/utils/drag.ts +6 -5
- package/src/utils/edgeUtils.ts +344 -427
- package/src/utils/edgeWorker.ts +471 -0
- package/src/utils/hittest.ts +37 -38
- package/src/utils/index.ts +3 -0
- package/src/utils/keyboardUtils.ts +5 -5
- package/src/utils/packageOutline.ts +96 -0
- package/src/utils/rafThrottle.ts +162 -0
- package/src/utils/workerManager.ts +335 -0
- package/src/view/graph.vue +47 -33
- /package/src/statics/icons/childIcons//346/210/230/347/225/{245@3x.png" → 245/345/261/202@3x.png"} +0 -0
|
@@ -0,0 +1,1544 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="gantt-container" :style="containerStyle" @click="onContainerClick">
|
|
3
|
+
<!-- 搜索栏 -->
|
|
4
|
+
<div v-if="showSearchBar" class="gantt-search-bar">
|
|
5
|
+
<input v-model="searchKeyword" type="text" class="search-input" placeholder="搜索..." @input="onSearchInput"
|
|
6
|
+
ref="searchInputRef" />
|
|
7
|
+
<span class="search-result-info">{{ searchResultText }}</span>
|
|
8
|
+
<button class="search-nav-btn" :disabled="!canGoPrev" @click="gotoPrevMatch" title="上一条">
|
|
9
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
|
10
|
+
<path d="M8 10L4 6l4-4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"
|
|
11
|
+
stroke-linejoin="round" />
|
|
12
|
+
</svg>
|
|
13
|
+
</button>
|
|
14
|
+
<button class="search-nav-btn" :disabled="!canGoNext" @click="gotoNextMatch" title="下一条">
|
|
15
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
|
16
|
+
<path d="M4 10l4-4-4-4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"
|
|
17
|
+
stroke-linejoin="round" />
|
|
18
|
+
</svg>
|
|
19
|
+
</button>
|
|
20
|
+
<button class="search-close-btn" @click="closeSearch" title="关闭">×</button>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<!-- 左侧表格 -->
|
|
24
|
+
<div class="gantt-left" ref="leftTableRef">
|
|
25
|
+
<table class="gantt-table">
|
|
26
|
+
<thead>
|
|
27
|
+
<tr class="header-row-1">
|
|
28
|
+
<th :colspan="visibleColumns.length"></th>
|
|
29
|
+
</tr>
|
|
30
|
+
<tr class="header-row-2">
|
|
31
|
+
<th v-for="col in visibleColumns" :key="col.configId"
|
|
32
|
+
:class="isNameColumn(col.configId) ? 'col-name' : 'col-date'"
|
|
33
|
+
:style="{ width: columnWidths[col.configId] + 'px' }">
|
|
34
|
+
<div class="th-content">
|
|
35
|
+
<span class="th-label">{{ col.name }}</span>
|
|
36
|
+
<div class="col-resizer" @mousedown="startColumnResize(col.configId, $event)"></div>
|
|
37
|
+
</div>
|
|
38
|
+
</th>
|
|
39
|
+
</tr>
|
|
40
|
+
</thead>
|
|
41
|
+
<tbody>
|
|
42
|
+
<!-- 虚拟占位:不渲染窗口外的真实行,但用 spacer 维持完整滚动高度 -->
|
|
43
|
+
<tr v-if="virtualTopSpacerHeight > 0" class="gantt-spacer-row" aria-hidden="true">
|
|
44
|
+
<td :colspan="visibleColumnSpan">
|
|
45
|
+
<div class="gantt-spacer" :style="{ height: virtualTopSpacerHeight + 'px' }"></div>
|
|
46
|
+
</td>
|
|
47
|
+
</tr>
|
|
48
|
+
<tr v-for="row in virtualRows" :key="row.item.id" class="gantt-row"
|
|
49
|
+
:class="{ 'row-selected': selectedRowIds.has(row.item.id) }"
|
|
50
|
+
@click.stop="selectRow(row.item.id, $event)"
|
|
51
|
+
@contextmenu="handleContextMenu(row.item, $event)">
|
|
52
|
+
|
|
53
|
+
<!-- 动态渲染所有列 -->
|
|
54
|
+
<td v-for="col in visibleColumns" :key="col.configId"
|
|
55
|
+
:class="isNameColumn(col.configId) ? 'col-name' : 'col-date'"
|
|
56
|
+
:style="{ width: columnWidths[col.configId] + 'px' }" :title="getCellValue(row.item, col.configId)"
|
|
57
|
+
@dblclick.stop="handleCellDoubleClick(row.item, col)">
|
|
58
|
+
|
|
59
|
+
<!-- 名称列特殊处理:显示展开图标 -->
|
|
60
|
+
<template v-if="isNameColumn(col.configId)">
|
|
61
|
+
<div style="display: flex; align-items: center; gap: 4px;">
|
|
62
|
+
<span v-if="row.item.icon" class="expand-icon" @click.stop="toggleExpand(row.item)">
|
|
63
|
+
<img :src="getIcon('childIcons', row.item.icon)" alt="" style="width: 16px; height: 16px; display: block;" />
|
|
64
|
+
</span>
|
|
65
|
+
<!-- 编辑状态 -->
|
|
66
|
+
<input v-if="editingTextCell?.id === row.item.id && editingTextCell?.field === col.configId"
|
|
67
|
+
v-model="editingTextCell.value" type="text" class="cell-input" ref="textInputRef"
|
|
68
|
+
@blur="saveTextEdit(row.item, col.configId)" @keyup.enter="saveTextEdit(row.item, col.configId)"
|
|
69
|
+
@keyup.esc="cancelTextEdit" @click.stop style="flex: 1;" />
|
|
70
|
+
<!-- 显示状态 -->
|
|
71
|
+
<span v-else class="item-name">{{ getCellValue(row.item, col.configId) }}</span>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
75
|
+
<!-- 日期列:支持编辑 -->
|
|
76
|
+
<template v-else-if="isDateColumn(col.configId)">
|
|
77
|
+
<div v-if="editingCell?.id === row.item.id && editingCell?.field === col.configId" @click.stop>
|
|
78
|
+
<el-date-picker
|
|
79
|
+
v-model="editingCell.value"
|
|
80
|
+
type="datetime"
|
|
81
|
+
format="YYYY-MM-DD HH:mm:ss"
|
|
82
|
+
value-format="YYYY-MM-DD HH:mm:ss"
|
|
83
|
+
:teleported="true"
|
|
84
|
+
:editable="false"
|
|
85
|
+
:clearable="true"
|
|
86
|
+
ref="datePickerRef"
|
|
87
|
+
@change="onDateChange(row.item, col.configId, $event)"
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
<span v-else>{{ formatDate(getCellValue(row.item, col.configId)) }}</span>
|
|
91
|
+
</template>
|
|
92
|
+
|
|
93
|
+
<!-- 其他列:支持文本编辑 -->
|
|
94
|
+
<template v-else>
|
|
95
|
+
<!-- 编辑状态 -->
|
|
96
|
+
<input v-if="editingTextCell?.id === row.item.id && editingTextCell?.field === col.configId"
|
|
97
|
+
v-model="editingTextCell.value" type="text" class="cell-input" ref="textInputRef"
|
|
98
|
+
@blur="saveTextEdit(row.item, col.configId)" @keyup.enter="saveTextEdit(row.item, col.configId)"
|
|
99
|
+
@keyup.esc="cancelTextEdit" @click.stop />
|
|
100
|
+
<!-- 显示状态 -->
|
|
101
|
+
<span v-else>{{ getCellValue(row.item, col.configId) }}</span>
|
|
102
|
+
</template>
|
|
103
|
+
</td>
|
|
104
|
+
</tr>
|
|
105
|
+
<tr v-if="virtualBottomSpacerHeight > 0" class="gantt-spacer-row" aria-hidden="true">
|
|
106
|
+
<td :colspan="visibleColumnSpan">
|
|
107
|
+
<div class="gantt-spacer" :style="{ height: virtualBottomSpacerHeight + 'px' }"></div>
|
|
108
|
+
</td>
|
|
109
|
+
</tr>
|
|
110
|
+
</tbody>
|
|
111
|
+
</table>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<!-- 可拖拽分隔线 -->
|
|
115
|
+
<div class="gantt-divider" @mousedown="startResize" />
|
|
116
|
+
|
|
117
|
+
<!-- 右侧时间轴 -->
|
|
118
|
+
<div class="gantt-timeline" ref="timelineRef">
|
|
119
|
+
<div class="timeline-header" :style="{ minWidth: monthHeaders.length * monthWidth + 'px' }">
|
|
120
|
+
<!-- 年份行 -->
|
|
121
|
+
<div class="year-row">
|
|
122
|
+
<div v-for="year in yearHeaders" :key="year.year" class="year-cell" :style="{ width: year.width + 'px' }">
|
|
123
|
+
{{ year.year }}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
<!-- 月份行 -->
|
|
127
|
+
<div class="month-row">
|
|
128
|
+
<div v-for="(month, idx) in monthHeaders" :key="idx" class="month-cell" :style="{ width: monthWidth + 'px' }">
|
|
129
|
+
{{ month.label }}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<!-- 时间轴主体 -->
|
|
135
|
+
<div class="timeline-body" :style="{ width: monthHeaders.length * monthWidth + 'px' }">
|
|
136
|
+
<!-- 网格线 -->
|
|
137
|
+
<div class="grid-lines">
|
|
138
|
+
<div v-for="(month, idx) in monthHeaders" :key="idx" class="grid-line"
|
|
139
|
+
:style="{ left: (idx + 1) * monthWidth - 1 + 'px' }" />
|
|
140
|
+
</div>
|
|
141
|
+
<!-- 左右两侧共用同一组 spacer 和可视行,才能保持逐行对齐 -->
|
|
142
|
+
<div v-if="virtualTopSpacerHeight > 0" class="timeline-spacer" :style="{ height: virtualTopSpacerHeight + 'px' }" aria-hidden="true"></div>
|
|
143
|
+
<!-- 甘特条 -->
|
|
144
|
+
<div v-for="row in virtualRows" :key="row.item.id" class="bar-row"
|
|
145
|
+
:class="{ 'row-selected': selectedRowIds.has(row.item.id) }"
|
|
146
|
+
@click.stop="selectRow(row.item.id, $event)"
|
|
147
|
+
@contextmenu="handleContextMenu(row.item, $event)">
|
|
148
|
+
<div v-if="getItemStartDate(row.item) && getItemEndDate(row.item) && !row.item.isMilestone" class="gantt-bar"
|
|
149
|
+
:style="getBarStyle(row.item)"
|
|
150
|
+
:title="`${'目标'}: ${row.item.goal ?? ''}\n${'愿景'}: ${row.item.vision ?? ''}`" />
|
|
151
|
+
<div v-if="row.item.isMilestone" class="gantt-milestone" :style="getMilestoneStyle(row.item)"
|
|
152
|
+
:title="getItemName(row.item)" />
|
|
153
|
+
</div>
|
|
154
|
+
<div v-if="virtualBottomSpacerHeight > 0" class="timeline-spacer" :style="{ height: virtualBottomSpacerHeight + 'px' }" aria-hidden="true"></div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<!-- 右键菜单 -->
|
|
159
|
+
<GanttContextMenu
|
|
160
|
+
v-model:visible="showContextMenu"
|
|
161
|
+
:position="contextMenuPosition"
|
|
162
|
+
:can-move-up="canMoveUp"
|
|
163
|
+
:can-move-down="canMoveDown"
|
|
164
|
+
@property-config="handlePropertyConfig"
|
|
165
|
+
@tree-highlight="handleTreeHighlight"
|
|
166
|
+
@move-up="handleMoveUpFromMenu"
|
|
167
|
+
@move-down="handleMoveDownFromMenu"
|
|
168
|
+
@delete="handleDeleteFromMenu"
|
|
169
|
+
@remove="handleRemoveFromMenu"
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
</template>
|
|
173
|
+
|
|
174
|
+
<script setup lang="ts">
|
|
175
|
+
import { computed, ref, nextTick, watch, onMounted, onUnmounted } from 'vue'
|
|
176
|
+
import type { Shape, GanttData } from '../../types'
|
|
177
|
+
import { ElDatePicker } from 'element-plus'
|
|
178
|
+
import { eventBus } from '../../store/eventBus'
|
|
179
|
+
import GanttContextMenu from '../GanttContextMenu/GanttContextMenu.vue'
|
|
180
|
+
import { getIcon } from '../../utils/iconLoader'
|
|
181
|
+
import { useChartRowSelection, useVirtualScroll } from '../../hooks'
|
|
182
|
+
import { DateUtils } from '../../utils'
|
|
183
|
+
|
|
184
|
+
interface Props {
|
|
185
|
+
shape: Shape
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const props = defineProps<Props>()
|
|
189
|
+
|
|
190
|
+
const timelineRef = ref<HTMLDivElement>()
|
|
191
|
+
const searchInputRef = ref<HTMLInputElement>()
|
|
192
|
+
const leftTableRef = ref<HTMLDivElement>()
|
|
193
|
+
const tableWidth = ref(340)
|
|
194
|
+
const monthWidth = 80
|
|
195
|
+
|
|
196
|
+
// 搜索相关状态
|
|
197
|
+
const showSearchBar = ref(false)
|
|
198
|
+
const searchKeyword = ref('')
|
|
199
|
+
const searchMatchedIds = ref<string[]>([])
|
|
200
|
+
const currentMatchIndex = ref(-1)
|
|
201
|
+
|
|
202
|
+
// 容器样式
|
|
203
|
+
const containerStyle = computed(() => {
|
|
204
|
+
return {
|
|
205
|
+
width: '100%',
|
|
206
|
+
height: '100%',
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// 预定义的颜色数组(用于随机选择)
|
|
211
|
+
const ganttColors = [
|
|
212
|
+
'#409eff', // 蓝色
|
|
213
|
+
'#67c23a', // 绿色
|
|
214
|
+
'#e6a23c', // 橙色
|
|
215
|
+
'#f56c6c', // 红色
|
|
216
|
+
'#909399', // 灰色
|
|
217
|
+
'#5470c6', // 深蓝
|
|
218
|
+
'#91cc75', // 浅绿
|
|
219
|
+
'#fac858', // 黄色
|
|
220
|
+
'#ee6666', // 浅红
|
|
221
|
+
'#73c0de', // 青色
|
|
222
|
+
'#3ba272', // 青绿
|
|
223
|
+
'#fc8452', // 橙红
|
|
224
|
+
'#9a60b4', // 紫色
|
|
225
|
+
'#ea7ccc', // 粉色
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
// 根据 ID 生成一致的随机颜色(同一个 ID 总是返回相同的颜色)
|
|
229
|
+
function getColorForId(id: string): string {
|
|
230
|
+
let hash = 0
|
|
231
|
+
for (let i = 0; i < id.length; i++) {
|
|
232
|
+
hash = id.charCodeAt(i) + ((hash << 5) - hash)
|
|
233
|
+
}
|
|
234
|
+
const index = Math.abs(hash) % ganttColors.length
|
|
235
|
+
return ganttColors[index]
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 甘特图数据:使用后端数据
|
|
239
|
+
type GanttItem = GanttData & { hasChildren: boolean }
|
|
240
|
+
const ganttItems = ref<GanttItem[]>([])
|
|
241
|
+
|
|
242
|
+
// 默认列配置(仅作为后备,实际使用时会被后端数据覆盖)
|
|
243
|
+
const defaultColumns: any[] = [
|
|
244
|
+
{ configId: 'name', name: '名称', isShow: true, isReadonly: false },
|
|
245
|
+
{ configId: 'startDate', name: '开始时间', isShow: true, isReadonly: false },
|
|
246
|
+
{ configId: 'endDate', name: '结束时间', isShow: true, isReadonly: false },
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
// 列配置:优先使用后端数据
|
|
250
|
+
const columnConfig = ref<any[]>([...defaultColumns])
|
|
251
|
+
|
|
252
|
+
// 列宽度状态(动态调整,使用 configId 作为 key)
|
|
253
|
+
const columnWidths = ref<Record<string, number>>({})
|
|
254
|
+
|
|
255
|
+
// 根据 configId 获取默认列宽
|
|
256
|
+
function getDefaultColumnWidth(configId: string): number {
|
|
257
|
+
// 名称列默认宽一些
|
|
258
|
+
if (configId.includes('name') || configId.includes('Name')) {
|
|
259
|
+
return 140
|
|
260
|
+
}
|
|
261
|
+
// 日期列默认窄一些
|
|
262
|
+
if (configId.includes('Date') || configId.includes('date') || configId.includes('Time') || configId.includes('time')) {
|
|
263
|
+
return 90
|
|
264
|
+
}
|
|
265
|
+
// 其他列默认中等宽度
|
|
266
|
+
return 100
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 初始化默认列宽
|
|
270
|
+
defaultColumns.forEach(col => {
|
|
271
|
+
columnWidths.value[col.configId] = getDefaultColumnWidth(col.configId)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// 可见列
|
|
275
|
+
const visibleColumns = computed(() => {
|
|
276
|
+
return columnConfig.value.filter(col => col.isShow)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
// 切换列显示状态
|
|
280
|
+
function toggleColumnVisibility(configId: string) {
|
|
281
|
+
const colIndex = columnConfig.value.findIndex(c => c.configId === configId)
|
|
282
|
+
|
|
283
|
+
if (colIndex !== -1) {
|
|
284
|
+
const col = columnConfig.value[colIndex]
|
|
285
|
+
const newCol = { ...col, isShow: !col.isShow }
|
|
286
|
+
const newColumns = [...columnConfig.value]
|
|
287
|
+
newColumns[colIndex] = newCol
|
|
288
|
+
columnConfig.value = newColumns
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 监听真实数据变化,同步到 ganttItems 和 columnConfig
|
|
293
|
+
watch(() => props.shape.ganttData, (data) => {
|
|
294
|
+
if (data && data.length > 0) {
|
|
295
|
+
ganttItems.value = data.map(d => ({ ...d, hasChildren: false }))
|
|
296
|
+
} else {
|
|
297
|
+
ganttItems.value = []
|
|
298
|
+
}
|
|
299
|
+
}, { immediate: true })
|
|
300
|
+
|
|
301
|
+
watch(() => props.shape.attributeColumns, (columns) => {
|
|
302
|
+
if (columns && columns.length > 0) {
|
|
303
|
+
columnConfig.value = columns
|
|
304
|
+
|
|
305
|
+
// 初始化列宽(如果还没有设置)
|
|
306
|
+
columns.forEach((col: any) => {
|
|
307
|
+
if (!columnWidths.value[col.configId]) {
|
|
308
|
+
columnWidths.value[col.configId] = getDefaultColumnWidth(col.configId)
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
eventBus.emit('gantt-columns-from-backend', columns)
|
|
313
|
+
} else {
|
|
314
|
+
// 没有后端数据时使用默认配置
|
|
315
|
+
columnConfig.value = [...defaultColumns]
|
|
316
|
+
eventBus.emit('gantt-columns-from-backend', defaultColumns)
|
|
317
|
+
}
|
|
318
|
+
}, { immediate: true })
|
|
319
|
+
|
|
320
|
+
// 行选中状态(支持多选)
|
|
321
|
+
const {
|
|
322
|
+
canMoveDown,
|
|
323
|
+
canMoveUp,
|
|
324
|
+
clearSelection,
|
|
325
|
+
contextMenuPosition,
|
|
326
|
+
deleteRows,
|
|
327
|
+
handleContextMenu,
|
|
328
|
+
handleDeleteFromMenu,
|
|
329
|
+
handleMoveDownFromMenu,
|
|
330
|
+
handleMoveUpFromMenu,
|
|
331
|
+
handlePropertyConfig,
|
|
332
|
+
handleRemoveFromMenu,
|
|
333
|
+
handleTreeHighlight,
|
|
334
|
+
moveDown,
|
|
335
|
+
moveUp,
|
|
336
|
+
onRemoveSuccess,
|
|
337
|
+
removeRows,
|
|
338
|
+
selectLastRow,
|
|
339
|
+
selectRow,
|
|
340
|
+
selectedRowIds,
|
|
341
|
+
showContextMenu,
|
|
342
|
+
} = useChartRowSelection<GanttItem>({
|
|
343
|
+
items: ganttItems,
|
|
344
|
+
rowSelectedEvent: 'gantt-row-selected',
|
|
345
|
+
onBeforeSelect: () => {
|
|
346
|
+
closeDatePicker()
|
|
347
|
+
closeTextEdit()
|
|
348
|
+
},
|
|
349
|
+
onClearSelection: () => {
|
|
350
|
+
closeDatePicker()
|
|
351
|
+
closeTextEdit()
|
|
352
|
+
},
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
function onContainerClick() {
|
|
356
|
+
clearSelection()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 搜索结果文本
|
|
360
|
+
const searchResultText = computed(() => {
|
|
361
|
+
if (!searchKeyword.value) return ''
|
|
362
|
+
if (searchMatchedIds.value.length === 0) return '0 of 0'
|
|
363
|
+
return `${currentMatchIndex.value + 1} of ${searchMatchedIds.value.length}`
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
// 是否可以上一条/下一条
|
|
367
|
+
const canGoPrev = computed(() => searchMatchedIds.value.length > 0 && currentMatchIndex.value > 0)
|
|
368
|
+
const canGoNext = computed(() => searchMatchedIds.value.length > 0 && currentMatchIndex.value < searchMatchedIds.value.length - 1)
|
|
369
|
+
|
|
370
|
+
// 过滤后的甘特图数据(只显示匹配的)
|
|
371
|
+
const filteredGanttItems = computed(() => {
|
|
372
|
+
if (!searchKeyword.value || searchMatchedIds.value.length === 0) {
|
|
373
|
+
return ganttItems.value
|
|
374
|
+
}
|
|
375
|
+
return ganttItems.value.filter(item => searchMatchedIds.value.includes(item.id))
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
// 固定行高虚拟化:左右两侧只渲染同一段可视窗口,减少表格和甘特条的 DOM 数量。
|
|
379
|
+
// ========== 虚拟滚动 ==========
|
|
380
|
+
const GANTT_HEADER_HEIGHT = 56
|
|
381
|
+
const GANTT_ROW_HEIGHT = 32
|
|
382
|
+
const GANTT_VIRTUAL_OVERSCAN = 8
|
|
383
|
+
|
|
384
|
+
const {
|
|
385
|
+
virtualRows,
|
|
386
|
+
virtualTopSpacerHeight,
|
|
387
|
+
virtualBottomSpacerHeight,
|
|
388
|
+
virtualStartIndex,
|
|
389
|
+
virtualEndIndex,
|
|
390
|
+
syncVirtualViewportState,
|
|
391
|
+
visibleColumnSpan,
|
|
392
|
+
} = useVirtualScroll(filteredGanttItems, {
|
|
393
|
+
itemHeight: GANTT_ROW_HEIGHT,
|
|
394
|
+
headerHeight: GANTT_HEADER_HEIGHT,
|
|
395
|
+
overscan: GANTT_VIRTUAL_OVERSCAN,
|
|
396
|
+
containerRef: leftTableRef,
|
|
397
|
+
extraContainerRefs: [timelineRef],
|
|
398
|
+
columnCount: computed(() => visibleColumns.value.length),
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
// 搜索输入处理
|
|
402
|
+
function onSearchInput() {
|
|
403
|
+
const keyword = searchKeyword.value.trim().toLowerCase()
|
|
404
|
+
if (!keyword) {
|
|
405
|
+
searchMatchedIds.value = []
|
|
406
|
+
currentMatchIndex.value = -1
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 模糊匹配:搜索所有可见列的值
|
|
411
|
+
const matched = ganttItems.value.filter(item => {
|
|
412
|
+
return visibleColumns.value.some(col => {
|
|
413
|
+
const value = getCellValue(item, col.configId)
|
|
414
|
+
return value.toLowerCase().includes(keyword)
|
|
415
|
+
})
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
searchMatchedIds.value = matched.map(item => item.id)
|
|
419
|
+
currentMatchIndex.value = searchMatchedIds.value.length > 0 ? 0 : -1
|
|
420
|
+
|
|
421
|
+
// 自动选中第一个匹配项
|
|
422
|
+
if (currentMatchIndex.value >= 0) {
|
|
423
|
+
selectRow(searchMatchedIds.value[0])
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 上一条匹配
|
|
428
|
+
function gotoPrevMatch() {
|
|
429
|
+
if (!canGoPrev.value) return
|
|
430
|
+
currentMatchIndex.value--
|
|
431
|
+
selectRow(searchMatchedIds.value[currentMatchIndex.value])
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 下一条匹配
|
|
435
|
+
function gotoNextMatch() {
|
|
436
|
+
if (!canGoNext.value) return
|
|
437
|
+
currentMatchIndex.value++
|
|
438
|
+
selectRow(searchMatchedIds.value[currentMatchIndex.value])
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 打开搜索
|
|
442
|
+
function openSearch() {
|
|
443
|
+
showSearchBar.value = true
|
|
444
|
+
nextTick(() => {
|
|
445
|
+
searchInputRef.value?.focus()
|
|
446
|
+
scheduleGanttPaneBottomCompensation()
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// 关闭搜索
|
|
451
|
+
function closeSearch() {
|
|
452
|
+
showSearchBar.value = false
|
|
453
|
+
searchKeyword.value = ''
|
|
454
|
+
searchMatchedIds.value = []
|
|
455
|
+
currentMatchIndex.value = -1
|
|
456
|
+
nextTick(() => {
|
|
457
|
+
scheduleGanttPaneBottomCompensation()
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// 监听工具栏指令
|
|
462
|
+
function onGanttToolbarAction(action: string) {
|
|
463
|
+
if (action === 'ganttMoveUp') moveUp()
|
|
464
|
+
else if (action === 'ganttMoveDown') moveDown()
|
|
465
|
+
else if (action === 'ganttSearch') openSearch()
|
|
466
|
+
else if (action === 'ganttDelete') deleteRows()
|
|
467
|
+
else if (action === 'ganttRemove') removeRows()
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// 监听后端数据变化
|
|
471
|
+
function handleCanvasDataChange(data: any[]) {
|
|
472
|
+
if (data && data.length > 0) {
|
|
473
|
+
ganttItems.value = data.map(d => ({ ...d, hasChildren: false }))
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function handleCanvasColumnChange(columns: any[]) {
|
|
478
|
+
if (columns && columns.length > 0) {
|
|
479
|
+
columnConfig.value = columns
|
|
480
|
+
|
|
481
|
+
// 初始化列宽(如果还没有设置)
|
|
482
|
+
columns.forEach((col: any) => {
|
|
483
|
+
if (!columnWidths.value[col.configId]) {
|
|
484
|
+
columnWidths.value[col.configId] = getDefaultColumnWidth(col.configId)
|
|
485
|
+
}
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
// 同步到工具栏菜单
|
|
489
|
+
eventBus.emit('gantt-columns-from-backend', columns)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// // 处理编辑成功
|
|
494
|
+
// function handleEditSuccess(data: { itemId: string; configId: string; newValue: string }) {
|
|
495
|
+
// // 更新本地数据
|
|
496
|
+
// const item = ganttItems.value.find(i => i.id === data.itemId)
|
|
497
|
+
// if (item) {
|
|
498
|
+
// (item as any)[data.configId] = data.newValue
|
|
499
|
+
// }
|
|
500
|
+
// }
|
|
501
|
+
|
|
502
|
+
// // 处理编辑失败
|
|
503
|
+
// function handleEditFailure(data: { itemId: string; configId: string; oldValue: string }) {
|
|
504
|
+
// // 恢复原值
|
|
505
|
+
// const item = ganttItems.value.find(i => i.id === data.itemId)
|
|
506
|
+
// if (item) {
|
|
507
|
+
// (item as any)[data.configId] = data.oldValue
|
|
508
|
+
// }
|
|
509
|
+
// }
|
|
510
|
+
onMounted(() => {
|
|
511
|
+
eventBus.on('gantt-toolbar-action', onGanttToolbarAction)
|
|
512
|
+
eventBus.on('gantt-toggle-column', toggleColumnVisibility)
|
|
513
|
+
eventBus.on('canvas-data-change', handleCanvasDataChange)
|
|
514
|
+
eventBus.on('canvas-data-column', handleCanvasColumnChange)
|
|
515
|
+
//监听移除成功事件
|
|
516
|
+
eventBus.on('table-gantt-remove:success', onRemoveSuccess)
|
|
517
|
+
// 新增成功回执:直接选中最后一条
|
|
518
|
+
eventBus.on('table-gantt-add:success', selectLastRow)
|
|
519
|
+
// eventBus.on('gantt-cell-edit-success', handleEditSuccess)
|
|
520
|
+
// eventBus.on('gantt-cell-edit-failure', handleEditFailure)
|
|
521
|
+
|
|
522
|
+
// 同步左右滚动
|
|
523
|
+
setupScrollSync()
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
onUnmounted(() => {
|
|
527
|
+
eventBus.off('gantt-toolbar-action', onGanttToolbarAction)
|
|
528
|
+
eventBus.off('gantt-toggle-column', toggleColumnVisibility)
|
|
529
|
+
eventBus.off('canvas-data-change', handleCanvasDataChange)
|
|
530
|
+
eventBus.off('canvas-data-column', handleCanvasColumnChange)
|
|
531
|
+
eventBus.off('table-gantt-remove:success', onRemoveSuccess)
|
|
532
|
+
eventBus.off('table-gantt-add:success', selectLastRow)
|
|
533
|
+
// eventBus.off('gantt-cell-edit-success', handleEditSuccess)
|
|
534
|
+
// eventBus.off('gantt-cell-edit-failure', handleEditFailure)
|
|
535
|
+
|
|
536
|
+
// 清理滚动监听
|
|
537
|
+
cleanupScrollSync()
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
// 日期编辑状态
|
|
541
|
+
const editingCell = ref<{ id: string; field: string; value: string | null } | null>(null)
|
|
542
|
+
const datePickerRef = ref()
|
|
543
|
+
|
|
544
|
+
// 文本编辑状态
|
|
545
|
+
const editingTextCell = ref<{ id: string; field: string; value: string } | null>(null)
|
|
546
|
+
const textInputRef = ref<HTMLInputElement>()
|
|
547
|
+
|
|
548
|
+
// 根据 configId 获取单元格的值
|
|
549
|
+
function getCellValue(item: GanttData & { hasChildren: boolean }, configId: string): string {
|
|
550
|
+
const value = (item as any)[configId]
|
|
551
|
+
|
|
552
|
+
// 如果值是对象(如 Element.owner),返回其 nodeName
|
|
553
|
+
if (value && typeof value === 'object' && value.nodeName) {
|
|
554
|
+
return value.nodeName
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return value || ''
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// 获取行的名称(用于显示)
|
|
561
|
+
function getItemName(item: GanttData): string {
|
|
562
|
+
// 尝试从第一列获取名称
|
|
563
|
+
if (columnConfig.value.length > 0) {
|
|
564
|
+
return getCellValue(item as any, columnConfig.value[0].configId)
|
|
565
|
+
}
|
|
566
|
+
return item['NamedElement.name'] || item.id
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// 获取行的开始日期
|
|
570
|
+
function getItemStartDate(item: GanttData): string {
|
|
571
|
+
// 查找日期列的 configId
|
|
572
|
+
const dateCol = columnConfig.value.find(col =>
|
|
573
|
+
col.configId.toLowerCase().includes('startdate') ||
|
|
574
|
+
col.configId.toLowerCase().includes('start')
|
|
575
|
+
)
|
|
576
|
+
return dateCol ? (item[dateCol.configId] || '') : ''
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// 获取行的结束日期
|
|
580
|
+
function getItemEndDate(item: GanttData): string {
|
|
581
|
+
// 查找日期列的 configId
|
|
582
|
+
const dateCol = columnConfig.value.find(col =>
|
|
583
|
+
col.configId.toLowerCase().includes('enddate') ||
|
|
584
|
+
col.configId.toLowerCase().includes('end')
|
|
585
|
+
)
|
|
586
|
+
return dateCol ? (item[dateCol.configId] || '') : ''
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// 判断是否是日期列
|
|
590
|
+
function isDateColumn(configId: string): boolean {
|
|
591
|
+
// 通过 configId 判断是否包含日期相关关键字
|
|
592
|
+
const lowerConfigId = configId.toLowerCase()
|
|
593
|
+
return lowerConfigId.includes('date') || lowerConfigId.includes('time')
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 判断是否是名称列(第一列,显示展开图标)
|
|
597
|
+
function isNameColumn(configId: string): boolean {
|
|
598
|
+
// 判断是否是第一列
|
|
599
|
+
return columnConfig.value.length > 0 && columnConfig.value[0].configId === configId
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function openDatePicker(item: GanttData & { hasChildren: boolean }, field: string) {
|
|
603
|
+
editingCell.value = { id: item.id, field, value: getCellValue(item, field) || null }
|
|
604
|
+
nextTick(() => {
|
|
605
|
+
datePickerRef.value?.focus?.()
|
|
606
|
+
})
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function onDateChange(item: GanttData & { hasChildren: boolean }, field: string, val: string | null) {
|
|
610
|
+
if (!val) {
|
|
611
|
+
closeDatePicker()
|
|
612
|
+
return
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const oldValue = getCellValue(item, field)
|
|
616
|
+
|
|
617
|
+
// 值未改变,直接关闭编辑
|
|
618
|
+
if (oldValue === val) {
|
|
619
|
+
closeDatePicker()
|
|
620
|
+
return
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// 发送编辑事件到父组件
|
|
624
|
+
eventBus.emit('gantt-cell-edit', {
|
|
625
|
+
item,
|
|
626
|
+
configId: field,
|
|
627
|
+
oldValue,
|
|
628
|
+
newValue: val
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
closeDatePicker()
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function closeDatePicker() {
|
|
635
|
+
editingCell.value = null
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 文本编辑相关函数
|
|
639
|
+
function handleCellDoubleClick(item: GanttData & { hasChildren: boolean }, col: any) {
|
|
640
|
+
// 如果列是只读的,不允许编辑
|
|
641
|
+
if (col.isReadonly) return
|
|
642
|
+
|
|
643
|
+
// 先关闭之前的编辑
|
|
644
|
+
closeDatePicker()
|
|
645
|
+
closeTextEdit()
|
|
646
|
+
|
|
647
|
+
// 如果是日期列,打开日期选择器
|
|
648
|
+
if (isDateColumn(col.configId)) {
|
|
649
|
+
openDatePicker(item, col.configId)
|
|
650
|
+
} else {
|
|
651
|
+
// 否则打开文本编辑
|
|
652
|
+
openTextEdit(item, col.configId)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function openTextEdit(item: GanttData & { hasChildren: boolean }, field: string) {
|
|
657
|
+
editingTextCell.value = { id: item.id, field, value: getCellValue(item, field) }
|
|
658
|
+
nextTick(() => {
|
|
659
|
+
textInputRef.value?.focus()
|
|
660
|
+
textInputRef.value?.select()
|
|
661
|
+
})
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function saveTextEdit(item: GanttData & { hasChildren: boolean }, field: string) {
|
|
665
|
+
if (!editingTextCell.value || editingTextCell.value.value === undefined) {
|
|
666
|
+
editingTextCell.value = null
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const oldValue = getCellValue(item, field)
|
|
671
|
+
const newValue = editingTextCell.value.value
|
|
672
|
+
|
|
673
|
+
// 值未改变,直接关闭编辑
|
|
674
|
+
if (oldValue === newValue) {
|
|
675
|
+
editingTextCell.value = null
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// 发送编辑事件到父组件
|
|
680
|
+
eventBus.emit('gantt-cell-edit', {
|
|
681
|
+
item,
|
|
682
|
+
configId: field,
|
|
683
|
+
oldValue,
|
|
684
|
+
newValue
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
// 暂时关闭编辑状态(等待接口返回)
|
|
688
|
+
editingTextCell.value = null
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function cancelTextEdit() {
|
|
692
|
+
editingTextCell.value = null
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function closeTextEdit() {
|
|
696
|
+
// 如果有正在编辑的文本,先保存
|
|
697
|
+
if (editingTextCell.value) {
|
|
698
|
+
const item = ganttItems.value.find(i => i.id === editingTextCell.value!.id)
|
|
699
|
+
if (item) {
|
|
700
|
+
saveTextEdit(item, editingTextCell.value.field)
|
|
701
|
+
} else {
|
|
702
|
+
editingTextCell.value = null
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
watch([virtualStartIndex, virtualEndIndex], () => {
|
|
708
|
+
const renderedIds = new Set(virtualRows.value.map(({ item }) => item.id))
|
|
709
|
+
|
|
710
|
+
if (editingCell.value && !renderedIds.has(editingCell.value.id)) {
|
|
711
|
+
closeDatePicker()
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (editingTextCell.value && !renderedIds.has(editingTextCell.value.id)) {
|
|
715
|
+
closeTextEdit()
|
|
716
|
+
}
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
// 计算时间范围
|
|
720
|
+
const timeRange = computed(() => {
|
|
721
|
+
const items = ganttItems.value
|
|
722
|
+
if (items.length === 0) {
|
|
723
|
+
const now = new Date()
|
|
724
|
+
return { start: new Date(now.getFullYear(), 0, 1), end: new Date(now.getFullYear() + 1, 0, 1) }
|
|
725
|
+
}
|
|
726
|
+
let minDate = Infinity
|
|
727
|
+
let maxDate = -Infinity
|
|
728
|
+
for (const item of items) {
|
|
729
|
+
const startDate = getItemStartDate(item)
|
|
730
|
+
const endDate = getItemEndDate(item)
|
|
731
|
+
if (startDate) {
|
|
732
|
+
const timestamp = DateUtils.getTimestamp(startDate)
|
|
733
|
+
if (timestamp && timestamp < minDate) minDate = timestamp
|
|
734
|
+
}
|
|
735
|
+
if (endDate) {
|
|
736
|
+
const timestamp = DateUtils.getTimestamp(endDate)
|
|
737
|
+
if (timestamp && timestamp > maxDate) maxDate = timestamp
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// 如果没有任何有效日期,显示当前年份
|
|
742
|
+
if (minDate === Infinity && maxDate === -Infinity) {
|
|
743
|
+
const now = new Date()
|
|
744
|
+
return { start: new Date(now.getFullYear(), 0, 1), end: new Date(now.getFullYear() + 1, 0, 1) }
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// 如果只有开始日期,没有结束日期,则结束日期设为开始日期后一年
|
|
748
|
+
if (minDate !== Infinity && maxDate === -Infinity) {
|
|
749
|
+
maxDate = new Date(minDate).setFullYear(new Date(minDate).getFullYear() + 1)
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// 如果只有结束日期,没有开始日期,则开始日期设为结束日期前一年
|
|
753
|
+
if (minDate === Infinity && maxDate !== -Infinity) {
|
|
754
|
+
minDate = new Date(maxDate).setFullYear(new Date(maxDate).getFullYear() - 1)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// 获取最早和最晚日期
|
|
758
|
+
const minDateObj = new Date(minDate)
|
|
759
|
+
const maxDateObj = new Date(maxDate)
|
|
760
|
+
|
|
761
|
+
// 开始时间:向前推 2 个月,但不早于该年的 1 月
|
|
762
|
+
const startMonth = Math.max(0, minDateObj.getMonth() - 2)
|
|
763
|
+
const start = new Date(minDateObj.getFullYear(), startMonth, 1)
|
|
764
|
+
|
|
765
|
+
// 结束时间:向后推,确保至少显示 12 个月
|
|
766
|
+
const monthsDiff = (maxDateObj.getFullYear() - start.getFullYear()) * 12 +
|
|
767
|
+
(maxDateObj.getMonth() - start.getMonth())
|
|
768
|
+
|
|
769
|
+
let endMonth = maxDateObj.getMonth() + 6 // 默认向后推 6 个月
|
|
770
|
+
let endYear = maxDateObj.getFullYear()
|
|
771
|
+
|
|
772
|
+
// 如果总月数少于 12 个月,扩展到至少 12 个月
|
|
773
|
+
if (monthsDiff < 12) {
|
|
774
|
+
endMonth = start.getMonth() + 12
|
|
775
|
+
endYear = start.getFullYear()
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// 处理月份溢出
|
|
779
|
+
while (endMonth >= 12) {
|
|
780
|
+
endMonth -= 12
|
|
781
|
+
endYear += 1
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const end = new Date(endYear, endMonth, 1)
|
|
785
|
+
return { start, end }
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
// 月份表头
|
|
789
|
+
const monthHeaders = computed(() => {
|
|
790
|
+
const headers: { year: number; month: number; label: string }[] = []
|
|
791
|
+
const monthLabels = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
|
|
792
|
+
const { start, end } = timeRange.value
|
|
793
|
+
const cur = new Date(start)
|
|
794
|
+
while (cur < end) {
|
|
795
|
+
headers.push({ year: cur.getFullYear(), month: cur.getMonth(), label: monthLabels[cur.getMonth()] })
|
|
796
|
+
cur.setMonth(cur.getMonth() + 1)
|
|
797
|
+
}
|
|
798
|
+
return headers
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
// 年份表头
|
|
802
|
+
const yearHeaders = computed(() => {
|
|
803
|
+
const years: { year: number; width: number }[] = []
|
|
804
|
+
const months = monthHeaders.value
|
|
805
|
+
if (months.length === 0) return years
|
|
806
|
+
let currentYear = months[0].year
|
|
807
|
+
let count = 0
|
|
808
|
+
for (const m of months) {
|
|
809
|
+
if (m.year !== currentYear) {
|
|
810
|
+
years.push({ year: currentYear, width: count * monthWidth })
|
|
811
|
+
currentYear = m.year
|
|
812
|
+
count = 0
|
|
813
|
+
}
|
|
814
|
+
count++
|
|
815
|
+
}
|
|
816
|
+
years.push({ year: currentYear, width: count * monthWidth })
|
|
817
|
+
return years
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
// 日期格式化(使用 DateUtils)
|
|
821
|
+
function formatDate(dateStr: string): string {
|
|
822
|
+
return DateUtils.format(dateStr, 'datetime')
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// 计算甘特条位置
|
|
826
|
+
function getBarStyle(item: GanttData) {
|
|
827
|
+
const { start } = timeRange.value
|
|
828
|
+
const itemStartDate = getItemStartDate(item)
|
|
829
|
+
const itemEndDate = getItemEndDate(item)
|
|
830
|
+
if (!itemStartDate || !itemEndDate) return {}
|
|
831
|
+
|
|
832
|
+
const startDate = DateUtils.parse(itemStartDate)
|
|
833
|
+
const endDate = DateUtils.parse(itemEndDate)
|
|
834
|
+
if (!startDate || !endDate) return {}
|
|
835
|
+
|
|
836
|
+
// 计算开始位置:从时间轴起点到任务开始的月数
|
|
837
|
+
const startMonthOffset = getMonthOffset(start, startDate)
|
|
838
|
+
const left = startMonthOffset * monthWidth
|
|
839
|
+
|
|
840
|
+
// 计算宽度:任务持续的月数
|
|
841
|
+
const durationMonths = getMonthOffset(startDate, endDate)
|
|
842
|
+
const width = Math.max(durationMonths * monthWidth, 4)
|
|
843
|
+
|
|
844
|
+
// 使用后端提供的颜色,如果没有则根据 ID 生成随机颜色
|
|
845
|
+
const bgColor = item.ganttStyle?.backgroundColor || getColorForId(item.id)
|
|
846
|
+
|
|
847
|
+
return { left: `${left}px`, width: `${width}px`, backgroundColor: bgColor }
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// 里程碑样式
|
|
851
|
+
function getMilestoneStyle(item: GanttData) {
|
|
852
|
+
const { start } = timeRange.value
|
|
853
|
+
const itemStartDate = getItemStartDate(item)
|
|
854
|
+
if (!itemStartDate) return {}
|
|
855
|
+
|
|
856
|
+
// 计算位置:从时间轴起点到里程碑的月数
|
|
857
|
+
const startDate = DateUtils.parse(itemStartDate)
|
|
858
|
+
if (!startDate) return {}
|
|
859
|
+
|
|
860
|
+
const startMonthOffset = getMonthOffset(start, startDate)
|
|
861
|
+
const left = startMonthOffset * monthWidth
|
|
862
|
+
|
|
863
|
+
return { left: `${left}px` }
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// 计算两个日期之间的月份偏移量(对齐到月份边界)
|
|
867
|
+
function getMonthOffset(startDate: Date, endDate: Date): number {
|
|
868
|
+
const startYear = startDate.getFullYear()
|
|
869
|
+
const startMonth = startDate.getMonth()
|
|
870
|
+
const startDay = startDate.getDate()
|
|
871
|
+
|
|
872
|
+
const endYear = endDate.getFullYear()
|
|
873
|
+
const endMonth = endDate.getMonth()
|
|
874
|
+
const endDay = endDate.getDate()
|
|
875
|
+
|
|
876
|
+
// 计算完整的月份差
|
|
877
|
+
const monthsDiff = (endYear - startYear) * 12 + (endMonth - startMonth)
|
|
878
|
+
|
|
879
|
+
// 如果是同一个月
|
|
880
|
+
if (monthsDiff === 0) {
|
|
881
|
+
const monthDays = new Date(startYear, startMonth + 1, 0).getDate()
|
|
882
|
+
// 特殊情况:整月(1号到月末)
|
|
883
|
+
if (startDay === 1 && endDay === monthDays) {
|
|
884
|
+
return 1
|
|
885
|
+
}
|
|
886
|
+
// 从1号开始
|
|
887
|
+
if (startDay === 1) {
|
|
888
|
+
return endDay / monthDays
|
|
889
|
+
}
|
|
890
|
+
// 其他情况
|
|
891
|
+
return (endDay - startDay + 1) / monthDays
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// 跨月情况
|
|
895
|
+
const startMonthDays = new Date(startYear, startMonth + 1, 0).getDate()
|
|
896
|
+
const endMonthDays = new Date(endYear, endMonth + 1, 0).getDate()
|
|
897
|
+
|
|
898
|
+
// 起始月:如果是1号,从月初开始(0偏移);否则计算已过的天数
|
|
899
|
+
const startOffset = startDay === 1 ? 0 : (startDay - 1) / startMonthDays
|
|
900
|
+
|
|
901
|
+
// 结束月:如果是月末,到月末(1.0);如果是1号,到月初(0);否则计算已过的天数
|
|
902
|
+
let endOffset = 0
|
|
903
|
+
if (endDay === endMonthDays) {
|
|
904
|
+
endOffset = 1
|
|
905
|
+
} else if (endDay === 1) {
|
|
906
|
+
endOffset = 0
|
|
907
|
+
} else {
|
|
908
|
+
endOffset = endDay / endMonthDays
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// 总偏移 = 完整月份 - 起始偏移 + 结束偏移
|
|
912
|
+
return monthsDiff - startOffset + endOffset
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function toggleExpand(item: GanttData & { hasChildren: boolean }) {
|
|
916
|
+
item.isExpand = !item.isExpand
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// 分隔线拖拽
|
|
920
|
+
function startResize(e: MouseEvent) {
|
|
921
|
+
const startX = e.clientX
|
|
922
|
+
const startWidth = tableWidth.value
|
|
923
|
+
const onMove = (ev: MouseEvent) => {
|
|
924
|
+
tableWidth.value = Math.max(180, Math.min(500, startWidth + ev.clientX - startX))
|
|
925
|
+
}
|
|
926
|
+
const onUp = () => {
|
|
927
|
+
document.removeEventListener('mousemove', onMove)
|
|
928
|
+
document.removeEventListener('mouseup', onUp)
|
|
929
|
+
}
|
|
930
|
+
document.addEventListener('mousemove', onMove)
|
|
931
|
+
document.addEventListener('mouseup', onUp)
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// 列宽调整
|
|
935
|
+
function startColumnResize(columnKey: string, e: MouseEvent) {
|
|
936
|
+
e.stopPropagation()
|
|
937
|
+
e.preventDefault()
|
|
938
|
+
|
|
939
|
+
const startX = e.clientX
|
|
940
|
+
const startWidth = columnWidths.value[columnKey]
|
|
941
|
+
|
|
942
|
+
const onMove = (ev: MouseEvent) => {
|
|
943
|
+
const delta = ev.clientX - startX
|
|
944
|
+
const newWidth = Math.max(60, Math.min(300, startWidth + delta))
|
|
945
|
+
columnWidths.value[columnKey] = newWidth
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const onUp = () => {
|
|
949
|
+
document.removeEventListener('mousemove', onMove)
|
|
950
|
+
document.removeEventListener('mouseup', onUp)
|
|
951
|
+
document.body.style.cursor = ''
|
|
952
|
+
document.body.style.userSelect = ''
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
document.body.style.cursor = 'col-resize'
|
|
956
|
+
document.body.style.userSelect = 'none'
|
|
957
|
+
document.addEventListener('mousemove', onMove)
|
|
958
|
+
document.addEventListener('mouseup', onUp)
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// 滚动同步相关
|
|
962
|
+
let isLeftScrolling = false
|
|
963
|
+
let isRightScrolling = false
|
|
964
|
+
let leftScrollHandler: ((e: Event) => void) | null = null
|
|
965
|
+
let rightScrollHandler: ((e: Event) => void) | null = null
|
|
966
|
+
let paneResizeObserver: ResizeObserver | null = null
|
|
967
|
+
let paneResizeHandler: (() => void) | null = null
|
|
968
|
+
let paneCompensationRafId: number | null = null
|
|
969
|
+
|
|
970
|
+
function getHorizontalScrollbarHeight(el: HTMLElement) {
|
|
971
|
+
return Math.max(0, el.offsetHeight - el.clientHeight)
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function updateGanttPaneBottomCompensation() {
|
|
975
|
+
const leftEl = leftTableRef.value
|
|
976
|
+
const rightEl = timelineRef.value
|
|
977
|
+
|
|
978
|
+
if (!leftEl || !rightEl) return
|
|
979
|
+
|
|
980
|
+
const leftScrollbarHeight = leftEl.scrollWidth > leftEl.clientWidth + 1
|
|
981
|
+
? getHorizontalScrollbarHeight(leftEl)
|
|
982
|
+
: 0
|
|
983
|
+
const rightScrollbarHeight = rightEl.scrollWidth > rightEl.clientWidth + 1
|
|
984
|
+
? getHorizontalScrollbarHeight(rightEl)
|
|
985
|
+
: 0
|
|
986
|
+
const targetScrollbarHeight = Math.max(leftScrollbarHeight, rightScrollbarHeight)
|
|
987
|
+
|
|
988
|
+
leftEl.style.setProperty(
|
|
989
|
+
'--gantt-bottom-compensation',
|
|
990
|
+
`${Math.max(0, targetScrollbarHeight - leftScrollbarHeight)}px`
|
|
991
|
+
)
|
|
992
|
+
rightEl.style.setProperty(
|
|
993
|
+
'--gantt-bottom-compensation',
|
|
994
|
+
`${Math.max(0, targetScrollbarHeight - rightScrollbarHeight)}px`
|
|
995
|
+
)
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function scheduleGanttPaneBottomCompensation() {
|
|
999
|
+
if (paneCompensationRafId !== null) {
|
|
1000
|
+
cancelAnimationFrame(paneCompensationRafId)
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
paneCompensationRafId = requestAnimationFrame(() => {
|
|
1004
|
+
paneCompensationRafId = null
|
|
1005
|
+
updateGanttPaneBottomCompensation()
|
|
1006
|
+
})
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function setupScrollSync() {
|
|
1010
|
+
const leftEl = leftTableRef.value
|
|
1011
|
+
const rightEl = timelineRef.value
|
|
1012
|
+
|
|
1013
|
+
if (!leftEl || !rightEl) return
|
|
1014
|
+
|
|
1015
|
+
// 左侧滚动时同步到右侧
|
|
1016
|
+
leftScrollHandler = () => {
|
|
1017
|
+
if (isRightScrolling) return
|
|
1018
|
+
isLeftScrolling = true
|
|
1019
|
+
rightEl.scrollTop = leftEl.scrollTop
|
|
1020
|
+
// 同步后立刻刷新虚拟窗口,避免 scrollTop 变了但渲染行还没切换。
|
|
1021
|
+
syncVirtualViewportState(leftEl)
|
|
1022
|
+
requestAnimationFrame(() => {
|
|
1023
|
+
isLeftScrolling = false
|
|
1024
|
+
})
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// 右侧滚动时同步到左侧
|
|
1028
|
+
rightScrollHandler = () => {
|
|
1029
|
+
if (isLeftScrolling) return
|
|
1030
|
+
isRightScrolling = true
|
|
1031
|
+
leftEl.scrollTop = rightEl.scrollTop
|
|
1032
|
+
syncVirtualViewportState(rightEl)
|
|
1033
|
+
requestAnimationFrame(() => {
|
|
1034
|
+
isRightScrolling = false
|
|
1035
|
+
})
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
leftEl.addEventListener('scroll', leftScrollHandler)
|
|
1039
|
+
rightEl.addEventListener('scroll', rightScrollHandler)
|
|
1040
|
+
|
|
1041
|
+
paneResizeHandler = () => {
|
|
1042
|
+
syncVirtualViewportState()
|
|
1043
|
+
scheduleGanttPaneBottomCompensation()
|
|
1044
|
+
}
|
|
1045
|
+
window.addEventListener('resize', paneResizeHandler)
|
|
1046
|
+
|
|
1047
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
1048
|
+
paneResizeObserver = new ResizeObserver(paneResizeHandler)
|
|
1049
|
+
paneResizeObserver.observe(leftEl)
|
|
1050
|
+
paneResizeObserver.observe(rightEl)
|
|
1051
|
+
|
|
1052
|
+
const leftTable = leftEl.querySelector('.gantt-table')
|
|
1053
|
+
const timelineBody = rightEl.querySelector('.timeline-body')
|
|
1054
|
+
if (leftTable) paneResizeObserver.observe(leftTable)
|
|
1055
|
+
if (timelineBody) paneResizeObserver.observe(timelineBody)
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
syncVirtualViewportState(leftEl)
|
|
1059
|
+
scheduleGanttPaneBottomCompensation()
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function cleanupScrollSync() {
|
|
1063
|
+
const leftEl = leftTableRef.value
|
|
1064
|
+
const rightEl = timelineRef.value
|
|
1065
|
+
|
|
1066
|
+
if (leftScrollHandler && leftEl) {
|
|
1067
|
+
leftEl.removeEventListener('scroll', leftScrollHandler)
|
|
1068
|
+
}
|
|
1069
|
+
if (rightScrollHandler && rightEl) {
|
|
1070
|
+
rightEl.removeEventListener('scroll', rightScrollHandler)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if (paneResizeHandler) {
|
|
1074
|
+
window.removeEventListener('resize', paneResizeHandler)
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
paneResizeObserver?.disconnect()
|
|
1078
|
+
|
|
1079
|
+
if (paneCompensationRafId !== null) {
|
|
1080
|
+
cancelAnimationFrame(paneCompensationRafId)
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
leftEl?.style.removeProperty('--gantt-bottom-compensation')
|
|
1084
|
+
rightEl?.style.removeProperty('--gantt-bottom-compensation')
|
|
1085
|
+
|
|
1086
|
+
leftScrollHandler = null
|
|
1087
|
+
rightScrollHandler = null
|
|
1088
|
+
paneResizeObserver = null
|
|
1089
|
+
paneResizeHandler = null
|
|
1090
|
+
paneCompensationRafId = null
|
|
1091
|
+
}
|
|
1092
|
+
</script>
|
|
1093
|
+
|
|
1094
|
+
<style scoped>
|
|
1095
|
+
/* 统一行高变量 */
|
|
1096
|
+
.gantt-container {
|
|
1097
|
+
--header-row-h: 28px;
|
|
1098
|
+
/* 右侧年份行、月份行各自的高度 */
|
|
1099
|
+
--data-row-h: 32px;
|
|
1100
|
+
/* 数据行高度(左右一致) */
|
|
1101
|
+
|
|
1102
|
+
display: flex;
|
|
1103
|
+
box-sizing: border-box;
|
|
1104
|
+
border: 1px solid #dcdfe6;
|
|
1105
|
+
background: #fff;
|
|
1106
|
+
overflow: hidden;
|
|
1107
|
+
font-size: 12px;
|
|
1108
|
+
font-family: 'Source Han Sans SC', 'Microsoft YaHei', 'PingFang SC', Arial, sans-serif;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/* ========== 搜索栏 ========== */
|
|
1112
|
+
.gantt-search-bar {
|
|
1113
|
+
position: absolute;
|
|
1114
|
+
top: 0;
|
|
1115
|
+
left: 0;
|
|
1116
|
+
right: 0;
|
|
1117
|
+
height: 36px;
|
|
1118
|
+
background: #f5f7fa;
|
|
1119
|
+
border-bottom: 1px solid #dcdfe6;
|
|
1120
|
+
display: flex;
|
|
1121
|
+
align-items: center;
|
|
1122
|
+
padding: 0 12px;
|
|
1123
|
+
gap: 8px;
|
|
1124
|
+
z-index: 10;
|
|
1125
|
+
box-sizing: border-box;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
.search-input {
|
|
1129
|
+
flex: 1;
|
|
1130
|
+
height: 24px;
|
|
1131
|
+
padding: 0 8px;
|
|
1132
|
+
border: 1px solid #dcdfe6;
|
|
1133
|
+
border-radius: 3px;
|
|
1134
|
+
font-size: 12px;
|
|
1135
|
+
outline: none;
|
|
1136
|
+
transition: border-color 0.2s;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
.search-input:focus {
|
|
1140
|
+
border-color: #409eff;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
.search-result-info {
|
|
1144
|
+
font-size: 12px;
|
|
1145
|
+
color: #606266;
|
|
1146
|
+
white-space: nowrap;
|
|
1147
|
+
min-width: 60px;
|
|
1148
|
+
text-align: center;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
.search-nav-btn {
|
|
1152
|
+
width: 24px;
|
|
1153
|
+
height: 24px;
|
|
1154
|
+
border: 1px solid #dcdfe6;
|
|
1155
|
+
border-radius: 3px;
|
|
1156
|
+
background: #fff;
|
|
1157
|
+
cursor: pointer;
|
|
1158
|
+
display: flex;
|
|
1159
|
+
align-items: center;
|
|
1160
|
+
justify-content: center;
|
|
1161
|
+
color: #606266;
|
|
1162
|
+
transition: all 0.2s;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
.search-nav-btn:hover:not(:disabled) {
|
|
1166
|
+
border-color: #409eff;
|
|
1167
|
+
color: #409eff;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
.search-nav-btn:disabled {
|
|
1171
|
+
cursor: not-allowed;
|
|
1172
|
+
opacity: 0.4;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
.search-close-btn {
|
|
1176
|
+
width: 24px;
|
|
1177
|
+
height: 24px;
|
|
1178
|
+
border: 1px solid #dcdfe6;
|
|
1179
|
+
border-radius: 3px;
|
|
1180
|
+
background: #fff;
|
|
1181
|
+
cursor: pointer;
|
|
1182
|
+
font-size: 18px;
|
|
1183
|
+
line-height: 1;
|
|
1184
|
+
color: #909399;
|
|
1185
|
+
transition: all 0.2s;
|
|
1186
|
+
display: flex;
|
|
1187
|
+
align-items: center;
|
|
1188
|
+
justify-content: center;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
.search-close-btn:hover {
|
|
1192
|
+
border-color: #f56c6c;
|
|
1193
|
+
color: #f56c6c;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/* ========== 左侧面板 ========== */
|
|
1197
|
+
.gantt-left {
|
|
1198
|
+
width: v-bind("tableWidth + 'px'");
|
|
1199
|
+
min-width: 180px;
|
|
1200
|
+
flex-shrink: 0;
|
|
1201
|
+
border-right: 1px solid #dcdfe6;
|
|
1202
|
+
overflow-x: auto;
|
|
1203
|
+
overflow-y: auto;
|
|
1204
|
+
margin-top: v-bind("showSearchBar ? '36px' : '0'");
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/* 隐藏左侧的垂直滚动条 */
|
|
1208
|
+
.gantt-left::-webkit-scrollbar {
|
|
1209
|
+
width: 0;
|
|
1210
|
+
height: 8px;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
.gantt-left::-webkit-scrollbar-track {
|
|
1214
|
+
background: #f1f1f1;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
.gantt-left::-webkit-scrollbar-thumb {
|
|
1218
|
+
background: #c1c1c1;
|
|
1219
|
+
border-radius: 4px;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
.gantt-left::-webkit-scrollbar-thumb:hover {
|
|
1223
|
+
background: #a8a8a8;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
.gantt-table {
|
|
1227
|
+
width: max-content;
|
|
1228
|
+
min-width: 100%;
|
|
1229
|
+
border-collapse: separate;
|
|
1230
|
+
border-spacing: 0;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/* 所有 th/td 统一 box-sizing,用 border-bottom + border-right 模拟表格线 */
|
|
1234
|
+
.gantt-table th,
|
|
1235
|
+
.gantt-table td {
|
|
1236
|
+
box-sizing: border-box;
|
|
1237
|
+
border: none;
|
|
1238
|
+
border-bottom: 1px solid #ebeef5;
|
|
1239
|
+
border-right: 1px solid #ebeef5;
|
|
1240
|
+
padding: 0 8px;
|
|
1241
|
+
text-align: left;
|
|
1242
|
+
white-space: nowrap;
|
|
1243
|
+
overflow: hidden;
|
|
1244
|
+
text-overflow: ellipsis;
|
|
1245
|
+
vertical-align: middle;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/* 每行第一个单元格加左边框 */
|
|
1249
|
+
.gantt-table th:first-child,
|
|
1250
|
+
.gantt-table td:first-child {
|
|
1251
|
+
border-left: 1px solid #ebeef5;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/* 表头第1行:空行,对应右侧年份行 */
|
|
1255
|
+
.header-row-1 th {
|
|
1256
|
+
height: var(--header-row-h);
|
|
1257
|
+
background: #f5f7fa;
|
|
1258
|
+
position: sticky;
|
|
1259
|
+
top: 0;
|
|
1260
|
+
z-index: 1;
|
|
1261
|
+
border-top: 1px solid #ebeef5;
|
|
1262
|
+
border-bottom-color: #dcdfe6;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/* 表头第2行:列标题,对应右侧月份行 */
|
|
1266
|
+
.header-row-2 th {
|
|
1267
|
+
height: var(--header-row-h);
|
|
1268
|
+
background: #f5f7fa;
|
|
1269
|
+
font-weight: 500;
|
|
1270
|
+
color: #606266;
|
|
1271
|
+
position: sticky;
|
|
1272
|
+
top: var(--header-row-h);
|
|
1273
|
+
z-index: 1;
|
|
1274
|
+
border-bottom-color: #dcdfe6;
|
|
1275
|
+
padding: 0;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
.th-content {
|
|
1279
|
+
position: relative;
|
|
1280
|
+
width: 100%;
|
|
1281
|
+
height: 100%;
|
|
1282
|
+
display: flex;
|
|
1283
|
+
align-items: center;
|
|
1284
|
+
padding: 0 8px;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
.th-label {
|
|
1288
|
+
flex: 1;
|
|
1289
|
+
overflow: hidden;
|
|
1290
|
+
text-overflow: ellipsis;
|
|
1291
|
+
white-space: nowrap;
|
|
1292
|
+
font-weight: 600;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
.col-resizer {
|
|
1296
|
+
position: absolute;
|
|
1297
|
+
right: 0;
|
|
1298
|
+
top: 0;
|
|
1299
|
+
bottom: 0;
|
|
1300
|
+
width: 5px;
|
|
1301
|
+
cursor: col-resize;
|
|
1302
|
+
user-select: none;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
.col-resizer:hover {
|
|
1306
|
+
background-color: rgba(64, 158, 255, 0.1);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/* 数据行 */
|
|
1310
|
+
.gantt-table td {
|
|
1311
|
+
height: var(--data-row-h);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
.gantt-spacer-row td {
|
|
1315
|
+
height: 0;
|
|
1316
|
+
padding: 0;
|
|
1317
|
+
border: none !important;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/* spacer 只负责撑高度,不参与真实内容布局 */
|
|
1321
|
+
.gantt-spacer {
|
|
1322
|
+
width: 1px;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/* 当行包含日期选择器时,允许溢出 */
|
|
1326
|
+
.gantt-row:has(.col-date > div) {
|
|
1327
|
+
position: relative;
|
|
1328
|
+
z-index: 10;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
.col-name {
|
|
1332
|
+
max-width: none;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
.col-date {
|
|
1336
|
+
width: auto;
|
|
1337
|
+
font-size: 11px;
|
|
1338
|
+
color: #909399;
|
|
1339
|
+
text-align: center;
|
|
1340
|
+
position: relative;
|
|
1341
|
+
cursor: default;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/* 当日期列包含日期选择器时,允许溢出 */
|
|
1345
|
+
.col-date:has(div) {
|
|
1346
|
+
overflow: visible !important;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/* 日期选择器容器 */
|
|
1350
|
+
.col-date>div {
|
|
1351
|
+
position: relative;
|
|
1352
|
+
z-index: 100;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/* 日期选择器内嵌样式 */
|
|
1356
|
+
.col-date :deep(.el-date-editor) {
|
|
1357
|
+
width: 100% !important;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
.col-date :deep(.el-input__wrapper) {
|
|
1361
|
+
padding: 0 4px;
|
|
1362
|
+
height: 26px;
|
|
1363
|
+
font-size: 11px;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/* 日期选择器弹出层 */
|
|
1367
|
+
.col-date :deep(.el-picker__popper) {
|
|
1368
|
+
z-index: 1000 !important;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
.expand-icon {
|
|
1372
|
+
cursor: pointer;
|
|
1373
|
+
user-select: none;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
.item-name {
|
|
1377
|
+
overflow: hidden;
|
|
1378
|
+
text-overflow: ellipsis;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/* 单元格输入框样式 */
|
|
1382
|
+
.cell-input {
|
|
1383
|
+
width: 100%;
|
|
1384
|
+
height: 24px;
|
|
1385
|
+
padding: 0 4px;
|
|
1386
|
+
border: 1px solid #409eff;
|
|
1387
|
+
border-radius: 2px;
|
|
1388
|
+
font-size: 12px;
|
|
1389
|
+
outline: none;
|
|
1390
|
+
box-sizing: border-box;
|
|
1391
|
+
background: #fff;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
.cell-input:focus {
|
|
1395
|
+
border-color: #409eff;
|
|
1396
|
+
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/* ========== 分隔线 ========== */
|
|
1400
|
+
.gantt-divider {
|
|
1401
|
+
width: 4px;
|
|
1402
|
+
cursor: col-resize;
|
|
1403
|
+
background: #dcdfe6;
|
|
1404
|
+
flex-shrink: 0;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
.gantt-divider:hover {
|
|
1408
|
+
background: #409eff;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/* ========== 右侧时间轴 ========== */
|
|
1412
|
+
.gantt-timeline {
|
|
1413
|
+
flex: 1;
|
|
1414
|
+
overflow: auto;
|
|
1415
|
+
position: relative;
|
|
1416
|
+
margin-top: v-bind("showSearchBar ? '36px' : '0'");
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
.gantt-left,
|
|
1420
|
+
.gantt-timeline {
|
|
1421
|
+
padding-bottom: var(--gantt-bottom-compensation, 0px);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/* 右侧显示滚动条 */
|
|
1425
|
+
.gantt-timeline::-webkit-scrollbar {
|
|
1426
|
+
width: 8px;
|
|
1427
|
+
height: 8px;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
.gantt-timeline::-webkit-scrollbar-track {
|
|
1431
|
+
background: #f1f1f1;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
.gantt-timeline::-webkit-scrollbar-thumb {
|
|
1435
|
+
background: #c1c1c1;
|
|
1436
|
+
border-radius: 4px;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
.gantt-timeline::-webkit-scrollbar-thumb:hover {
|
|
1440
|
+
background: #a8a8a8;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
.timeline-header {
|
|
1444
|
+
position: sticky;
|
|
1445
|
+
top: 0;
|
|
1446
|
+
z-index: 2;
|
|
1447
|
+
background: #f5f7fa;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
.year-row {
|
|
1451
|
+
display: flex;
|
|
1452
|
+
height: var(--header-row-h);
|
|
1453
|
+
border-bottom: 1px solid #dcdfe6;
|
|
1454
|
+
box-sizing: border-box;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
.year-cell {
|
|
1458
|
+
text-align: center;
|
|
1459
|
+
font-weight: 500;
|
|
1460
|
+
line-height: calc(var(--header-row-h) - 1px);
|
|
1461
|
+
color: #303133;
|
|
1462
|
+
border-right: 1px solid #ebeef5;
|
|
1463
|
+
flex-shrink: 0;
|
|
1464
|
+
box-sizing: border-box;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
.month-row {
|
|
1468
|
+
display: flex;
|
|
1469
|
+
height: var(--header-row-h);
|
|
1470
|
+
border-bottom: 1px solid #dcdfe6;
|
|
1471
|
+
box-sizing: border-box;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
.month-cell {
|
|
1475
|
+
text-align: center;
|
|
1476
|
+
line-height: calc(var(--header-row-h) - 1px);
|
|
1477
|
+
color: #606266;
|
|
1478
|
+
border-right: 1px solid #ebeef5;
|
|
1479
|
+
flex-shrink: 0;
|
|
1480
|
+
box-sizing: border-box;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/* ========== 甘特条区域 ========== */
|
|
1484
|
+
.timeline-body {
|
|
1485
|
+
position: relative;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
.timeline-spacer {
|
|
1489
|
+
width: 100%;
|
|
1490
|
+
pointer-events: none;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
.grid-lines {
|
|
1494
|
+
position: absolute;
|
|
1495
|
+
top: 0;
|
|
1496
|
+
bottom: 0;
|
|
1497
|
+
left: 0;
|
|
1498
|
+
right: 0;
|
|
1499
|
+
pointer-events: none;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
.grid-line {
|
|
1503
|
+
position: absolute;
|
|
1504
|
+
top: 0;
|
|
1505
|
+
bottom: 0;
|
|
1506
|
+
width: 1px;
|
|
1507
|
+
border-left: 1px dashed #e4e7ed;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
.bar-row {
|
|
1511
|
+
height: var(--data-row-h);
|
|
1512
|
+
position: relative;
|
|
1513
|
+
border-bottom: 1px solid #ebeef5;
|
|
1514
|
+
box-sizing: border-box;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
.gantt-bar {
|
|
1518
|
+
position: absolute;
|
|
1519
|
+
top: 6px;
|
|
1520
|
+
height: 20px;
|
|
1521
|
+
border-radius: 3px;
|
|
1522
|
+
background: #409eff;
|
|
1523
|
+
opacity: 0.85;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/* 行选中效果(左右两侧共用) */
|
|
1527
|
+
.row-selected {
|
|
1528
|
+
background-color: #ecf5ff !important;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
.gantt-row.row-selected td {
|
|
1532
|
+
background-color: #ecf5ff;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
.gantt-milestone {
|
|
1536
|
+
position: absolute;
|
|
1537
|
+
top: 8px;
|
|
1538
|
+
width: 14px;
|
|
1539
|
+
height: 14px;
|
|
1540
|
+
background: #e6a23c;
|
|
1541
|
+
transform: rotate(45deg);
|
|
1542
|
+
margin-left: -7px;
|
|
1543
|
+
}
|
|
1544
|
+
</style>
|