v1.4.3 - 垂直虚拟加载优化
This commit is contained in:
+107
-8
@@ -35,12 +35,20 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
// TaskList 容器引用
|
// TaskList 容器引用
|
||||||
const taskListRef = ref<HTMLElement | null>(null)
|
const taskListRef = ref<HTMLElement | null>(null)
|
||||||
|
const taskListBodyRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// 缓存容器宽度,避免频繁读取 offsetWidth 导致强制重排
|
// 缓存容器宽度,避免频繁读取 offsetWidth 导致强制重排
|
||||||
const cachedContainerWidth = ref(0)
|
const cachedContainerWidth = ref(0)
|
||||||
|
|
||||||
// 使用 ResizeObserver 监听容器宽度变化
|
// 使用 ResizeObserver 监听容器宽度变化
|
||||||
let containerResizeObserver: ResizeObserver | null = null
|
let containerResizeObserver: ResizeObserver | null = null
|
||||||
|
let bodyResizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
// 纵向虚拟滚动相关状态
|
||||||
|
const ROW_HEIGHT = 51 // 每行高度(与TaskList Row一致)
|
||||||
|
const VERTICAL_BUFFER = 5 // 上下额外渲染的缓冲行数
|
||||||
|
const taskListScrollTop = ref(0)
|
||||||
|
const taskListBodyHeight = ref(0)
|
||||||
|
|
||||||
// 获取列宽度样式(百分比转像素)
|
// 获取列宽度样式(百分比转像素)
|
||||||
const getColumnWidthStyle = (column: { width?: number | string }) => {
|
const getColumnWidthStyle = (column: { width?: number | string }) => {
|
||||||
@@ -242,6 +250,61 @@ const getAllTasks = (taskList: Task[]): Task[] => {
|
|||||||
return allTasks
|
return allTasks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取当前折叠状态下的可见任务列表
|
||||||
|
const getFlattenedVisibleTasks = (
|
||||||
|
taskList: Task[],
|
||||||
|
level = 0,
|
||||||
|
): Array<{ task: Task; level: number }> => {
|
||||||
|
const result: Array<{ task: Task; level: number }> = []
|
||||||
|
|
||||||
|
for (const task of taskList) {
|
||||||
|
result.push({ task, level })
|
||||||
|
|
||||||
|
const isMilestoneGroup = task.type === 'milestone-group'
|
||||||
|
|
||||||
|
if (!isMilestoneGroup && task.children && task.children.length > 0 && !task.collapsed) {
|
||||||
|
result.push(...getFlattenedVisibleTasks(task.children, level + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扁平化后的可见任务列表
|
||||||
|
const flattenedTasks = computed(() => getFlattenedVisibleTasks(localTasks.value))
|
||||||
|
|
||||||
|
// 计算可视区域任务范围
|
||||||
|
const visibleTaskRange = computed(() => {
|
||||||
|
const scrollTop = taskListScrollTop.value
|
||||||
|
const containerHeight = taskListBodyHeight.value || 600
|
||||||
|
|
||||||
|
const startIndex = Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER
|
||||||
|
const endIndex = Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER
|
||||||
|
|
||||||
|
const total = flattenedTasks.value.length
|
||||||
|
const clampedStart = Math.min(Math.max(0, startIndex), total)
|
||||||
|
const clampedEnd = Math.min(total, Math.max(clampedStart + 1, endIndex))
|
||||||
|
|
||||||
|
return {
|
||||||
|
startIndex: clampedStart,
|
||||||
|
endIndex: clampedEnd,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 虚拟列表中需要渲染的任务
|
||||||
|
const visibleTasks = computed(() => {
|
||||||
|
const { startIndex, endIndex } = visibleTaskRange.value
|
||||||
|
return flattenedTasks.value.slice(startIndex, endIndex)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Spacer 高度用于撑起滚动区域
|
||||||
|
const totalContentHeight = computed(() => flattenedTasks.value.length * ROW_HEIGHT)
|
||||||
|
const startSpacerHeight = computed(() => visibleTaskRange.value.startIndex * ROW_HEIGHT)
|
||||||
|
const endSpacerHeight = computed(() => {
|
||||||
|
const visibleHeight = visibleTasks.value.length * ROW_HEIGHT
|
||||||
|
return Math.max(0, totalContentHeight.value - startSpacerHeight.value - visibleHeight)
|
||||||
|
})
|
||||||
|
|
||||||
// 监听外部传入的 tasks 数据变化
|
// 监听外部传入的 tasks 数据变化
|
||||||
watch(
|
watch(
|
||||||
() => props.tasks,
|
() => props.tasks,
|
||||||
@@ -345,16 +408,23 @@ const handleTaskListScroll = (event: Event) => {
|
|||||||
|
|
||||||
const scrollTop = target.scrollTop
|
const scrollTop = target.scrollTop
|
||||||
|
|
||||||
|
taskListScrollTop.value = scrollTop
|
||||||
|
|
||||||
// 同步垂直滚动到Timeline
|
// 同步垂直滚动到Timeline
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('task-list-vertical-scroll', {
|
new CustomEvent('task-list-vertical-scroll', {
|
||||||
detail: { scrollTop },
|
detail: { scrollTop },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} // 处理Timeline垂直滚动同步
|
}
|
||||||
|
|
||||||
|
// 处理Timeline垂直滚动同步
|
||||||
const handleTimelineVerticalScroll = (event: CustomEvent) => {
|
const handleTimelineVerticalScroll = (event: CustomEvent) => {
|
||||||
const { scrollTop } = event.detail
|
const { scrollTop } = event.detail
|
||||||
const taskListBodyElement = document.querySelector('.task-list-body') as HTMLElement
|
const taskListBodyElement = taskListBodyRef.value
|
||||||
|
|
||||||
|
taskListScrollTop.value = scrollTop
|
||||||
|
|
||||||
if (taskListBodyElement && Math.abs(taskListBodyElement.scrollTop - scrollTop) > 1) {
|
if (taskListBodyElement && Math.abs(taskListBodyElement.scrollTop - scrollTop) > 1) {
|
||||||
// 使用更精确的比较,避免1px以内的细微差异导致的循环触发
|
// 使用更精确的比较,避免1px以内的细微差异导致的循环触发
|
||||||
taskListBodyElement.scrollTop = scrollTop
|
taskListBodyElement.scrollTop = scrollTop
|
||||||
@@ -372,6 +442,7 @@ const handleTaskRowContextMenu = (event: { task: Task; position: { x: number; y:
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
console.error('TaskList - Failed to dispatch context-menu event', error)
|
console.error('TaskList - Failed to dispatch context-menu event', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,6 +488,20 @@ onMounted(async () => {
|
|||||||
containerResizeObserver.observe(taskListRef.value)
|
containerResizeObserver.observe(taskListRef.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听TaskList body高度变化,提供虚拟滚动所需尺寸
|
||||||
|
if (taskListBodyRef.value) {
|
||||||
|
taskListBodyHeight.value = taskListBodyRef.value.clientHeight
|
||||||
|
taskListScrollTop.value = taskListBodyRef.value.scrollTop
|
||||||
|
|
||||||
|
bodyResizeObserver = new ResizeObserver(entries => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
taskListBodyHeight.value = entry.contentRect.height
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
bodyResizeObserver.observe(taskListBodyRef.value)
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('task-updated', handleTaskUpdated as EventListener)
|
window.addEventListener('task-updated', handleTaskUpdated as EventListener)
|
||||||
window.addEventListener('task-added', handleTaskAdded as EventListener)
|
window.addEventListener('task-added', handleTaskAdded as EventListener)
|
||||||
window.addEventListener('request-task-list', handleRequestTaskList as EventListener)
|
window.addEventListener('request-task-list', handleRequestTaskList as EventListener)
|
||||||
@@ -438,6 +523,11 @@ onUnmounted(() => {
|
|||||||
containerResizeObserver = null
|
containerResizeObserver = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bodyResizeObserver) {
|
||||||
|
bodyResizeObserver.disconnect()
|
||||||
|
bodyResizeObserver = null
|
||||||
|
}
|
||||||
|
|
||||||
window.removeEventListener('task-updated', handleTaskUpdated as EventListener)
|
window.removeEventListener('task-updated', handleTaskUpdated as EventListener)
|
||||||
window.removeEventListener('task-added', handleTaskAdded as EventListener)
|
window.removeEventListener('task-added', handleTaskAdded as EventListener)
|
||||||
window.removeEventListener('request-task-list', handleRequestTaskList as EventListener)
|
window.removeEventListener('request-task-list', handleRequestTaskList as EventListener)
|
||||||
@@ -470,17 +560,20 @@ onUnmounted(() => {
|
|||||||
{{ (t as any)[column.key] || column.label }}
|
{{ (t as any)[column.key] || column.label }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-list-body" @scroll="handleTaskListScroll">
|
<div ref="taskListBodyRef" class="task-list-body" @scroll="handleTaskListScroll">
|
||||||
<TaskRow
|
<div class="task-list-body-spacer" :style="{ height: `${startSpacerHeight}px` }"></div>
|
||||||
v-for="task in localTasks"
|
|
||||||
|
<TaskRow
|
||||||
|
v-for="{ task, level } in visibleTasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
:task="task"
|
:task="task"
|
||||||
:level="0"
|
:level="level"
|
||||||
:is-hovered="hoveredTaskId === task.id"
|
:is-hovered="hoveredTaskId === task.id"
|
||||||
:hovered-task-id="hoveredTaskId"
|
:hovered-task-id="hoveredTaskId"
|
||||||
:on-hover="handleTaskRowHover"
|
:on-hover="handleTaskRowHover"
|
||||||
:columns="visibleColumns"
|
:columns="visibleColumns"
|
||||||
:get-column-width-style="getColumnWidthStyle"
|
:get-column-width-style="getColumnWidthStyle"
|
||||||
|
:disable-children-render="true"
|
||||||
@toggle="toggleCollapse"
|
@toggle="toggleCollapse"
|
||||||
@dblclick="handleTaskRowDoubleClick"
|
@dblclick="handleTaskRowDoubleClick"
|
||||||
@contextmenu="handleTaskRowContextMenu"
|
@contextmenu="handleTaskRowContextMenu"
|
||||||
@@ -494,6 +587,8 @@ onUnmounted(() => {
|
|||||||
<slot name="custom-task-content" v-bind="rowScope" />
|
<slot name="custom-task-content" v-bind="rowScope" />
|
||||||
</template>
|
</template>
|
||||||
</TaskRow>
|
</TaskRow>
|
||||||
|
|
||||||
|
<div class="task-list-body-spacer" :style="{ height: `${endSpacerHeight}px` }"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -546,14 +641,18 @@ onUnmounted(() => {
|
|||||||
width: max-content;
|
width: max-content;
|
||||||
background: var(--gantt-bg-primary);
|
background: var(--gantt-bg-primary);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-x: hidden; /* 让body部分可以滚动 */
|
overflow-x: hidden; /* 允许横向滚动,确保列完整展示 */
|
||||||
overflow-y: auto; /* 允许垂直滚动 */
|
overflow-y: auto;
|
||||||
|
|
||||||
/* Webkit浏览器滚动条样式 */
|
/* Webkit浏览器滚动条样式 */
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
|
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-list-body-spacer {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.task-list-body::-webkit-scrollbar {
|
.task-list-body::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface Props {
|
|||||||
onHover?: (taskId: number | null) => void
|
onHover?: (taskId: number | null) => void
|
||||||
columns: TaskListColumnConfig[]
|
columns: TaskListColumnConfig[]
|
||||||
getColumnWidthStyle?: (column: { width?: number | string }) => object
|
getColumnWidthStyle?: (column: { width?: number | string }) => object
|
||||||
|
disableChildrenRender?: boolean
|
||||||
}
|
}
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -468,7 +469,7 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="hasChildren && !props.task.collapsed && !isMilestoneGroup">
|
<template v-if="!props.disableChildrenRender && hasChildren && !props.task.collapsed && !isMilestoneGroup">
|
||||||
<TaskRow
|
<TaskRow
|
||||||
v-for="child in props.task.children"
|
v-for="child in props.task.children"
|
||||||
:key="child.id"
|
:key="child.id"
|
||||||
@@ -479,6 +480,7 @@ onUnmounted(() => {
|
|||||||
:on-hover="props.onHover"
|
:on-hover="props.onHover"
|
||||||
:columns="props.columns"
|
:columns="props.columns"
|
||||||
:get-column-width-style="props.getColumnWidthStyle"
|
:get-column-width-style="props.getColumnWidthStyle"
|
||||||
|
:disable-children-render="props.disableChildrenRender"
|
||||||
@toggle="emit('toggle', $event)"
|
@toggle="emit('toggle', $event)"
|
||||||
@dblclick="emit('dblclick', $event)"
|
@dblclick="emit('dblclick', $event)"
|
||||||
@start-timer="emit('start-timer', $event)"
|
@start-timer="emit('start-timer', $event)"
|
||||||
|
|||||||
@@ -657,6 +657,11 @@ let hideBubblesTimeout: number | null = null // 半圆显示恢复定时器
|
|||||||
const HOUR_WIDTH = 40 // 每小时40px
|
const HOUR_WIDTH = 40 // 每小时40px
|
||||||
const VIRTUAL_BUFFER = 10 // 减少缓冲区以提升滑动性能
|
const VIRTUAL_BUFFER = 10 // 减少缓冲区以提升滑动性能
|
||||||
|
|
||||||
|
// 纵向虚拟滚动相关状态
|
||||||
|
const ROW_HEIGHT = 51 // 每行高度51px (50px + 1px border)
|
||||||
|
const VERTICAL_BUFFER = 5 // 纵向缓冲区行数
|
||||||
|
const timelineBodyScrollTop = ref(0) // 纵向滚动位置
|
||||||
|
|
||||||
// 数据缓存
|
// 数据缓存
|
||||||
const timelineDataCache = new Map<string, unknown>()
|
const timelineDataCache = new Map<string, unknown>()
|
||||||
|
|
||||||
@@ -701,6 +706,30 @@ const visibleHourRange = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 计算纵向可视区域的任务范围
|
||||||
|
const visibleTaskRange = computed(() => {
|
||||||
|
const scrollTop = timelineBodyScrollTop.value
|
||||||
|
const containerHeight = timelineBodyHeight.value || 600
|
||||||
|
|
||||||
|
// 计算可视区域的开始和结束任务索引
|
||||||
|
const startIndex = Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER
|
||||||
|
const endIndex = Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER
|
||||||
|
|
||||||
|
return {
|
||||||
|
startIndex: Math.max(0, startIndex),
|
||||||
|
endIndex: Math.min(tasks.value.length, Math.max(startIndex + 1, endIndex)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取虚拟滚动优化后的可见任务列表
|
||||||
|
const visibleTasks = computed(() => {
|
||||||
|
const { startIndex, endIndex } = visibleTaskRange.value
|
||||||
|
return tasks.value.slice(startIndex, endIndex).map((task, index) => ({
|
||||||
|
task,
|
||||||
|
originalIndex: startIndex + index,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
// 防抖处理滚动事件(优化:增加防抖时间)
|
// 防抖处理滚动事件(优化:增加防抖时间)
|
||||||
const debounce = <T extends (...args: unknown[]) => void>(func: T, wait: number): T => {
|
const debounce = <T extends (...args: unknown[]) => void>(func: T, wait: number): T => {
|
||||||
let timeout: number | null = null
|
let timeout: number | null = null
|
||||||
@@ -724,6 +753,11 @@ const debouncedUpdateCanvasPosition = debounce(() => {
|
|||||||
updateSvgSize() // 重新计算 Canvas 位置和尺寸
|
updateSvgSize() // 重新计算 Canvas 位置和尺寸
|
||||||
}, 50)
|
}, 50)
|
||||||
|
|
||||||
|
// 防抖更新纵向滚动位置
|
||||||
|
const debouncedUpdateVerticalScroll = debounce((scrollTop: number) => {
|
||||||
|
timelineBodyScrollTop.value = scrollTop
|
||||||
|
}, 16) // 使用16ms约等于60fps,保证流畅性
|
||||||
|
|
||||||
// 缓存时间轴数据的函数
|
// 缓存时间轴数据的函数
|
||||||
const getCachedTimelineData = (): unknown => {
|
const getCachedTimelineData = (): unknown => {
|
||||||
const scale = currentTimeScale.value
|
const scale = currentTimeScale.value
|
||||||
@@ -2162,6 +2196,10 @@ onMounted(() => {
|
|||||||
// 处理TaskList垂直滚动同步
|
// 处理TaskList垂直滚动同步
|
||||||
const handleTaskListVerticalScroll = (event: CustomEvent) => {
|
const handleTaskListVerticalScroll = (event: CustomEvent) => {
|
||||||
const { scrollTop } = event.detail
|
const { scrollTop } = event.detail
|
||||||
|
|
||||||
|
// 立即更新纵向滚动位置(用于虚拟滚动计算)
|
||||||
|
timelineBodyScrollTop.value = scrollTop
|
||||||
|
|
||||||
if (timelineBodyElement.value && Math.abs(timelineBodyElement.value.scrollTop - scrollTop) > 1) {
|
if (timelineBodyElement.value && Math.abs(timelineBodyElement.value.scrollTop - scrollTop) > 1) {
|
||||||
// 使用更精确的比较,避免1px以内的细微差异导致的循环触发
|
// 使用更精确的比较,避免1px以内的细微差异导致的循环触发
|
||||||
timelineBodyElement.value.scrollTop = scrollTop
|
timelineBodyElement.value.scrollTop = scrollTop
|
||||||
@@ -2175,6 +2213,9 @@ const handleTimelineBodyScroll = (event: Event) => {
|
|||||||
|
|
||||||
const scrollTop = target.scrollTop
|
const scrollTop = target.scrollTop
|
||||||
|
|
||||||
|
// 立即更新纵向滚动位置(用于虚拟滚动计算)
|
||||||
|
timelineBodyScrollTop.value = scrollTop
|
||||||
|
|
||||||
// 拖拽时不同步滚动事件,避免性能问题
|
// 拖拽时不同步滚动事件,避免性能问题
|
||||||
if (isDragging.value) return
|
if (isDragging.value) return
|
||||||
|
|
||||||
@@ -3510,13 +3551,13 @@ const handleAddSuccessor = (task: Task) => {
|
|||||||
<!-- 同时需要考虑左侧TaskList包含1px的bottom border -->
|
<!-- 同时需要考虑左侧TaskList包含1px的bottom border -->
|
||||||
<div class="task-bar-container" :style="{ height: `${contentHeight}px` }">
|
<div class="task-bar-container" :style="{ height: `${contentHeight}px` }">
|
||||||
<div class="task-rows" :style="{ height: `${contentHeight}px` }">
|
<div class="task-rows" :style="{ height: `${contentHeight}px` }">
|
||||||
<!-- 使用v-memo减少渲染 -->
|
<!-- 使用虚拟滚动渲染可见任务 -->
|
||||||
<div
|
<div
|
||||||
v-for="(task, index) in tasks"
|
v-for="{ task, originalIndex } in visibleTasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
class="task-row"
|
class="task-row"
|
||||||
:class="{ 'task-row-hovered': hoveredTaskId === task.id }"
|
:class="{ 'task-row-hovered': hoveredTaskId === task.id }"
|
||||||
:style="{ top: `${index * 51}px` }"
|
:style="{ top: `${originalIndex * 51}px` }"
|
||||||
@mouseenter="handleTaskRowHover(task.id)"
|
@mouseenter="handleTaskRowHover(task.id)"
|
||||||
@mouseleave="handleTaskRowHover(null)"
|
@mouseleave="handleTaskRowHover(null)"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user