@mx-sose-front/mx-sose-graph 1.1.7 → 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.
Files changed (52) hide show
  1. package/dist/assets/edgeWorker-b57ca007.js +2 -0
  2. package/dist/assets/edgeWorker-b57ca007.js.map +1 -0
  3. package/dist/index.d.ts +633 -30
  4. package/dist/index.esm.js +8728 -4734
  5. package/dist/index.esm.js.map +1 -1
  6. package/dist/index.umd.js +1 -1
  7. package/dist/index.umd.js.map +1 -1
  8. package/dist/style.css +1 -1
  9. package/package.json +1 -1
  10. package/src/components/Common/Tree.vue +451 -0
  11. package/src/components/Common/index.ts +2 -0
  12. package/src/components/DiagramListTooltip/DiagramListTooltip.vue +1 -2
  13. package/src/components/Edge/Edge.vue +172 -169
  14. package/src/components/Gantt/Gantt.vue +1544 -0
  15. package/src/components/GanttContextMenu/GanttContextMenu.vue +304 -0
  16. package/src/components/InteractionLayer.vue +343 -147
  17. package/src/components/Matrix/Matrix.vue +828 -0
  18. package/src/components/Matrix/index.ts +168 -0
  19. package/src/components/Shape/ConceptualRole.vue +2 -34
  20. package/src/components/Table/Table.vue +970 -0
  21. package/src/constants/edgeShapeKeys.ts +8 -5
  22. package/src/constants/index.ts +259 -45
  23. package/src/hooks/index.ts +2 -0
  24. package/src/hooks/useChartRowSelection.ts +456 -0
  25. package/src/hooks/useResize.ts +2 -2
  26. package/src/hooks/useVirtualScroll.ts +258 -0
  27. package/src/index.ts +1 -1
  28. package/src/render/shape-renderer.ts +62 -2
  29. package/src/statics/icons/childIcons//345/221/275/344/273/244@3x.png +0 -0
  30. package/src/statics/icons/childIcons//346/210/230/347/225/245/346/246/202/345/277/265/350/241/250@3x.png +0 -0
  31. package/src/statics/icons/childIcons//346/216/247/345/210/266@3x.png +0 -0
  32. package/src/statics/icons/createMenu/down.png +0 -0
  33. package/src/statics/icons/createMenu/remove.png +0 -0
  34. package/src/statics/icons/createMenu/up.png +0 -0
  35. package/src/store/graphStore.ts +217 -44
  36. package/src/types/index.ts +86 -4
  37. package/src/utils/batchAutoExpand.ts +9 -10
  38. package/src/utils/containers.ts +72 -17
  39. package/src/utils/contextMenuUtils.ts +7 -7
  40. package/src/utils/dateUtils.ts +160 -0
  41. package/src/utils/diagram.ts +10 -8
  42. package/src/utils/drag.ts +6 -5
  43. package/src/utils/edgeUtils.ts +344 -427
  44. package/src/utils/edgeWorker.ts +471 -0
  45. package/src/utils/hittest.ts +37 -38
  46. package/src/utils/index.ts +3 -0
  47. package/src/utils/keyboardUtils.ts +5 -5
  48. package/src/utils/packageOutline.ts +96 -0
  49. package/src/utils/rafThrottle.ts +162 -0
  50. package/src/utils/workerManager.ts +335 -0
  51. package/src/view/graph.vue +47 -33
  52. /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>