diff --git a/src/components/TaskList.vue b/src/components/TaskList.vue index e19d978..695483e 100644 --- a/src/components/TaskList.vue +++ b/src/components/TaskList.vue @@ -35,12 +35,20 @@ const { t } = useI18n() // TaskList 容器引用 const taskListRef = ref(null) +const taskListBodyRef = ref(null) // 缓存容器宽度,避免频繁读取 offsetWidth 导致强制重排 const cachedContainerWidth = ref(0) // 使用 ResizeObserver 监听容器宽度变化 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 }) => { @@ -242,6 +250,61 @@ const getAllTasks = (taskList: Task[]): Task[] => { 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 数据变化 watch( () => props.tasks, @@ -345,16 +408,23 @@ const handleTaskListScroll = (event: Event) => { const scrollTop = target.scrollTop + taskListScrollTop.value = scrollTop + // 同步垂直滚动到Timeline window.dispatchEvent( new CustomEvent('task-list-vertical-scroll', { detail: { scrollTop }, }), ) -} // 处理Timeline垂直滚动同步 +} + +// 处理Timeline垂直滚动同步 const handleTimelineVerticalScroll = (event: CustomEvent) => { 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) { // 使用更精确的比较,避免1px以内的细微差异导致的循环触发 taskListBodyElement.scrollTop = scrollTop @@ -372,6 +442,7 @@ const handleTaskRowContextMenu = (event: { task: Task; position: { x: number; y: }), ) } catch (error) { + // eslint-disable-next-line no-console console.error('TaskList - Failed to dispatch context-menu event', error) } } @@ -417,6 +488,20 @@ onMounted(async () => { 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-added', handleTaskAdded as EventListener) window.addEventListener('request-task-list', handleRequestTaskList as EventListener) @@ -438,6 +523,11 @@ onUnmounted(() => { containerResizeObserver = null } + if (bodyResizeObserver) { + bodyResizeObserver.disconnect() + bodyResizeObserver = null + } + window.removeEventListener('task-updated', handleTaskUpdated as EventListener) window.removeEventListener('task-added', handleTaskAdded as EventListener) window.removeEventListener('request-task-list', handleRequestTaskList as EventListener) @@ -470,17 +560,20 @@ onUnmounted(() => { {{ (t as any)[column.key] || column.label }} -
- +
+ + { + +
@@ -546,14 +641,18 @@ onUnmounted(() => { width: max-content; background: var(--gantt-bg-primary); flex: 1; - overflow-x: hidden; /* 让body部分可以滚动 */ - overflow-y: auto; /* 允许垂直滚动 */ + overflow-x: hidden; /* 允许横向滚动,确保列完整展示 */ + overflow-y: auto; /* Webkit浏览器滚动条样式 */ scrollbar-width: thin; scrollbar-color: var(--gantt-scrollbar-thumb) transparent; } +.task-list-body-spacer { + width: 100%; +} + .task-list-body::-webkit-scrollbar { width: 8px; height: 8px; diff --git a/src/components/TaskRow.vue b/src/components/TaskRow.vue index dbc2918..a7e8567 100644 --- a/src/components/TaskRow.vue +++ b/src/components/TaskRow.vue @@ -36,6 +36,7 @@ interface Props { onHover?: (taskId: number | null) => void columns: TaskListColumnConfig[] getColumnWidthStyle?: (column: { width?: number | string }) => object + disableChildrenRender?: boolean } const props = defineProps() const emit = defineEmits([ @@ -468,7 +469,7 @@ onUnmounted(() => { -