Merge remote-tracking branch 'src'

This commit is contained in:
qiuchengw
2025-12-01 16:36:13 +08:00
13 changed files with 795 additions and 110 deletions
+23 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+6 -4
View File
@@ -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
View File
@@ -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)"
>
+46
View File
@@ -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',
},
},
},
},
}