Merge remote-tracking branch 'src'
This commit is contained in:
@@ -24,6 +24,7 @@ interface Props {
|
||||
width: number
|
||||
height: number
|
||||
offsetLeft?: number // Canvas 在全局坐标系中的偏移量(用于虚拟渲染)
|
||||
offsetTop?: number // Canvas 在垂直方向的偏移量(用于虚拟渲染)
|
||||
highlightedTaskId: number | null
|
||||
highlightedTaskIds: Set<number>
|
||||
hoveredTaskId: number | null
|
||||
@@ -36,6 +37,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
verticalLines: () => [],
|
||||
showVerticalLines: true,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
})
|
||||
|
||||
// Canvas 引用
|
||||
@@ -124,6 +126,8 @@ const drawLinks = () => {
|
||||
// 虚拟渲染:计算 Canvas 覆盖的范围
|
||||
const canvasStartX = props.offsetLeft
|
||||
const canvasEndX = props.offsetLeft + displayWidth
|
||||
const canvasStartY = props.offsetTop
|
||||
const canvasEndY = props.offsetTop + displayHeight
|
||||
|
||||
// 定义线条数据类型
|
||||
interface LineData {
|
||||
@@ -157,16 +161,6 @@ const drawLinks = () => {
|
||||
continue
|
||||
}
|
||||
|
||||
// 虚拟渲染:跳过不在 Canvas 覆盖范围内的关系线
|
||||
// 如果起点和终点都在 Canvas 外,跳过
|
||||
const fromX = fromBar.left + fromBar.width
|
||||
const toX = toBar.left
|
||||
const lineMinX = Math.min(fromX, toX)
|
||||
const lineMaxX = Math.max(fromX, toX)
|
||||
if (lineMaxX < canvasStartX || lineMinX > canvasEndX) {
|
||||
continue // 完全在 Canvas 外,跳过
|
||||
}
|
||||
|
||||
// 判断高亮状态
|
||||
const fromIsPrimary = props.highlightedTaskId === predecessorId
|
||||
const toIsPrimary = props.highlightedTaskId === task.id
|
||||
@@ -189,11 +183,26 @@ const drawLinks = () => {
|
||||
const globalX2 = toBar.left
|
||||
const globalY2 = toBar.top + toBar.height / 2 + toYOffset
|
||||
|
||||
// 虚拟渲染:跳过不在 Canvas 覆盖范围内的关系线
|
||||
// 如果起点和终点都在 Canvas 外,跳过
|
||||
const lineMinX = Math.min(globalX1, globalX2)
|
||||
const lineMaxX = Math.max(globalX1, globalX2)
|
||||
const lineMinY = Math.min(globalY1, globalY2)
|
||||
const lineMaxY = Math.max(globalY1, globalY2)
|
||||
if (
|
||||
lineMaxX < canvasStartX ||
|
||||
lineMinX > canvasEndX ||
|
||||
lineMaxY < canvasStartY ||
|
||||
lineMinY > canvasEndY
|
||||
) {
|
||||
continue // 完全在 Canvas 外,跳过
|
||||
}
|
||||
|
||||
// 转换为 Canvas 局部坐标
|
||||
const x1 = globalX1 - props.offsetLeft
|
||||
const y1 = globalY1
|
||||
const y1 = globalY1 - props.offsetTop
|
||||
const x2 = globalX2 - props.offsetLeft
|
||||
const y2 = globalY2
|
||||
const y2 = globalY2 - props.offsetTop
|
||||
|
||||
const c1x = x1 + 40
|
||||
const c1y = y1
|
||||
@@ -369,6 +378,7 @@ watch(
|
||||
() => props.verticalLines,
|
||||
() => props.showVerticalLines,
|
||||
() => props.offsetLeft, // 监听虚拟渲染的偏移量变化
|
||||
() => props.offsetTop,
|
||||
],
|
||||
() => {
|
||||
// 使用 RAF 调度重绘,合并连续的多次变化为单次绘制
|
||||
@@ -430,7 +440,7 @@ defineExpose({
|
||||
top: 0,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
transform: `translateX(${offsetLeft}px)`,
|
||||
transform: `translate(${offsetLeft}px, ${offsetTop}px)`,
|
||||
zIndex: highlightedTaskId !== null ? 1001 : 25,
|
||||
pointerEvents: 'none',
|
||||
}"
|
||||
|
||||
+102
-10
@@ -119,6 +119,25 @@ const createLocalToday = (): Date => {
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
}
|
||||
|
||||
// 缓存今天的日期,避免频繁创建
|
||||
// 每分钟更新一次缓存(对于日期判断来说足够了)
|
||||
const cachedToday = ref(createLocalToday())
|
||||
let todayCacheTimer: number | null = null
|
||||
|
||||
onMounted(() => {
|
||||
// 每60秒更新一次今天的日期缓存
|
||||
todayCacheTimer = window.setInterval(() => {
|
||||
cachedToday.value = createLocalToday()
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (todayCacheTimer !== null) {
|
||||
clearInterval(todayCacheTimer)
|
||||
todayCacheTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
const formatDateToLocalString = (date: Date): string => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
@@ -223,7 +242,7 @@ const taskBarStyle = computed(() => {
|
||||
|
||||
const startDate = createLocalDate(currentStartDate)
|
||||
const endDate = createLocalDate(currentEndDate)
|
||||
const baseStart = createLocalDate(props.startDate)
|
||||
const baseStart = parsedBaseStartDate.value
|
||||
if (!startDate || !endDate || !baseStart) {
|
||||
return {
|
||||
left: '0px',
|
||||
@@ -392,6 +411,24 @@ const taskBarStyle = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 缓存 TaskBar 的位置信息,减少 DOM 读取频率
|
||||
const cachedPosition = ref({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
timestamp: 0,
|
||||
})
|
||||
|
||||
// 位置缓存有效期(毫秒)
|
||||
const POSITION_CACHE_TTL = 100 // 100ms 内使用缓存值
|
||||
|
||||
// 缓存解析后的结束日期,避免在 taskStatus 中重复解析
|
||||
const parsedEndDate = computed(() => createLocalDate(props.task.endDate || ''))
|
||||
|
||||
// 缓存解析后的基准开始日期
|
||||
const parsedBaseStartDate = computed(() => createLocalDate(props.startDate))
|
||||
|
||||
// 计算任务状态和颜色
|
||||
const taskStatus = computed(() => {
|
||||
// 父级任务(Story类型)使用与新建按钮一致的配色
|
||||
@@ -404,8 +441,9 @@ const taskStatus = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
const today = createLocalToday()
|
||||
const endDate = createLocalDate(props.task.endDate || '')
|
||||
// 使用缓存的今天日期,避免频繁创建日期对象
|
||||
const today = cachedToday.value
|
||||
const endDate = parsedEndDate.value
|
||||
const progress = props.task.progress || 0
|
||||
|
||||
// 已完成
|
||||
@@ -556,6 +594,8 @@ const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-r
|
||||
const timelineContainer = document.querySelector('.timeline') as HTMLElement
|
||||
if (!timelineContainer || !barRef.value) return
|
||||
|
||||
// 在 mousedown 事件中读取位置是合理的(不是高频操作)
|
||||
// 这个值用于计算拖拽偏移量,只在开始拖拽时读取一次
|
||||
const barRect = barRef.value.getBoundingClientRect()
|
||||
|
||||
// 计算鼠标相对于TaskBar的位置
|
||||
@@ -595,17 +635,57 @@ const handleAutoScroll = (event: CustomEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用缓存机制减少 DOM 读取频率,但保证位置准确性
|
||||
let reportPositionScheduled = false
|
||||
|
||||
function reportBarPosition() {
|
||||
if (barRef.value) {
|
||||
// 如果已经安排了本帧的位置报告,则跳过
|
||||
if (reportPositionScheduled) return
|
||||
|
||||
reportPositionScheduled = true
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
reportPositionScheduled = false
|
||||
|
||||
if (!barRef.value) return
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// 如果缓存未过期,使用缓存值
|
||||
if (now - cachedPosition.value.timestamp < POSITION_CACHE_TTL) {
|
||||
emit('bar-mounted', {
|
||||
id: props.task.id,
|
||||
left: cachedPosition.value.left,
|
||||
top: cachedPosition.value.top,
|
||||
width: cachedPosition.value.width,
|
||||
height: cachedPosition.value.height,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 缓存过期或首次调用,读取 DOM 并更新缓存
|
||||
// TaskBar 传递绝对位置(相对于视口),Timeline 会将其转换为相对位置
|
||||
const rect = barRef.value.getBoundingClientRect()
|
||||
emit('bar-mounted', {
|
||||
id: props.task.id,
|
||||
|
||||
// 计算并缓存位置
|
||||
const position = {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
cachedPosition.value = {
|
||||
...position,
|
||||
timestamp: now,
|
||||
}
|
||||
|
||||
emit('bar-mounted', {
|
||||
id: props.task.id,
|
||||
...position,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 拖拽时的实时日期提示框状态
|
||||
@@ -1123,8 +1203,20 @@ const handleMouseUp = () => {
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
reportBarPosition()
|
||||
|
||||
// 使用 ResizeObserver 监听任务名称宽度变化
|
||||
if (taskBarNameRef.value) {
|
||||
nameTextWidth.value = taskBarNameRef.value.getBoundingClientRect().width
|
||||
const nameResizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
nameTextWidth.value = entry.contentRect.width
|
||||
}
|
||||
})
|
||||
nameResizeObserver.observe(taskBarNameRef.value)
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
nameResizeObserver.disconnect()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1395,7 +1487,7 @@ const stickyStyles = computed(() => {
|
||||
} else if (nameNeedsRightSticky) {
|
||||
const offset = rightBoundary - taskLeft - nameWidth
|
||||
// name 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
|
||||
nameLeft = `${offset - handleWidth - 3}px` // 考虑手柄宽度 + 间距
|
||||
nameLeft = `${offset - handleWidth - 10}px` // 考虑手柄宽度 + 间距
|
||||
namePosition = 'absolute'
|
||||
nameTop = '2px'
|
||||
}
|
||||
@@ -1431,7 +1523,7 @@ const stickyStyles = computed(() => {
|
||||
progressTop = '18px'
|
||||
} else if (progressNeedsRightSticky) {
|
||||
const offset = rightBoundary - taskLeft - progressWidth
|
||||
// progress 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
|
||||
// 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
|
||||
progressLeft = `${offset - handleWidth - 3}px` // 考虑手柄宽度 + 间距
|
||||
progressPosition = 'absolute'
|
||||
progressTop = '18px'
|
||||
|
||||
+145
-9
@@ -35,6 +35,20 @@ const { t } = useI18n()
|
||||
|
||||
// TaskList 容器引用
|
||||
const taskListRef = ref<HTMLElement | null>(null)
|
||||
const taskListBodyRef = ref<HTMLElement | null>(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 }) => {
|
||||
@@ -44,7 +58,7 @@ const getColumnWidthStyle = (column: { width?: number | string }) => {
|
||||
|
||||
// 如果是百分比,转换为像素
|
||||
if (typeof column.width === 'string' && column.width.includes('%')) {
|
||||
const containerWidth = taskListRef.value?.offsetWidth || 0
|
||||
const containerWidth = cachedContainerWidth.value || 0
|
||||
if (containerWidth > 0) {
|
||||
const percentage = parseFloat(column.width) / 100
|
||||
const pixels = Math.floor(containerWidth * percentage)
|
||||
@@ -89,6 +103,14 @@ const handleSplitterDragStart = () => {
|
||||
// 处理拖拽结束事件
|
||||
const handleSplitterDragEnd = () => {
|
||||
isSplitterDragging.value = false
|
||||
|
||||
// ⚠️ 拖拽结束后,手动更新一次容器宽度,触发列宽重新计算
|
||||
if (taskListRef.value) {
|
||||
const newWidth = taskListRef.value.offsetWidth
|
||||
if (Math.abs(newWidth - cachedContainerWidth.value) > 1) {
|
||||
cachedContainerWidth.value = newWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理任务行悬停事件
|
||||
@@ -228,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,
|
||||
@@ -331,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
|
||||
@@ -358,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)
|
||||
}
|
||||
}
|
||||
@@ -386,6 +471,37 @@ const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 初始化 ResizeObserver 监听容器宽度变化
|
||||
if (taskListRef.value) {
|
||||
containerResizeObserver = new ResizeObserver((entries) => {
|
||||
// ⚠️ 拖拽期间跳过更新,避免高频重新计算列宽
|
||||
// TaskList的列宽不需要在拖拽期间实时更新,只需在拖拽结束后更新一次
|
||||
if (isSplitterDragging.value) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
// 使用 contentRect.width 避免强制重排
|
||||
cachedContainerWidth.value = entry.contentRect.width
|
||||
}
|
||||
})
|
||||
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)
|
||||
@@ -401,6 +517,17 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理 ResizeObserver
|
||||
if (containerResizeObserver) {
|
||||
containerResizeObserver.disconnect()
|
||||
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)
|
||||
@@ -433,17 +560,20 @@ onUnmounted(() => {
|
||||
{{ (t as any)[column.key] || column.label }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-list-body" @scroll="handleTaskListScroll">
|
||||
<TaskRow
|
||||
v-for="task in localTasks"
|
||||
<div ref="taskListBodyRef" class="task-list-body" @scroll="handleTaskListScroll">
|
||||
<div class="task-list-body-spacer" :style="{ height: `${startSpacerHeight}px` }"></div>
|
||||
|
||||
<TaskRow
|
||||
v-for="{ task, level } in visibleTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:level="0"
|
||||
:level="level"
|
||||
:is-hovered="hoveredTaskId === task.id"
|
||||
:hovered-task-id="hoveredTaskId"
|
||||
:on-hover="handleTaskRowHover"
|
||||
:columns="visibleColumns"
|
||||
:get-column-width-style="getColumnWidthStyle"
|
||||
:disable-children-render="true"
|
||||
@toggle="toggleCollapse"
|
||||
@dblclick="handleTaskRowDoubleClick"
|
||||
@contextmenu="handleTaskRowContextMenu"
|
||||
@@ -457,6 +587,8 @@ onUnmounted(() => {
|
||||
<slot name="custom-task-content" v-bind="rowScope" />
|
||||
</template>
|
||||
</TaskRow>
|
||||
|
||||
<div class="task-list-body-spacer" :style="{ height: `${endSpacerHeight}px` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -509,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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch, useSlots } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
import { useI18n } from '../composables/useI18n'
|
||||
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
@@ -36,7 +36,8 @@ interface Props {
|
||||
hoveredTaskId?: number | null
|
||||
onHover?: (taskId: number | null) => void
|
||||
columns: TaskListColumnConfig[]
|
||||
getColumnWidthStyle?: (column: { width?: number | string }) => CSSProperties
|
||||
getColumnWidthStyle?: (column: { width?: number | string }) => StyleValue
|
||||
disableChildrenRender?: boolean
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits([
|
||||
@@ -412,7 +413,7 @@ onUnmounted(() => {
|
||||
:key="column.key"
|
||||
class="col"
|
||||
:class="column.cssClass || `col-${column.key}`"
|
||||
:style="getColumnWidthStyle ? getColumnWidthStyle(column) : {}"
|
||||
:style="getColumnWidthStyle ? getColumnWidthStyle(column) : undefined"
|
||||
>
|
||||
<!-- 里程碑分组显示空列 -->
|
||||
<template v-if="isMilestoneGroup">
|
||||
@@ -469,7 +470,7 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="hasChildren && !props.task.collapsed && !isMilestoneGroup">
|
||||
<template v-if="!props.disableChildrenRender && hasChildren && !props.task.collapsed && !isMilestoneGroup">
|
||||
<TaskRow
|
||||
v-for="child in props.task.children"
|
||||
:key="child.id"
|
||||
@@ -480,6 +481,7 @@ onUnmounted(() => {
|
||||
:on-hover="props.onHover"
|
||||
:columns="props.columns"
|
||||
:get-column-width-style="props.getColumnWidthStyle"
|
||||
:disable-children-render="props.disableChildrenRender"
|
||||
@toggle="emit('toggle', $event)"
|
||||
@dblclick="emit('dblclick', $event)"
|
||||
@start-timer="emit('start-timer', $event)"
|
||||
|
||||
+95
-18
@@ -157,15 +157,20 @@ const dayWidth = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 获取任务数据的日期范围(用于月度视图时间轴范围计算)
|
||||
const getTasksDateRange = () => {
|
||||
type TaskDateRange = { minDate: Date; maxDate: Date } | null
|
||||
let cachedTaskDateRange: TaskDateRange = null
|
||||
|
||||
const invalidateTaskDateRangeCache = () => {
|
||||
cachedTaskDateRange = null
|
||||
}
|
||||
|
||||
const computeTasksDateRange = (): TaskDateRange => {
|
||||
if (!tasks.value || tasks.value.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dates: Date[] = []
|
||||
|
||||
// 收集所有任务的开始和结束日期
|
||||
const collectDatesFromTask = (task: Task) => {
|
||||
if (task.startDate) {
|
||||
dates.push(new Date(task.startDate))
|
||||
@@ -174,7 +179,6 @@ const getTasksDateRange = () => {
|
||||
dates.push(new Date(task.endDate))
|
||||
}
|
||||
|
||||
// 递归处理子任务 - 使用 for 循环代替 forEach
|
||||
if (task.children && task.children.length > 0) {
|
||||
for (const child of task.children) {
|
||||
collectDatesFromTask(child)
|
||||
@@ -182,7 +186,6 @@ const getTasksDateRange = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 for...of 循环代替 forEach
|
||||
for (const task of tasks.value) {
|
||||
collectDatesFromTask(task)
|
||||
}
|
||||
@@ -191,7 +194,6 @@ const getTasksDateRange = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
// 过滤有效日期并直接获取最小/最大时间戳
|
||||
let minTime = Infinity
|
||||
let maxTime = -Infinity
|
||||
for (const date of dates) {
|
||||
@@ -206,10 +208,20 @@ const getTasksDateRange = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const minDate = new Date(minTime)
|
||||
const maxDate = new Date(maxTime)
|
||||
return {
|
||||
minDate: new Date(minTime),
|
||||
maxDate: new Date(maxTime),
|
||||
}
|
||||
}
|
||||
|
||||
return { minDate, maxDate }
|
||||
// 获取任务数据的日期范围(用于月度/年度视图时间轴范围计算)
|
||||
const getTasksDateRange = () => {
|
||||
if (cachedTaskDateRange) {
|
||||
return cachedTaskDateRange
|
||||
}
|
||||
|
||||
cachedTaskDateRange = computeTasksDateRange()
|
||||
return cachedTaskDateRange
|
||||
}
|
||||
|
||||
// 获取小时视图的时间范围
|
||||
@@ -657,6 +669,11 @@ let hideBubblesTimeout: number | null = null // 半圆显示恢复定时器
|
||||
const HOUR_WIDTH = 40 // 每小时40px
|
||||
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>()
|
||||
|
||||
@@ -701,6 +718,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 => {
|
||||
let timeout: number | null = null
|
||||
@@ -717,12 +758,12 @@ const debounce = <T extends (...args: unknown[]) => void>(func: T, wait: number)
|
||||
// 优化的滚动处理器(增加防抖时间到 50ms)
|
||||
const debouncedUpdatePositions = debounce(() => {
|
||||
computeAllMilestonesPositions()
|
||||
}, 50)
|
||||
}, 200)
|
||||
|
||||
// 虚拟渲染:防抖更新 Canvas 位置(滚动时触发)
|
||||
const debouncedUpdateCanvasPosition = debounce(() => {
|
||||
updateSvgSize() // 重新计算 Canvas 位置和尺寸
|
||||
}, 50)
|
||||
}, 200)
|
||||
|
||||
// 缓存时间轴数据的函数
|
||||
const getCachedTimelineData = (): unknown => {
|
||||
@@ -1938,17 +1979,20 @@ const svgHeight = ref(0)
|
||||
const canvasWidth = ref(0)
|
||||
const canvasHeight = ref(0)
|
||||
const canvasOffsetLeft = ref(0) // Canvas 在全局坐标系中的偏移量
|
||||
const canvasOffsetTop = ref(0)
|
||||
|
||||
// 虚拟渲染 Canvas 的安全宽度(防止超过浏览器限制)
|
||||
// 可根据实际需求调整:
|
||||
// - 5000: 最小内存 (~30MB),适合低端设备,但滚动时更频繁更新
|
||||
// - 10000: 平衡选择 (~60MB),覆盖小时视图 10 天,周视图 2 年
|
||||
const SAFE_CANVAS_WIDTH = 5000 // 平衡性能和覆盖范围
|
||||
const SAFE_CANVAS_HEIGHT = 5000
|
||||
|
||||
function updateSvgSize() {
|
||||
if (bodyContentRef.value) {
|
||||
// 获取 bodyContent 的总宽度和可视区域宽度
|
||||
const totalWidth = bodyContentRef.value.offsetWidth
|
||||
const totalHeight = contentHeight.value
|
||||
|
||||
// 使用已经维护的 timelineScrollLeft,而不是从 DOM 重新读取
|
||||
// 因为 handleTimelineScroll 已经实时更新了这个值
|
||||
@@ -1975,9 +2019,23 @@ function updateSvgSize() {
|
||||
|
||||
canvasOffsetLeft.value = idealOffsetLeft
|
||||
|
||||
const clampedHeight = Math.min(totalHeight, SAFE_CANVAS_HEIGHT)
|
||||
canvasHeight.value = clampedHeight
|
||||
svgWidth.value = canvasWidth.value
|
||||
svgHeight.value = contentHeight.value
|
||||
canvasHeight.value = contentHeight.value
|
||||
svgHeight.value = clampedHeight
|
||||
|
||||
const scrollTop = timelineBodyScrollTop.value
|
||||
const bufferTop = clampedHeight / 3
|
||||
let idealOffsetTop = Math.max(0, scrollTop - bufferTop)
|
||||
|
||||
if (totalHeight <= clampedHeight) {
|
||||
idealOffsetTop = 0
|
||||
} else {
|
||||
const maxOffsetTop = totalHeight - clampedHeight
|
||||
idealOffsetTop = Math.min(idealOffsetTop, maxOffsetTop)
|
||||
}
|
||||
|
||||
canvasOffsetTop.value = idealOffsetTop
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2001,7 +2059,9 @@ function handleBarMounted(payload: {
|
||||
height: payload.height,
|
||||
},
|
||||
}
|
||||
updateSvgSize()
|
||||
setTimeout(() => {
|
||||
updateSvgSize()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// 向上传递 TaskBar 拖拽/拉伸事件
|
||||
@@ -2162,6 +2222,12 @@ onMounted(() => {
|
||||
// 处理TaskList垂直滚动同步
|
||||
const handleTaskListVerticalScroll = (event: CustomEvent) => {
|
||||
const { scrollTop } = event.detail
|
||||
|
||||
// 立即更新纵向滚动位置(用于虚拟滚动计算)
|
||||
timelineBodyScrollTop.value = scrollTop
|
||||
|
||||
debouncedUpdateCanvasPosition()
|
||||
|
||||
if (timelineBodyElement.value && Math.abs(timelineBodyElement.value.scrollTop - scrollTop) > 1) {
|
||||
// 使用更精确的比较,避免1px以内的细微差异导致的循环触发
|
||||
timelineBodyElement.value.scrollTop = scrollTop
|
||||
@@ -2175,6 +2241,11 @@ const handleTimelineBodyScroll = (event: Event) => {
|
||||
|
||||
const scrollTop = target.scrollTop
|
||||
|
||||
// 立即更新纵向滚动位置(用于虚拟滚动计算)
|
||||
timelineBodyScrollTop.value = scrollTop
|
||||
|
||||
debouncedUpdateCanvasPosition()
|
||||
|
||||
// 拖拽时不同步滚动事件,避免性能问题
|
||||
if (isDragging.value) return
|
||||
|
||||
@@ -2701,6 +2772,7 @@ const convertTaskToMilestone = (task: Task): Milestone => {
|
||||
watch(
|
||||
() => tasks.value.length,
|
||||
() => {
|
||||
invalidateTaskDateRangeCache()
|
||||
computeAllMilestonesPositions()
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -2749,6 +2821,9 @@ const updateTimelineRange = () => {
|
||||
watch(
|
||||
() => tasks.value?.length,
|
||||
(newLength, oldLength) => {
|
||||
if (newLength !== oldLength) {
|
||||
invalidateTaskDateRangeCache()
|
||||
}
|
||||
// 当任务从无到有时,重新计算时间范围
|
||||
if (oldLength === 0 && newLength > 0) {
|
||||
debouncedUpdateTimelineRange(50)
|
||||
@@ -2789,7 +2864,7 @@ watch([timelineData, timelineContainerWidth], () => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
updateSvgSize()
|
||||
}, 50)
|
||||
}, 200)
|
||||
})
|
||||
taskBarRenderTimer = null
|
||||
}, 100)
|
||||
@@ -2799,6 +2874,7 @@ watch([timelineData, timelineContainerWidth], () => {
|
||||
watch(
|
||||
() => tasks.value,
|
||||
newTasks => {
|
||||
invalidateTaskDateRangeCache()
|
||||
// 优化:使用 for 循环直接构建 Set,避免 map 创建临时数组
|
||||
const currentTaskIds = new Set<number>()
|
||||
for (const task of newTasks) {
|
||||
@@ -3327,6 +3403,7 @@ const handleAddSuccessor = (task: Task) => {
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
:offset-left="canvasOffsetLeft"
|
||||
:offset-top="canvasOffsetTop"
|
||||
:highlighted-task-id="highlightedTaskId"
|
||||
:highlighted-task-ids="highlightedTaskIds"
|
||||
:hovered-task-id="hoveredTaskId"
|
||||
@@ -3510,13 +3587,13 @@ const handleAddSuccessor = (task: Task) => {
|
||||
<!-- 同时需要考虑左侧TaskList包含1px的bottom border -->
|
||||
<div class="task-bar-container" :style="{ height: `${contentHeight}px` }">
|
||||
<div class="task-rows" :style="{ height: `${contentHeight}px` }">
|
||||
<!-- 使用v-memo减少渲染 -->
|
||||
<!-- 使用虚拟滚动渲染可见任务 -->
|
||||
<div
|
||||
v-for="(task, index) in tasks"
|
||||
v-for="{ task, originalIndex } in visibleTasks"
|
||||
:key="task.id"
|
||||
class="task-row"
|
||||
:class="{ 'task-row-hovered': hoveredTaskId === task.id }"
|
||||
:style="{ top: `${index * 51}px` }"
|
||||
:style="{ top: `${originalIndex * 51}px` }"
|
||||
@mouseenter="handleTaskRowHover(task.id)"
|
||||
@mouseleave="handleTaskRowHover(null)"
|
||||
>
|
||||
|
||||
@@ -240,6 +240,29 @@ const messages = {
|
||||
},
|
||||
},
|
||||
disableTaskbarFocusMode: '关闭聚焦功能',
|
||||
dataSourceAlreadyLoaded: '{name} 已是当前数据源',
|
||||
dataSourceLoadSuccess: '已加载 {name}',
|
||||
dataSourceLoadFailed: '{name} 加载失败',
|
||||
dataSourceSwitch: {
|
||||
title: '数据源切换',
|
||||
subtitle: '对比常规与超大数据集的初始化体验',
|
||||
loading: '数据加载中,请稍候...',
|
||||
alreadyLoaded: '{name} 已是当前数据源',
|
||||
loadSuccess: '已加载 {name}',
|
||||
loadFailed: '{name} 加载失败',
|
||||
sources: {
|
||||
normal: {
|
||||
label: '常规数据源',
|
||||
description: 'data.json · 含完整前/后置依赖,适合功能演示',
|
||||
badge: 'data.json',
|
||||
},
|
||||
large: {
|
||||
label: '超大数据源',
|
||||
description: 'data-large-1m.json · 百万级任务,验证虚拟渲染性能',
|
||||
badge: 'data-large-1m.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'en-US': {
|
||||
dateNotSet: 'Not set',
|
||||
@@ -478,6 +501,29 @@ const messages = {
|
||||
},
|
||||
},
|
||||
disableTaskbarFocusMode: 'Disable Focus Mode',
|
||||
dataSourceAlreadyLoaded: '{name} is already active',
|
||||
dataSourceLoadSuccess: '{name} loaded successfully',
|
||||
dataSourceLoadFailed: '{name} failed to load',
|
||||
dataSourceSwitch: {
|
||||
title: 'Data Sources',
|
||||
subtitle: 'Compare default vs. mega dataset initialization',
|
||||
loading: 'Loading data, please wait…',
|
||||
alreadyLoaded: '{name} is already active',
|
||||
loadSuccess: '{name} loaded successfully',
|
||||
loadFailed: '{name} failed to load',
|
||||
sources: {
|
||||
normal: {
|
||||
label: 'Standard Dataset',
|
||||
description: 'data.json · Full predecessor graph for feature demos',
|
||||
badge: 'data.json',
|
||||
},
|
||||
large: {
|
||||
label: 'Massive Dataset',
|
||||
description: 'data-large-1m.json · Million-level tasks to stress virtual rendering',
|
||||
badge: 'data-large-1m.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user