v1.9.5 - performance enhancement
This commit is contained in:
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.9.5] - 2026-03-07
|
||||
|
||||
### Fixed
|
||||
- 🔧 优化:优化算法,提升插件性能,更好兼容移动设备的访问
|
||||
- 🔧 优化:来自Github一位用户的共同探讨,带来此次性能的飞跃,同时他也提供了另外一种应用层的优化方案,请参考: <a href=\"https://github.com/nelson820125/jordium-gantt-vue3/discussions/16\">discussion #16</a>
|
||||
- 🔧 Optimized: Algorithm optimizations for improved plugin performance and better compatibility with mobile device access
|
||||
- 🔧 Optimized: Collaborative discussions with a Github user led to this performance leap, and they also provided another application-level optimization方案, please refer to: <a href=\"https://github.com/nelson820125/jordium-gantt-vue3/discussions/16\">discussion #16</a>
|
||||
|
||||
## [1.9.4] - 2026-03-06
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -606,5 +606,17 @@
|
||||
"🔧 Fixed: Vulnerabilities fixed, jspdf added dependency dompurify for HTML XSS sanitization",
|
||||
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to jwuxin@gitee for their use and feedback</span>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.9.5",
|
||||
"date": "2026-03-07",
|
||||
"notes": [
|
||||
"🔧 优化:优化算法,提升插件性能,更好兼容移动设备的访问",
|
||||
"🔧 优化:来自Github一位用户的共同探讨,是带来此次性能飞跃的关键,同时他也提供了另外一种应用层的优化方案,请参考: <a href=\"https://github.com/nelson820125/jordium-gantt-vue3/discussions/16\">discussion #16</a>",
|
||||
"<span style=\"font-weight: bold; color: #f00;\">特别感谢来自eldrift@github的使用与反馈</span>",
|
||||
"🔧 Optimized: Algorithm optimizations for improved plugin performance and better compatibility with mobile device access",
|
||||
"🔧 Optimized: Collaborative discussions with a Github user led to this performance leap, and they also provided another application-level optimization方案, please refer to: <a href=\"https://github.com/nelson820125/jordium-gantt-vue3/discussions/16\">discussion #16</a>",
|
||||
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to eldritf@github for their use and feedback</span>"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jordium-gantt-vue3",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.5",
|
||||
"type": "module",
|
||||
"main": "npm-package/dist/jordium-gantt-vue3.cjs.js",
|
||||
"module": "npm-package/dist/jordium-gantt-vue3.es.js",
|
||||
|
||||
@@ -33,6 +33,8 @@ interface Props {
|
||||
verticalLines?: VerticalLine[]
|
||||
showVerticalLines?: boolean
|
||||
// 主题模式通过inject获取,不再需要props
|
||||
// 滚动优化:垂直滚动时跳过 canvas 重绘,等滚动停止后一次性重绘
|
||||
isScrolling?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -40,6 +42,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
showVerticalLines: true,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
isScrolling: false,
|
||||
})
|
||||
|
||||
// 通过inject获取主题
|
||||
@@ -54,6 +57,8 @@ const canvasManager = new CanvasContextManager()
|
||||
// requestAnimationFrame 防抖控制
|
||||
let rafId: number | null = null
|
||||
let pendingRedraw = false
|
||||
// 滚动期间跳过重绘,滚动停止后补一次
|
||||
let pendingRedrawOnScrollStop = false
|
||||
|
||||
// 当前主题(用于分隔线颜色)
|
||||
const isDarkTheme = computed(() => injectedTheme.value === 'dark')
|
||||
@@ -406,8 +411,15 @@ const drawArrowOptimized = (
|
||||
/**
|
||||
* 使用 requestAnimationFrame 优化的重绘调度器
|
||||
* 合并多个连续的重绘请求为单次绘制
|
||||
* 垂直滚动期间跳过重绘,等滚动停止后补绘一次(避免每帧重绘 canvas)
|
||||
*/
|
||||
const scheduleRedraw = () => {
|
||||
// 滚动期间:延迟至滚动停止后再重绘
|
||||
if (props.isScrolling) {
|
||||
pendingRedrawOnScrollStop = true
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingRedraw) {
|
||||
// 已有待处理的重绘请求,跳过
|
||||
return
|
||||
@@ -428,6 +440,17 @@ const scheduleRedraw = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 监听滚动结束:滚动期间有待重绘时,在停止后立即补绘一次
|
||||
watch(
|
||||
() => props.isScrolling,
|
||||
(scrolling) => {
|
||||
if (!scrolling && pendingRedrawOnScrollStop) {
|
||||
pendingRedrawOnScrollStop = false
|
||||
scheduleRedraw()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 监听相关状态变化,自动重绘 Canvas
|
||||
watch(
|
||||
[
|
||||
|
||||
@@ -3694,6 +3694,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
position: absolute;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
touch-action: none; /* JS 完全接管拖拽手势,不影响父层水平滚动 */
|
||||
/* 阴影通过JS动态设置,使用TaskBar的颜色 */
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
|
||||
@@ -142,6 +142,9 @@ export function useTaskListEventHandlers(options: TaskListEventHandlersOptions)
|
||||
|
||||
// ==================== 滚动同步事件 ====================
|
||||
|
||||
// 滚动同步标志位:防止 Timeline → TaskList 写入后反向再次派发(2跳链路优化)
|
||||
let _isSyncingScrollFromTimeline = false
|
||||
|
||||
const handleTaskListScroll = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target) return
|
||||
@@ -149,6 +152,9 @@ export function useTaskListEventHandlers(options: TaskListEventHandlersOptions)
|
||||
const scrollTop = target.scrollTop
|
||||
taskListScrollTop.value = scrollTop
|
||||
|
||||
// 由 Timeline → TaskList 同步写入触发时,不再反向派发,避免 2 跳循环
|
||||
if (_isSyncingScrollFromTimeline) return
|
||||
|
||||
// 滚动时关闭所有右键菜单
|
||||
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
|
||||
|
||||
@@ -166,7 +172,11 @@ export function useTaskListEventHandlers(options: TaskListEventHandlersOptions)
|
||||
taskListScrollTop.value = scrollTop
|
||||
|
||||
if (taskListBodyElement && Math.abs(taskListBodyElement.scrollTop - scrollTop) > 1) {
|
||||
// 设置标志位:当前是由 Timeline 触发的同步写入
|
||||
// 防止 handleTaskListScroll 被触发后反向再次派发事件(2跳链路)
|
||||
_isSyncingScrollFromTimeline = true
|
||||
taskListBodyElement.scrollTop = scrollTop
|
||||
Promise.resolve().then(() => { _isSyncingScrollFromTimeline = false })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||
import type { Task } from '../../../../models/classes/Task'
|
||||
import { registerDragOver, unregisterDragOver } from '../../../../utils/dragOverRegistry'
|
||||
|
||||
/**
|
||||
* TaskRow 事件处理和拖拽交互
|
||||
@@ -98,27 +99,25 @@ export function useTaskRowEventHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
// 处理全局拖拽悬停事件
|
||||
const handleTaskRowDragOver = (event: CustomEvent) => {
|
||||
// 处理全局拖拽悬停事件(通过 dragOverRegistry 委托,不再直接监听 window)
|
||||
// 优化:从每行独立注册 window 监听器改为全局单一监听器 + Map 直接查找 O(1)
|
||||
const handleTaskRowDragOver = (mouseEvent: MouseEvent) => {
|
||||
if (!enableDrag.value || !taskRowRef.value || !dragOver) return
|
||||
|
||||
const { taskId, event: mouseEvent } = event.detail
|
||||
if (taskId === task.value.id) {
|
||||
dragOver(task.value, taskRowRef.value, mouseEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期钩子 - 注册事件监听器
|
||||
onMounted(() => {
|
||||
window.addEventListener('splitter-drag-start', handleSplitterDragStart)
|
||||
window.addEventListener('splitter-drag-end', handleSplitterDragEnd)
|
||||
window.addEventListener('task-row-drag-over', handleTaskRowDragOver as EventListener)
|
||||
// 使用 dragOverRegistry 委托注册,避免每个 TaskRow 各自占用一个 window 监听器
|
||||
registerDragOver(task.value.id, handleTaskRowDragOver)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('splitter-drag-start', handleSplitterDragStart)
|
||||
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd)
|
||||
window.removeEventListener('task-row-drag-over', handleTaskRowDragOver as EventListener)
|
||||
unregisterDragOver(task.value.id)
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -178,6 +178,11 @@ const showConflicts = inject<ComputedRef<boolean>>('gantt-show-conflicts', compu
|
||||
const ROW_HEIGHT = 51 // 每行高度51px (50px + 1px border)
|
||||
const VERTICAL_BUFFER = 5 // 纵向缓冲区行数
|
||||
const timelineBodyScrollTop = ref(0) // 纵向滚动位置
|
||||
// 滚动同步标志位:防止 TaskList ↔ Timeline 垂直滚动形成 2 跳循环派发
|
||||
let _isSyncingScrollFromTaskList = false
|
||||
// GanttLinks 性能优化:滚动期间暂停 canvas 重绘,滚动停止后补绘一次
|
||||
const isTimelineScrolling = ref(false)
|
||||
let _scrollIdleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const timelineBodyHeight = ref(0) // 容器高度状态管理
|
||||
|
||||
// v1.9.9 优化:注入GanttChart提供的资源布局,避免重复计算
|
||||
@@ -1745,6 +1750,74 @@ const visibleResources = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
// ─── 任务视图 TaskBar 分批渲染(对齐资源视图策略)────────────────────────────────────
|
||||
// 每次虚拟窗口滑动时,新进入视口的行限流挂载,避免同帧大量 getBoundingClientRect reflow
|
||||
const TASK_VIEW_BATCH_SIZE = 3 // 每帧最多新增 3 行(节奏与资源视图一致)
|
||||
let _taskBatchRafId: number | null = null
|
||||
let _taskBatchQueue: { task: Task; originalIndex: number }[] = []
|
||||
// P2 修复:ID 可为 number | string,之前用 Set<number> 导致字符串 ID 永不被清理
|
||||
const _taskRenderedIds = new Set<number | string>()
|
||||
const taskRenderedItems = shallowRef<{ task: Task; originalIndex: number }[]>([])
|
||||
|
||||
watch(
|
||||
[visibleTasks, viewMode] as const,
|
||||
([newVisible, mode]) => {
|
||||
if (mode !== 'task') {
|
||||
// 切换到其他视图时清理状态
|
||||
if (_taskBatchRafId !== null) { cancelAnimationFrame(_taskBatchRafId); _taskBatchRafId = null }
|
||||
_taskBatchQueue = []
|
||||
_taskRenderedIds.clear()
|
||||
taskRenderedItems.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 显式声明为 Set<number | string>,与 _taskRenderedIds 类型对齐(任务视图 ID 为 number)
|
||||
const newIds = new Set<number | string>(newVisible.map(t => t.task.id))
|
||||
|
||||
// P1 修复:移除已滚出视口的 ID,同步清理 taskBarPositions
|
||||
// 虚拟滚动进出视口不触发 tasks watch,若不主动清理会导致位置对象无限增长
|
||||
const removedIds: (number | string)[] = []
|
||||
_taskRenderedIds.forEach(id => {
|
||||
if (!newIds.has(id)) {
|
||||
_taskRenderedIds.delete(id)
|
||||
removedIds.push(id)
|
||||
}
|
||||
})
|
||||
if (removedIds.length > 0) {
|
||||
const newPositions = { ...taskBarPositions.value }
|
||||
for (const id of removedIds) {
|
||||
delete newPositions[id as number]
|
||||
}
|
||||
taskBarPositions.value = newPositions
|
||||
}
|
||||
|
||||
// 找出新进入视口的行
|
||||
const toAdd = newVisible.filter(t => !_taskRenderedIds.has(t.task.id))
|
||||
|
||||
// 取消上一帧未完成的批次
|
||||
if (_taskBatchRafId !== null) { cancelAnimationFrame(_taskBatchRafId); _taskBatchRafId = null }
|
||||
_taskBatchQueue = toAdd
|
||||
|
||||
// 立即渲染第一批(保证当帧无闪烁)
|
||||
const firstBatch = _taskBatchQueue.splice(0, TASK_VIEW_BATCH_SIZE)
|
||||
firstBatch.forEach(t => _taskRenderedIds.add(t.task.id))
|
||||
taskRenderedItems.value = newVisible.filter(t => _taskRenderedIds.has(t.task.id))
|
||||
|
||||
// 剩余行逐帧补入
|
||||
if (_taskBatchQueue.length > 0) {
|
||||
const step = () => {
|
||||
const batch = _taskBatchQueue.splice(0, TASK_VIEW_BATCH_SIZE)
|
||||
if (batch.length === 0) { _taskBatchRafId = null; return }
|
||||
batch.forEach(t => _taskRenderedIds.add(t.task.id))
|
||||
taskRenderedItems.value = visibleTasks.value.filter(t => _taskRenderedIds.has(t.task.id))
|
||||
_taskBatchRafId = _taskBatchQueue.length > 0 ? requestAnimationFrame(step) : null
|
||||
}
|
||||
_taskBatchRafId = requestAnimationFrame(step)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// v1.9.6 Sprint2(P1+P4) - 资源视图TaskBar分批渲染队列(优化:批次从20改为5)
|
||||
const RESOURCE_BATCH_SIZE = 5 // 方案4: 更小批次,从20改为5
|
||||
const FRAME_TIME_BUDGET = 8 // 每帧最多8ms用于TaskBar渲染
|
||||
@@ -3556,12 +3629,18 @@ 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
|
||||
// 虚拟渲染 Canvas 的安全尺寸(动态计算,防止超过浏览器限制和移动端 OOM)
|
||||
// 策略:以当前可见视口的倍数为基准,全屏/分辨率变化时 updateSvgSize() 自动重算
|
||||
// - 宽度 = clamp(视口宽度 × 3, 2000, 8000):覆盖日视图滑动 2 屏 + 关系线缓冲
|
||||
// - 高度 = clamp(视口高度 × 3, 1200, 4000):覆盖 3 屏任务行,移动端从 100MB→15MB
|
||||
function computeSafeCanvasDimensions(): { width: number; height: number } {
|
||||
const vw = Math.max(timelineContainerWidth.value, 300) // 避免初始化为 0
|
||||
const vh = Math.max(timelineBodyHeight.value, 300)
|
||||
return {
|
||||
width: Math.min(Math.max(vw * 3, 2000), 8000),
|
||||
height: Math.min(Math.max(vh * 3, 1200), 4000),
|
||||
}
|
||||
}
|
||||
|
||||
function updateSvgSize() {
|
||||
if (bodyContentRef.value) {
|
||||
@@ -3574,8 +3653,11 @@ function updateSvgSize() {
|
||||
const scrollLeft = timelineScrollLeft.value
|
||||
const scrollTop = timelineBodyScrollTop.value
|
||||
|
||||
// 根据当前视口(全屏/窗口/屏幕分辨率)动态确定 Canvas 安全尺寸
|
||||
const { width: SAFE_CANVAS_WIDTH, height: SAFE_CANVAS_HEIGHT } = computeSafeCanvasDimensions()
|
||||
|
||||
// 虚拟渲染策略(统一模式):
|
||||
// Canvas 始终使用固定安全宽度,通过 offsetLeft 动态定位
|
||||
// Canvas 始终使用动态安全宽度,通过 offsetLeft 动态定位
|
||||
canvasWidth.value = SAFE_CANVAS_WIDTH
|
||||
|
||||
// 计算 Canvas 覆盖的起始位置
|
||||
@@ -3614,6 +3696,12 @@ function updateSvgSize() {
|
||||
}
|
||||
}
|
||||
|
||||
// 原因2优化:批量合并 handleBarMounted 更新,避免每个 TaskBar 挂载都触发一次响应式变化
|
||||
// 原来:N 个 TaskBar 挂载 → N 次 taskBarPositions spread 内存复制 + N 次 GanttLinks RAF
|
||||
// 现在:N 个 TaskBar 挂载 → 1 次合并写入 + 1 次 GanttLinks 重绘
|
||||
const _pendingBarUpdates: Record<number, { left: number; top: number; width: number; height: number }> = {}
|
||||
let _barUpdateScheduled = false
|
||||
|
||||
function handleBarMounted(payload: {
|
||||
id: number
|
||||
left: number
|
||||
@@ -3622,23 +3710,52 @@ function handleBarMounted(payload: {
|
||||
height: number
|
||||
}) {
|
||||
if (!bodyContentRef.value) return
|
||||
const baseRect = bodyContentRef.value.getBoundingClientRect()
|
||||
// 统一坐标系:以bodyContent为基准
|
||||
// 注意:使用 shallowRef 时,需要触发整个对象引用的变化
|
||||
taskBarPositions.value = {
|
||||
...taskBarPositions.value,
|
||||
[payload.id]: {
|
||||
// 使用已有的 cachedBodyRect,避免每次挂载都触发强制 reflow
|
||||
// bodyRectInvalidated 在滚动时被置为 true,确保坐标转换准确
|
||||
if (!cachedBodyRect || bodyRectInvalidated) {
|
||||
cachedBodyRect = bodyContentRef.value.getBoundingClientRect()
|
||||
bodyRectCacheTime = Date.now()
|
||||
bodyRectInvalidated = false
|
||||
}
|
||||
const baseRect = cachedBodyRect
|
||||
// 先缓存到 pending,不立即触发响应式
|
||||
_pendingBarUpdates[payload.id] = {
|
||||
left: payload.left - baseRect.left,
|
||||
top: payload.top - baseRect.top,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
},
|
||||
}
|
||||
setTimeout(() => {
|
||||
updateSvgSize()
|
||||
}, 200)
|
||||
if (!_barUpdateScheduled) {
|
||||
// 滚动期间跳过 flush:_pendingBarUpdates 继续累积,等滚动停止后统一写入
|
||||
// GanttLinks 此时本就等待 isScrolling=false 才重绘,延迟不影响显示
|
||||
if (isTimelineScrolling.value) return
|
||||
|
||||
_barUpdateScheduled = true
|
||||
// 微任务:将同一渲染批次内的多次调用合并为一次响应式引用变化
|
||||
Promise.resolve().then(() => {
|
||||
_barUpdateScheduled = false
|
||||
// 一次性合并,只触发一次 GanttLinks 重绘
|
||||
taskBarPositions.value = { ...taskBarPositions.value, ..._pendingBarUpdates }
|
||||
for (const key in _pendingBarUpdates) {
|
||||
delete _pendingBarUpdates[key as unknown as number]
|
||||
}
|
||||
setTimeout(() => updateSvgSize(), 200)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动停止时统一 flush 滚动期间积累的 bar 位置更新
|
||||
watch(isTimelineScrolling, (scrolling) => {
|
||||
if (scrolling) return
|
||||
if (Object.keys(_pendingBarUpdates).length === 0) return
|
||||
_barUpdateScheduled = false
|
||||
taskBarPositions.value = { ...taskBarPositions.value, ..._pendingBarUpdates }
|
||||
for (const key in _pendingBarUpdates) {
|
||||
delete _pendingBarUpdates[key as unknown as number]
|
||||
}
|
||||
setTimeout(() => updateSvgSize(), 200)
|
||||
})
|
||||
|
||||
// 向上传递 TaskBar 拖拽/拉伸事件
|
||||
const handleTaskBarDragEnd = (updatedTask: Task) => {
|
||||
// 如果是资源视图,需要更新dataSource中的资源数据
|
||||
@@ -3859,8 +3976,12 @@ const handleTaskListVerticalScroll = (event: CustomEvent) => {
|
||||
debouncedUpdateCanvasPosition()
|
||||
|
||||
if (timelineBodyElement.value && Math.abs(timelineBodyElement.value.scrollTop - scrollTop) > 1) {
|
||||
// 使用更精确的比较,避免1px以内的细微差异导致的循环触发
|
||||
// 设置标志位:当前是由 TaskList 触发的同步写入
|
||||
// 防止 handleTimelineBodyScroll 被触发后反向再次派发事件(2跳链路)
|
||||
_isSyncingScrollFromTaskList = true
|
||||
timelineBodyElement.value.scrollTop = scrollTop
|
||||
// scroll 事件会同步触发,Promise.resolve 在其后执行,确保标志在返回相义务后重置
|
||||
Promise.resolve().then(() => { _isSyncingScrollFromTaskList = false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3877,11 +3998,22 @@ const handleTimelineBodyScroll = (event: Event) => {
|
||||
// 优化:滚动时失效 bodyRect 缓存(用于连接线拖拽)
|
||||
bodyRectInvalidated = true
|
||||
|
||||
// GanttLinks 性能优化:设置滚动标志,暂停 canvas 重绘
|
||||
isTimelineScrolling.value = true
|
||||
if (_scrollIdleTimer !== null) clearTimeout(_scrollIdleTimer)
|
||||
_scrollIdleTimer = setTimeout(() => {
|
||||
_scrollIdleTimer = null
|
||||
isTimelineScrolling.value = false
|
||||
}, 150)
|
||||
|
||||
debouncedUpdateCanvasPosition()
|
||||
|
||||
// 拖拽时不同步滚动事件,避免性能问题
|
||||
if (isDragging.value) return
|
||||
|
||||
// 由 TaskList → Timeline 同步写入触发时,不再反向派发,避免 2 跳循环
|
||||
if (_isSyncingScrollFromTaskList) return
|
||||
|
||||
// 同步垂直滚动到TaskList
|
||||
if (scrollTop >= 0) {
|
||||
window.dispatchEvent(
|
||||
@@ -4736,19 +4868,19 @@ watch(
|
||||
() => tasks.value,
|
||||
newTasks => {
|
||||
invalidateTaskDateRangeCache()
|
||||
// 优化:使用 for 循环直接构建 Set,避免 map 创建临时数组
|
||||
const currentTaskIds = new Set<number>()
|
||||
// P2 修复:task.id 可为 number 或 string(资源视图),统一转为 string 比较
|
||||
// 原来使用 parseInt(taskIdStr) 会将 "res-1" → NaN,导致字符串 ID 永远无法匹配,位置信息堆积
|
||||
const currentTaskIdStrs = new Set<string>()
|
||||
for (const task of newTasks) {
|
||||
currentTaskIds.add(task.id)
|
||||
currentTaskIdStrs.add(String(task.id))
|
||||
}
|
||||
|
||||
// 优化:使用 for...in 循环代替 Object.keys().forEach()
|
||||
// 注意:shallowRef 需要创建新对象来触发响应式
|
||||
const newPositions: Record<number, any> = {}
|
||||
for (const taskIdStr in taskBarPositions.value) {
|
||||
const taskId = parseInt(taskIdStr)
|
||||
if (currentTaskIds.has(taskId)) {
|
||||
newPositions[taskId] = taskBarPositions.value[taskId]
|
||||
// 对象键本身就是字符串,直接与 string Set 比较,避免 parseInt 对非数字 ID 返回 NaN
|
||||
if (currentTaskIdStrs.has(taskIdStr)) {
|
||||
newPositions[taskIdStr as unknown as number] = taskBarPositions.value[taskIdStr as unknown as number]
|
||||
}
|
||||
}
|
||||
taskBarPositions.value = newPositions
|
||||
@@ -5270,6 +5402,7 @@ const handleAddSuccessor = (task: Task) => {
|
||||
:hovered-task-id="hoveredTaskId"
|
||||
:vertical-lines="monthFirstVerticalLines"
|
||||
:show-vertical-lines="currentTimeScale === TimelineScale.WEEK"
|
||||
:is-scrolling="isTimelineScrolling"
|
||||
/>
|
||||
|
||||
<!-- 连接线拖拽引导线 - 🚀 优化:使用命令式 API,由 RAF 直接调用 draw() -->
|
||||
@@ -5458,9 +5591,9 @@ 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` }">
|
||||
<!-- 任务视图:使用虚拟滚动渲染可见任务 -->
|
||||
<!-- 任务视图:使用虚拟滚动分批渲染可见任务(taskRenderedItems 每帧限流 3 行新增)-->
|
||||
<div
|
||||
v-for="{ task, originalIndex } in visibleTasks"
|
||||
v-for="{ task, originalIndex } in taskRenderedItems"
|
||||
v-if="viewMode === 'task'"
|
||||
:key="task.id"
|
||||
class="task-row"
|
||||
|
||||
42
src/utils/dragOverRegistry.ts
Normal file
42
src/utils/dragOverRegistry.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* dragOverRegistry - 任务行拖拽悬停事件委托注册表
|
||||
*
|
||||
* 解决性能问题:原来每个可见 TaskRow 各自注册一个 window 'task-row-drag-over' 监听器,
|
||||
* 拖拽时 CustomEvent 广播需逐一触发所有监听器(O(n) 开销)。
|
||||
*
|
||||
* 改进后:改为单一全局监听器 + Map 直接查找(O(1)),
|
||||
* 监听器数量从 N 个(可见行数)降为 1 个。
|
||||
*/
|
||||
|
||||
type DragOverCallback = (mouseEvent: MouseEvent) => void
|
||||
|
||||
const _registry = new Map<number | string, DragOverCallback>()
|
||||
|
||||
function _handleGlobalDragOver(e: Event) {
|
||||
const { taskId, event: mouseEvent } = (e as CustomEvent).detail
|
||||
const handler = _registry.get(taskId)
|
||||
if (handler) handler(mouseEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册任务行的拖拽悬停回调。
|
||||
* 第一次注册时自动绑定全局 window 监听器。
|
||||
*/
|
||||
export function registerDragOver(taskId: number | string, handler: DragOverCallback): void {
|
||||
const wasEmpty = _registry.size === 0
|
||||
_registry.set(taskId, handler)
|
||||
if (wasEmpty) {
|
||||
window.addEventListener('task-row-drag-over', _handleGlobalDragOver)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销任务行的拖拽悬停回调。
|
||||
* 最后一个注销时自动解绑全局 window 监听器。
|
||||
*/
|
||||
export function unregisterDragOver(taskId: number | string): void {
|
||||
_registry.delete(taskId)
|
||||
if (_registry.size === 0) {
|
||||
window.removeEventListener('task-row-drag-over', _handleGlobalDragOver)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user