v1.4.2-patch.3 - 性能优化
This commit is contained in:
+6
-6
@@ -31,7 +31,7 @@
|
|||||||
"id": 1101,
|
"id": 1101,
|
||||||
"name": "试验方案设计与<span style='font-weight: bold;color:red;'>伦理审查</span>",
|
"name": "试验方案设计与<span style='font-weight: bold;color:red;'>伦理审查</span>",
|
||||||
"assignee": "方案设计师 李明",
|
"assignee": "方案设计师 李明",
|
||||||
"avatar": "https://i.pravatar.cc/150?img=1",
|
"avatar": "https://i.pravatar.cc/50?img=1",
|
||||||
"startDate": "2025-01-01",
|
"startDate": "2025-01-01",
|
||||||
"endDate": "2025-02-28",
|
"endDate": "2025-02-28",
|
||||||
"progress": 100,
|
"progress": 100,
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
"id": 1102,
|
"id": 1102,
|
||||||
"name": "受试者招募与筛选",
|
"name": "受试者招募与筛选",
|
||||||
"assignee": "招募专员 张丽",
|
"assignee": "招募专员 张丽",
|
||||||
"avatar": "https://i.pravatar.cc/150?img=5",
|
"avatar": "https://i.pravatar.cc/50?img=5",
|
||||||
"startDate": "2025-03-01",
|
"startDate": "2025-03-01",
|
||||||
"endDate": "2025-04-30",
|
"endDate": "2025-04-30",
|
||||||
"progress": 90,
|
"progress": 90,
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"id": 1103,
|
"id": 1103,
|
||||||
"name": "药物给药与安全性监测",
|
"name": "药物给药与安全性监测",
|
||||||
"assignee": "临床医生 Dr. Liu",
|
"assignee": "临床医生 Dr. Liu",
|
||||||
"avatar": "https://i.pravatar.cc/150?img=8",
|
"avatar": "https://i.pravatar.cc/50?img=8",
|
||||||
"startDate": "2025-05-01",
|
"startDate": "2025-05-01",
|
||||||
"endDate": "2025-08-31",
|
"endDate": "2025-08-31",
|
||||||
"progress": 40,
|
"progress": 40,
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
"id": 1201,
|
"id": 1201,
|
||||||
"name": "多中心试验<span style='font-weight: bold; color: blue;'>启动</span>",
|
"name": "多中心试验<span style='font-weight: bold; color: blue;'>启动</span>",
|
||||||
"assignee": "项目经理 王芳",
|
"assignee": "项目经理 王芳",
|
||||||
"avatar": "https://i.pravatar.cc/150?img=10",
|
"avatar": "https://i.pravatar.cc/50?img=10",
|
||||||
"startDate": "2025-09-01",
|
"startDate": "2025-09-01",
|
||||||
"endDate": "2025-11-30",
|
"endDate": "2025-11-30",
|
||||||
"progress": 25,
|
"progress": 25,
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
"id": 1202,
|
"id": 1202,
|
||||||
"name": "患者入组与随机化",
|
"name": "患者入组与随机化",
|
||||||
"assignee": "数据管理员 陈静",
|
"assignee": "数据管理员 陈静",
|
||||||
"avatar": "https://i.pravatar.cc/150?img=20",
|
"avatar": "https://i.pravatar.cc/50?img=20",
|
||||||
"startDate": "2025-12-01",
|
"startDate": "2025-12-01",
|
||||||
"endDate": "2026-03-31",
|
"endDate": "2026-03-31",
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
"id": 1203,
|
"id": 1203,
|
||||||
"name": "疗效评估与数据收集",
|
"name": "疗效评估与数据收集",
|
||||||
"assignee": "统计师 赵磊",
|
"assignee": "统计师 赵磊",
|
||||||
"avatar": "https://i.pravatar.cc/150?img=15",
|
"avatar": "https://i.pravatar.cc/50?img=15",
|
||||||
"startDate": "2026-01-01",
|
"startDate": "2026-01-01",
|
||||||
"endDate": "2026-08-31",
|
"endDate": "2026-08-31",
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
|
|||||||
@@ -136,35 +136,45 @@ interface Props {
|
|||||||
const ganttRootRef = ref<HTMLElement | null>(null)
|
const ganttRootRef = ref<HTMLElement | null>(null)
|
||||||
const ganttContainerWidth = ref(1920) // 默认使用常见的屏幕宽度作为初始值
|
const ganttContainerWidth = ref(1920) // 默认使用常见的屏幕宽度作为初始值
|
||||||
|
|
||||||
// 监听容器宽度变化
|
// ResizeObserver 用于监听容器宽度变化
|
||||||
const updateContainerWidth = () => {
|
let ganttRootResizeObserver: ResizeObserver | null = null
|
||||||
if (ganttRootRef.value) {
|
|
||||||
const newWidth = ganttRootRef.value.clientWidth
|
|
||||||
if (newWidth !== ganttContainerWidth.value) {
|
|
||||||
ganttContainerWidth.value = newWidth
|
|
||||||
// 容器宽度变化时,重新计算 TaskList 的宽度限制
|
|
||||||
ganttPanelLeftMinWidth.value = getTaskListMinWidth()
|
|
||||||
taskListBodyWidth.value = getTaskListMaxWidth()
|
|
||||||
taskListBodyProposedWidth.value = getTaskListMaxWidth()
|
|
||||||
taskListBodyWidthLimit.value = getTaskListMaxWidth()
|
|
||||||
|
|
||||||
// 确保当前宽度在新的限制范围内
|
// 监听容器宽度变化
|
||||||
const adjustedWidth = checkWidthLimits(leftPanelWidth.value)
|
const updateContainerWidth = (newWidth: number) => {
|
||||||
if (adjustedWidth !== leftPanelWidth.value) {
|
if (newWidth !== ganttContainerWidth.value) {
|
||||||
leftPanelWidth.value = adjustedWidth
|
ganttContainerWidth.value = newWidth
|
||||||
}
|
// 容器宽度变化时,重新计算 TaskList 的宽度限制
|
||||||
|
ganttPanelLeftMinWidth.value = getTaskListMinWidth()
|
||||||
|
taskListBodyWidth.value = getTaskListMaxWidth()
|
||||||
|
taskListBodyProposedWidth.value = getTaskListMaxWidth()
|
||||||
|
taskListBodyWidthLimit.value = getTaskListMaxWidth()
|
||||||
|
|
||||||
|
// 确保当前宽度在新的限制范围内
|
||||||
|
const adjustedWidth = checkWidthLimits(leftPanelWidth.value)
|
||||||
|
if (adjustedWidth !== leftPanelWidth.value) {
|
||||||
|
leftPanelWidth.value = adjustedWidth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateContainerWidth()
|
if (ganttRootRef.value) {
|
||||||
// 监听窗口大小变化
|
// 使用 ResizeObserver 监听容器宽度变化,避免频繁读取 clientWidth
|
||||||
window.addEventListener('resize', updateContainerWidth)
|
ganttRootResizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
// 使用 contentRect.width,避免强制重排
|
||||||
|
updateContainerWidth(entry.contentRect.width)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ganttRootResizeObserver.observe(ganttRootRef.value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', updateContainerWidth)
|
if (ganttRootResizeObserver) {
|
||||||
|
ganttRootResizeObserver.disconnect()
|
||||||
|
ganttRootResizeObserver = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// TaskList最小宽度,支持通过taskListConfig配置(支持像素和百分比)
|
// TaskList最小宽度,支持通过taskListConfig配置(支持像素和百分比)
|
||||||
@@ -331,9 +341,11 @@ function onMouseDown(e: MouseEvent) {
|
|||||||
document.addEventListener('wheel', blockAllEvents, { capture: true, passive: false })
|
document.addEventListener('wheel', blockAllEvents, { capture: true, passive: false })
|
||||||
document.addEventListener('contextmenu', blockAllEvents, { capture: true })
|
document.addEventListener('contextmenu', blockAllEvents, { capture: true })
|
||||||
|
|
||||||
|
// ⚠️ 使用requestAnimationFrame节流,但移除阈值检测,确保每帧都更新
|
||||||
|
let rafId: number | null = null
|
||||||
|
|
||||||
function onMouseMove(ev: MouseEvent) {
|
function onMouseMove(ev: MouseEvent) {
|
||||||
if (!dragging.value) return
|
if (!dragging.value) return
|
||||||
|
|
||||||
// 强制阻止所有默认行为和事件传播
|
// 强制阻止所有默认行为和事件传播
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
ev.stopPropagation()
|
ev.stopPropagation()
|
||||||
@@ -341,15 +353,29 @@ function onMouseDown(e: MouseEvent) {
|
|||||||
|
|
||||||
const delta = ev.clientX - startX
|
const delta = ev.clientX - startX
|
||||||
const proposedWidth = startWidth + delta
|
const proposedWidth = startWidth + delta
|
||||||
|
|
||||||
// 直接使用面板宽度限制检查,无需复杂的坐标计算
|
|
||||||
const finalWidth = checkWidthLimits(proposedWidth)
|
const finalWidth = checkWidthLimits(proposedWidth)
|
||||||
leftPanelWidth.value = finalWidth
|
|
||||||
|
// 取消之前的帧请求
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在下一帧更新(节流到60fps,避免过度触发响应式系统)
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
leftPanelWidth.value = finalWidth
|
||||||
|
rafId = null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseUp() {
|
function onMouseUp() {
|
||||||
dragging.value = false
|
dragging.value = false
|
||||||
|
|
||||||
|
// 取消未完成的帧请求
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
rafId = null
|
||||||
|
}
|
||||||
|
|
||||||
// 移除全局事件拦截器
|
// 移除全局事件拦截器
|
||||||
document.removeEventListener('mousedown', blockAllEvents, { capture: true })
|
document.removeEventListener('mousedown', blockAllEvents, { capture: true })
|
||||||
document.removeEventListener('click', blockAllEvents, { capture: true })
|
document.removeEventListener('click', blockAllEvents, { capture: true })
|
||||||
@@ -480,10 +506,8 @@ onMounted(() => {
|
|||||||
// 监听右侧面板(timeline 的可视容器)的宽度
|
// 监听右侧面板(timeline 的可视容器)的宽度
|
||||||
const rightPanel = document.querySelector('.gantt-panel-right')
|
const rightPanel = document.querySelector('.gantt-panel-right')
|
||||||
if (rightPanel) {
|
if (rightPanel) {
|
||||||
// 初始化宽度
|
// 使用 ResizeObserver 自动更新宽度,避免直接读取clientWidth造成强制重排
|
||||||
timelineContainerWidth.value = rightPanel.clientWidth
|
// ResizeObserver 会在开始观察时立即触发一次回调,提供初始宽度
|
||||||
|
|
||||||
// 使用 ResizeObserver 监听宽度变化
|
|
||||||
resizeObserver = new ResizeObserver(entries => {
|
resizeObserver = new ResizeObserver(entries => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
timelineContainerWidth.value = entry.contentRect.width
|
timelineContainerWidth.value = entry.contentRect.width
|
||||||
@@ -2162,6 +2186,7 @@ function handleMilestoneDialogDelete(milestoneId: number) {
|
|||||||
<div
|
<div
|
||||||
v-if="isTaskListVisible"
|
v-if="isTaskListVisible"
|
||||||
class="gantt-panel gantt-panel-left"
|
class="gantt-panel gantt-panel-left"
|
||||||
|
:class="{ dragging: dragging }"
|
||||||
:style="{ width: leftPanelWidth + 'px' }"
|
:style="{ width: leftPanelWidth + 'px' }"
|
||||||
>
|
>
|
||||||
<TaskList
|
<TaskList
|
||||||
@@ -2317,6 +2342,12 @@ function handleMilestoneDialogDelete(milestoneId: number) {
|
|||||||
.gantt-panel-left {
|
.gantt-panel-left {
|
||||||
/* width 由js控制 */
|
/* width 由js控制 */
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
|
/* ⚠️ 拖拽时禁用transition,提升响应速度 */
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-panel-left:not(.dragging) {
|
||||||
|
/* 非拖拽时保留平滑过渡效果(如toggle时) */
|
||||||
transition: width 0.1s;
|
transition: width 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+102
-10
@@ -119,6 +119,25 @@ const createLocalToday = (): Date => {
|
|||||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
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 formatDateToLocalString = (date: Date): string => {
|
||||||
const year = date.getFullYear()
|
const year = date.getFullYear()
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
@@ -223,7 +242,7 @@ const taskBarStyle = computed(() => {
|
|||||||
|
|
||||||
const startDate = createLocalDate(currentStartDate)
|
const startDate = createLocalDate(currentStartDate)
|
||||||
const endDate = createLocalDate(currentEndDate)
|
const endDate = createLocalDate(currentEndDate)
|
||||||
const baseStart = createLocalDate(props.startDate)
|
const baseStart = parsedBaseStartDate.value
|
||||||
if (!startDate || !endDate || !baseStart) {
|
if (!startDate || !endDate || !baseStart) {
|
||||||
return {
|
return {
|
||||||
left: '0px',
|
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(() => {
|
const taskStatus = computed(() => {
|
||||||
// 父级任务(Story类型)使用与新建按钮一致的配色
|
// 父级任务(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
|
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
|
const timelineContainer = document.querySelector('.timeline') as HTMLElement
|
||||||
if (!timelineContainer || !barRef.value) return
|
if (!timelineContainer || !barRef.value) return
|
||||||
|
|
||||||
|
// 在 mousedown 事件中读取位置是合理的(不是高频操作)
|
||||||
|
// 这个值用于计算拖拽偏移量,只在开始拖拽时读取一次
|
||||||
const barRect = barRef.value.getBoundingClientRect()
|
const barRect = barRef.value.getBoundingClientRect()
|
||||||
|
|
||||||
// 计算鼠标相对于TaskBar的位置
|
// 计算鼠标相对于TaskBar的位置
|
||||||
@@ -595,17 +635,57 @@ const handleAutoScroll = (event: CustomEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用缓存机制减少 DOM 读取频率,但保证位置准确性
|
||||||
|
let reportPositionScheduled = false
|
||||||
|
|
||||||
function reportBarPosition() {
|
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()
|
const rect = barRef.value.getBoundingClientRect()
|
||||||
emit('bar-mounted', {
|
|
||||||
id: props.task.id,
|
// 计算并缓存位置
|
||||||
|
const position = {
|
||||||
left: rect.left,
|
left: rect.left,
|
||||||
top: rect.top,
|
top: rect.top,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
cachedPosition.value = {
|
||||||
|
...position,
|
||||||
|
timestamp: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('bar-mounted', {
|
||||||
|
id: props.task.id,
|
||||||
|
...position,
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拖拽时的实时日期提示框状态
|
// 拖拽时的实时日期提示框状态
|
||||||
@@ -1123,8 +1203,20 @@ const handleMouseUp = () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
reportBarPosition()
|
reportBarPosition()
|
||||||
|
|
||||||
|
// 使用 ResizeObserver 监听任务名称宽度变化
|
||||||
if (taskBarNameRef.value) {
|
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) {
|
} else if (nameNeedsRightSticky) {
|
||||||
const offset = rightBoundary - taskLeft - nameWidth
|
const offset = rightBoundary - taskLeft - nameWidth
|
||||||
// name 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
|
// name 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
|
||||||
nameLeft = `${offset - handleWidth - 3}px` // 考虑手柄宽度 + 间距
|
nameLeft = `${offset - handleWidth - 10}px` // 考虑手柄宽度 + 间距
|
||||||
namePosition = 'absolute'
|
namePosition = 'absolute'
|
||||||
nameTop = '2px'
|
nameTop = '2px'
|
||||||
}
|
}
|
||||||
@@ -1431,7 +1523,7 @@ const stickyStyles = computed(() => {
|
|||||||
progressTop = '18px'
|
progressTop = '18px'
|
||||||
} else if (progressNeedsRightSticky) {
|
} else if (progressNeedsRightSticky) {
|
||||||
const offset = rightBoundary - taskLeft - progressWidth
|
const offset = rightBoundary - taskLeft - progressWidth
|
||||||
// progress 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
|
// 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
|
||||||
progressLeft = `${offset - handleWidth - 3}px` // 考虑手柄宽度 + 间距
|
progressLeft = `${offset - handleWidth - 3}px` // 考虑手柄宽度 + 间距
|
||||||
progressPosition = 'absolute'
|
progressPosition = 'absolute'
|
||||||
progressTop = '18px'
|
progressTop = '18px'
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ const { t } = useI18n()
|
|||||||
// TaskList 容器引用
|
// TaskList 容器引用
|
||||||
const taskListRef = ref<HTMLElement | null>(null)
|
const taskListRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// 缓存容器宽度,避免频繁读取 offsetWidth 导致强制重排
|
||||||
|
const cachedContainerWidth = ref(0)
|
||||||
|
|
||||||
|
// 使用 ResizeObserver 监听容器宽度变化
|
||||||
|
let containerResizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
// 获取列宽度样式(百分比转像素)
|
// 获取列宽度样式(百分比转像素)
|
||||||
const getColumnWidthStyle = (column: { width?: number | string }) => {
|
const getColumnWidthStyle = (column: { width?: number | string }) => {
|
||||||
if (!column.width) return {}
|
if (!column.width) return {}
|
||||||
@@ -44,7 +50,7 @@ const getColumnWidthStyle = (column: { width?: number | string }) => {
|
|||||||
|
|
||||||
// 如果是百分比,转换为像素
|
// 如果是百分比,转换为像素
|
||||||
if (typeof column.width === 'string' && column.width.includes('%')) {
|
if (typeof column.width === 'string' && column.width.includes('%')) {
|
||||||
const containerWidth = taskListRef.value?.offsetWidth || 0
|
const containerWidth = cachedContainerWidth.value || 0
|
||||||
if (containerWidth > 0) {
|
if (containerWidth > 0) {
|
||||||
const percentage = parseFloat(column.width) / 100
|
const percentage = parseFloat(column.width) / 100
|
||||||
const pixels = Math.floor(containerWidth * percentage)
|
const pixels = Math.floor(containerWidth * percentage)
|
||||||
@@ -89,6 +95,14 @@ const handleSplitterDragStart = () => {
|
|||||||
// 处理拖拽结束事件
|
// 处理拖拽结束事件
|
||||||
const handleSplitterDragEnd = () => {
|
const handleSplitterDragEnd = () => {
|
||||||
isSplitterDragging.value = false
|
isSplitterDragging.value = false
|
||||||
|
|
||||||
|
// ⚠️ 拖拽结束后,手动更新一次容器宽度,触发列宽重新计算
|
||||||
|
if (taskListRef.value) {
|
||||||
|
const newWidth = taskListRef.value.offsetWidth
|
||||||
|
if (Math.abs(newWidth - cachedContainerWidth.value) > 1) {
|
||||||
|
cachedContainerWidth.value = newWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理任务行悬停事件
|
// 处理任务行悬停事件
|
||||||
@@ -386,6 +400,23 @@ const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -401,6 +432,12 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
// 清理 ResizeObserver
|
||||||
|
if (containerResizeObserver) {
|
||||||
|
containerResizeObserver.disconnect()
|
||||||
|
containerResizeObserver = 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)
|
||||||
|
|||||||
+172
-110
@@ -627,6 +627,7 @@ let scrollTimeout: number | null = null
|
|||||||
// 粘性效果所需的滚动位置信息
|
// 粘性效果所需的滚动位置信息
|
||||||
const timelineScrollLeft = ref(0)
|
const timelineScrollLeft = ref(0)
|
||||||
const timelineContainerWidth = ref(0)
|
const timelineContainerWidth = ref(0)
|
||||||
|
const timelineScrollWidth = ref(0) // 缓存scrollWidth,避免在滚动事件中频繁读取造成强制重排
|
||||||
|
|
||||||
// 半圆气泡控制状态
|
// 半圆气泡控制状态
|
||||||
const hideBubbles = ref(true) // 初始时隐藏半圆,等待初始滚动完成
|
const hideBubbles = ref(true) // 初始时隐藏半圆,等待初始滚动完成
|
||||||
@@ -643,6 +644,9 @@ const timelineDataCache = new Map<string, unknown>()
|
|||||||
// 初始化状态
|
// 初始化状态
|
||||||
const isInitialLoad = ref(true)
|
const isInitialLoad = ref(true)
|
||||||
|
|
||||||
|
// 节流的容器宽度(用于虚拟滚动范围计算,避免每次微小变化都触发)
|
||||||
|
const throttledContainerWidth = ref(0)
|
||||||
|
|
||||||
// 计算小时视图的可视区域范围
|
// 计算小时视图的可视区域范围
|
||||||
const visibleHourRange = computed(() => {
|
const visibleHourRange = computed(() => {
|
||||||
if (currentTimeScale.value !== TimelineScale.HOUR) {
|
if (currentTimeScale.value !== TimelineScale.HOUR) {
|
||||||
@@ -650,7 +654,7 @@ const visibleHourRange = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scrollLeft = timelineScrollLeft.value
|
const scrollLeft = timelineScrollLeft.value
|
||||||
const containerWidth = timelineContainerWidth.value
|
const containerWidth = throttledContainerWidth.value || timelineContainerWidth.value
|
||||||
|
|
||||||
// 首次加载时,使用更大的初始渲染范围
|
// 首次加载时,使用更大的初始渲染范围
|
||||||
if (isInitialLoad.value && scrollLeft === 0) {
|
if (isInitialLoad.value && scrollLeft === 0) {
|
||||||
@@ -734,17 +738,25 @@ const optimizedTimelineData = computed(() => {
|
|||||||
if (currentTimeScale.value === TimelineScale.HOUR && Array.isArray(cachedData)) {
|
if (currentTimeScale.value === TimelineScale.HOUR && Array.isArray(cachedData)) {
|
||||||
const { startHour, endHour } = visibleHourRange.value
|
const { startHour, endHour } = visibleHourRange.value
|
||||||
|
|
||||||
return (cachedData as any[])
|
// 🚀 性能优化:只处理可见范围内的天数,而不是遍历全部365天
|
||||||
.map((day: any) => {
|
const dayStart = new Date(timelineConfig.value.startDate)
|
||||||
// 计算当前天相对于时间线开始的小时偏移
|
dayStart.setHours(0, 0, 0, 0)
|
||||||
const dayStart = new Date(timelineConfig.value.startDate)
|
|
||||||
dayStart.setHours(0, 0, 0, 0)
|
// 计算可见范围对应的起始和结束天数
|
||||||
const currentDay = new Date(day.year, day.month - 1, day.day)
|
const startDay = Math.floor(startHour / 24)
|
||||||
currentDay.setHours(0, 0, 0, 0)
|
const endDay = Math.ceil(endHour / 24)
|
||||||
const daysDiff = Math.floor(
|
|
||||||
(currentDay.getTime() - dayStart.getTime()) / (1000 * 60 * 60 * 24),
|
// 只处理可见天数范围 + 少量缓冲
|
||||||
)
|
const visibleDays = (cachedData as any[]).slice(
|
||||||
const totalHourOffset = daysDiff * 24
|
Math.max(0, startDay - 1),
|
||||||
|
Math.min(cachedData.length, endDay + 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
return visibleDays
|
||||||
|
.map((day: any, index: number) => {
|
||||||
|
// 使用相对于切片起始的索引计算偏移
|
||||||
|
const actualDayIndex = Math.max(0, startDay - 1) + index
|
||||||
|
const totalHourOffset = actualDayIndex * 24
|
||||||
|
|
||||||
// 计算当前天应该显示的小时范围
|
// 计算当前天应该显示的小时范围
|
||||||
const dayStartHour = Math.max(0, startHour - totalHourOffset)
|
const dayStartHour = Math.max(0, startHour - totalHourOffset)
|
||||||
@@ -763,6 +775,7 @@ const optimizedTimelineData = computed(() => {
|
|||||||
dayStartHour,
|
dayStartHour,
|
||||||
dayEndHour,
|
dayEndHour,
|
||||||
visibleRange: { startHour, endHour },
|
visibleRange: { startHour, endHour },
|
||||||
|
actualDayIndex,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -813,9 +826,18 @@ const totalTimelineWidth = computed(() => {
|
|||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 使用 watch 同步计算出的时间轴宽度到 scrollWidth 缓存
|
||||||
|
// 避免读取 DOM 的 scrollWidth 属性
|
||||||
|
// 注意:不使用 immediate: true,避免在初始化时出现函数未定义的问题
|
||||||
|
// 会在 onMounted 中手动初始化一次
|
||||||
|
watch(totalTimelineWidth, (newWidth) => {
|
||||||
|
timelineScrollWidth.value = newWidth
|
||||||
|
})
|
||||||
|
|
||||||
// 容器高度状态管理
|
// 容器高度状态管理
|
||||||
const timelineBodyHeight = ref(0)
|
const timelineBodyHeight = ref(0)
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
let containerResizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
// 里程碑位置信息管理(用于推挤效果)
|
// 里程碑位置信息管理(用于推挤效果)
|
||||||
const milestonePositions = ref<
|
const milestonePositions = ref<
|
||||||
@@ -940,21 +962,15 @@ const getOtherMilestonesInfo = (currentId: number) => {
|
|||||||
// 处理拖拽开始事件
|
// 处理拖拽开始事件
|
||||||
const handleSplitterDragStart = () => {
|
const handleSplitterDragStart = () => {
|
||||||
isSplitterDragging.value = true
|
isSplitterDragging.value = true
|
||||||
|
|
||||||
|
// ⚠️ 拖拽期间暂停ResizeObserver,避免高频触发
|
||||||
|
// ResizeObserver已经在回调中检查isSplitterDragging,这里作为双重保护
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理拖拽结束事件
|
// 处理拖拽结束事件
|
||||||
const handleSplitterDragEnd = () => {
|
const handleSplitterDragEnd = () => {
|
||||||
isSplitterDragging.value = false
|
isSplitterDragging.value = false
|
||||||
|
|
||||||
// 拖拽结束后,手动触发一次容器宽度更新
|
|
||||||
const timelineContainer = document.querySelector('.timeline') as HTMLElement
|
|
||||||
if (timelineContainer) {
|
|
||||||
const newWidth = timelineContainer.clientWidth
|
|
||||||
if (Math.abs(newWidth - timelineContainerWidth.value) > 1) {
|
|
||||||
timelineContainerWidth.value = newWidth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Splitter拖拽结束后,强制重新计算半圆显示状态
|
// Splitter拖拽结束后,强制重新计算半圆显示状态
|
||||||
// 因为Timeline容器宽度可能发生了变化
|
// 因为Timeline容器宽度可能发生了变化
|
||||||
hideBubbles.value = true
|
hideBubbles.value = true
|
||||||
@@ -966,7 +982,6 @@ const handleSplitterDragEnd = () => {
|
|||||||
// 处理Timeline容器resize事件(如TaskList切换等)
|
// 处理Timeline容器resize事件(如TaskList切换等)
|
||||||
const handleTimelineContainerResized = () => {
|
const handleTimelineContainerResized = () => {
|
||||||
// Timeline容器大小发生变化,需要强制重新计算半圆显示状态
|
// Timeline容器大小发生变化,需要强制重新计算半圆显示状态
|
||||||
|
|
||||||
// 立即隐藏半圆,让TaskBar重新计算边界
|
// 立即隐藏半圆,让TaskBar重新计算边界
|
||||||
hideBubbles.value = true
|
hideBubbles.value = true
|
||||||
|
|
||||||
@@ -1061,7 +1076,7 @@ const handleMilestoneUpdate = (updatedMilestone: Milestone) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成时间轴数据
|
// 生成时间轴数据
|
||||||
const generateTimelineData = (): any => {
|
function generateTimelineData(): any {
|
||||||
// 使用缓存版本提升性能
|
// 使用缓存版本提升性能
|
||||||
return getCachedTimelineData()
|
return getCachedTimelineData()
|
||||||
}
|
}
|
||||||
@@ -1072,7 +1087,7 @@ const clearTimelineCache = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成日视图时间轴数据 (原有逻辑)
|
// 生成日视图时间轴数据 (原有逻辑)
|
||||||
const generateDayTimelineData = () => {
|
function generateDayTimelineData() {
|
||||||
const months: unknown[] = []
|
const months: unknown[] = []
|
||||||
const currentDate = new Date(timelineConfig.value.startDate)
|
const currentDate = new Date(timelineConfig.value.startDate)
|
||||||
|
|
||||||
@@ -1113,7 +1128,7 @@ const generateDayTimelineData = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 判断是否为工作时间
|
// 判断是否为工作时间
|
||||||
const isWorkingHour = (hour: number, dayOfWeek: number) => {
|
function isWorkingHour(hour: number, dayOfWeek: number) {
|
||||||
// 周末(周六=6,周日=0)直接返回false,保持周末样式
|
// 周末(周六=6,周日=0)直接返回false,保持周末样式
|
||||||
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||||||
return false
|
return false
|
||||||
@@ -1142,7 +1157,7 @@ const isWorkingHour = (hour: number, dayOfWeek: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成小时视图时间轴数据
|
// 生成小时视图时间轴数据
|
||||||
const generateHourTimelineData = () => {
|
function generateHourTimelineData() {
|
||||||
const days: unknown[] = []
|
const days: unknown[] = []
|
||||||
const currentDate = new Date(timelineConfig.value.startDate)
|
const currentDate = new Date(timelineConfig.value.startDate)
|
||||||
|
|
||||||
@@ -1184,7 +1199,7 @@ const generateHourTimelineData = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成周视图时间轴数据
|
// 生成周视图时间轴数据
|
||||||
const generateWeekTimelineData = () => {
|
function generateWeekTimelineData() {
|
||||||
const allWeeks: unknown[] = []
|
const allWeeks: unknown[] = []
|
||||||
// 首先生成所有周
|
// 首先生成所有周
|
||||||
const startDate = new Date(timelineConfig.value.startDate)
|
const startDate = new Date(timelineConfig.value.startDate)
|
||||||
@@ -1251,7 +1266,7 @@ const generateWeekTimelineData = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成一周内的7个子列(用于精确定位)
|
// 生成一周内的7个子列(用于精确定位)
|
||||||
const generateSubDaysForWeek = (weekStart: Date) => {
|
function generateSubDaysForWeek(weekStart: Date) {
|
||||||
const subDays = []
|
const subDays = []
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
const date = new Date(weekStart)
|
const date = new Date(weekStart)
|
||||||
@@ -1265,7 +1280,7 @@ const generateSubDaysForWeek = (weekStart: Date) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 判断周是否包含今天
|
// 判断周是否包含今天
|
||||||
const isWeekContainsToday = (weekStart: Date, weekEnd: Date) => {
|
function isWeekContainsToday(weekStart: Date, weekEnd: Date) {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
return today >= weekStart && today <= weekEnd
|
return today >= weekStart && today <= weekEnd
|
||||||
@@ -1400,7 +1415,7 @@ const updateTimeScale = (scale: TimelineScale) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 判断是否为今天
|
// 判断是否为今天
|
||||||
const isToday = (date: Date) => {
|
function isToday(date: Date) {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
return (
|
return (
|
||||||
date.getDate() === today.getDate() &&
|
date.getDate() === today.getDate() &&
|
||||||
@@ -1632,11 +1647,17 @@ const scrollToTodayCenter = (retry = 0) => {
|
|||||||
todayPosition = daysDiff * dayWidth.value
|
todayPosition = daysDiff * dayWidth.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先查找 .timeline-body 作为滚动容器,否则回退到 .timeline
|
// 使用缓存的 timelineContainer 和 containerWidth,避免 querySelector 和 clientWidth 读取造成强制重排
|
||||||
const scrollContainer = document.querySelector('.timeline') as HTMLElement
|
if (!timelineContainer.value) {
|
||||||
|
// 容器未准备好,递归重试
|
||||||
|
if (retry < 10) {
|
||||||
|
setTimeout(() => scrollToTodayCenter(retry + 1), 60)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const containerWidth = scrollContainer.clientWidth
|
const containerWidth = timelineContainerWidth.value
|
||||||
// 若宽度为0,递归重试,最多10次
|
// 若宽度为0,说明 ResizeObserver 还未触发,递归重试,最多10次
|
||||||
if (containerWidth === 0 && retry < 10) {
|
if (containerWidth === 0 && retry < 10) {
|
||||||
setTimeout(() => scrollToTodayCenter(retry + 1), 60)
|
setTimeout(() => scrollToTodayCenter(retry + 1), 60)
|
||||||
return
|
return
|
||||||
@@ -1644,10 +1665,10 @@ const scrollToTodayCenter = (retry = 0) => {
|
|||||||
|
|
||||||
// 计算将今日列置于中间的滚动位置
|
// 计算将今日列置于中间的滚动位置
|
||||||
const centeredScrollPosition = todayPosition - containerWidth / 2 + 15
|
const centeredScrollPosition = todayPosition - containerWidth / 2 + 15
|
||||||
if (typeof scrollContainer.scrollTo === 'function') {
|
if (typeof timelineContainer.value.scrollTo === 'function') {
|
||||||
scrollContainer.scrollTo({ left: Math.max(0, centeredScrollPosition), behavior: 'smooth' })
|
timelineContainer.value.scrollTo({ left: Math.max(0, centeredScrollPosition), behavior: 'smooth' })
|
||||||
} else {
|
} else {
|
||||||
scrollContainer.scrollLeft = Math.max(0, centeredScrollPosition)
|
timelineContainer.value.scrollLeft = Math.max(0, centeredScrollPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动结束后延迟显示半圆,并标记初始化完成
|
// 滚动结束后延迟显示半圆,并标记初始化完成
|
||||||
@@ -1772,15 +1793,13 @@ const scrollToTasks = () => {
|
|||||||
}
|
}
|
||||||
totalDays += day
|
totalDays += day
|
||||||
|
|
||||||
// 计算滚动位置(每个日期30px宽度)
|
// 使用缓存的容器宽度,避免强制重排
|
||||||
const timelinePanel = document.querySelector('.gantt-panel-right')
|
const containerWidth = timelineContainerWidth.value || 400 // 默认值以防还未初始化
|
||||||
const timelinePanelW = timelinePanel?.clientWidth
|
const scrollPosition = (totalDays - 1) * 30 - containerWidth / 2
|
||||||
const scrollPosition = (totalDays - 1) * 30 - (timelinePanelW ? timelinePanelW / 2 : 200)
|
|
||||||
|
|
||||||
// 滚动到指定位置
|
// 使用缓存的 timelineContainer,避免 querySelector
|
||||||
const timeline = document.querySelector('.timeline')
|
if (timelineContainer.value) {
|
||||||
if (timeline) {
|
timelineContainer.value.scrollLeft = Math.max(0, scrollPosition)
|
||||||
timeline.scrollLeft = Math.max(0, scrollPosition)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1809,17 +1828,16 @@ const scrollToToday = () => {
|
|||||||
// 计算今天在时间线中的像素位置(每天30px宽度)
|
// 计算今天在时间线中的像素位置(每天30px宽度)
|
||||||
const todayPosition = daysDiff * 30
|
const todayPosition = daysDiff * 30
|
||||||
|
|
||||||
// 获取时间线容器宽度
|
// 使用缓存的 timelineContainer 和 containerWidth,避免强制重排
|
||||||
const timeline = document.querySelector('.timeline') as HTMLElement
|
if (!timelineContainer.value) return
|
||||||
if (!timeline) return
|
|
||||||
|
|
||||||
const containerWidth = timeline.clientWidth
|
const containerWidth = timelineContainerWidth.value
|
||||||
|
|
||||||
// 计算居中滚动位置
|
// 计算居中滚动位置
|
||||||
const centeredScrollPosition = todayPosition - containerWidth / 2 + 15
|
const centeredScrollPosition = todayPosition - containerWidth / 2 + 15
|
||||||
|
|
||||||
// 滚动到指定位置,确保今日列在中间
|
// 滚动到指定位置,确保今日列在中间
|
||||||
timeline.scrollTo({
|
timelineContainer.value.scrollTo({
|
||||||
left: Math.max(0, centeredScrollPosition),
|
left: Math.max(0, centeredScrollPosition),
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
})
|
})
|
||||||
@@ -1878,12 +1896,17 @@ const bodyContentRef = ref<HTMLElement | null>(null)
|
|||||||
const svgWidth = ref(0)
|
const svgWidth = ref(0)
|
||||||
const svgHeight = ref(0)
|
const svgHeight = ref(0)
|
||||||
|
|
||||||
|
// ResizeObserver 用于监听 bodyContent 宽度变化
|
||||||
|
let bodyContentResizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
// 缓存 bodyContent 的位置,避免频繁调用 getBoundingClientRect
|
||||||
|
const bodyContentPosition = ref({ left: 0, top: 0, timestamp: 0 })
|
||||||
|
const BODY_POSITION_CACHE_TTL = 100 // 100ms 缓存有效期
|
||||||
|
|
||||||
function updateSvgSize() {
|
function updateSvgSize() {
|
||||||
if (bodyContentRef.value) {
|
// 宽度已经通过 ResizeObserver 自动更新,这里只需要更新高度
|
||||||
svgWidth.value = bodyContentRef.value.offsetWidth
|
// 使用计算的内容高度,确保SVG覆盖所有任务行
|
||||||
// 使用计算的内容高度,确保SVG覆盖所有任务行
|
svgHeight.value = contentHeight.value
|
||||||
svgHeight.value = contentHeight.value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBarMounted(payload: {
|
function handleBarMounted(payload: {
|
||||||
@@ -1894,11 +1917,33 @@ function handleBarMounted(payload: {
|
|||||||
height: number
|
height: number
|
||||||
}) {
|
}) {
|
||||||
if (!bodyContentRef.value) return
|
if (!bodyContentRef.value) return
|
||||||
const baseRect = bodyContentRef.value.getBoundingClientRect()
|
|
||||||
|
const now = Date.now()
|
||||||
|
let baseLeft = 0
|
||||||
|
let baseTop = 0
|
||||||
|
|
||||||
|
// 检查缓存是否有效
|
||||||
|
if (now - bodyContentPosition.value.timestamp < BODY_POSITION_CACHE_TTL) {
|
||||||
|
// 使用缓存的位置
|
||||||
|
baseLeft = bodyContentPosition.value.left
|
||||||
|
baseTop = bodyContentPosition.value.top
|
||||||
|
} else {
|
||||||
|
// 缓存过期,读取 DOM 并更新缓存
|
||||||
|
const baseRect = bodyContentRef.value.getBoundingClientRect()
|
||||||
|
baseLeft = baseRect.left
|
||||||
|
baseTop = baseRect.top
|
||||||
|
|
||||||
|
bodyContentPosition.value = {
|
||||||
|
left: baseLeft,
|
||||||
|
top: baseTop,
|
||||||
|
timestamp: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 统一坐标系:以bodyContent为基准
|
// 统一坐标系:以bodyContent为基准
|
||||||
taskBarPositions.value[payload.id] = {
|
taskBarPositions.value[payload.id] = {
|
||||||
left: payload.left - baseRect.left,
|
left: payload.left - baseLeft,
|
||||||
top: payload.top - baseRect.top,
|
top: payload.top - baseTop,
|
||||||
width: payload.width,
|
width: payload.width,
|
||||||
height: payload.height,
|
height: payload.height,
|
||||||
}
|
}
|
||||||
@@ -1930,8 +1975,8 @@ const handleScrollToPosition = (targetScrollLeft: number) => {
|
|||||||
// 开始自动滚动时隐藏半圆
|
// 开始自动滚动时隐藏半圆
|
||||||
hideBubbles.value = true
|
hideBubbles.value = true
|
||||||
|
|
||||||
// 确保滚动位置在有效范围内
|
// 使用缓存的值避免强制重排
|
||||||
const maxScrollLeft = timelineContainer.value.scrollWidth - timelineContainer.value.clientWidth
|
const maxScrollLeft = timelineScrollWidth.value - timelineContainerWidth.value
|
||||||
const clampedScrollLeft = Math.max(0, Math.min(targetScrollLeft, maxScrollLeft))
|
const clampedScrollLeft = Math.max(0, Math.min(targetScrollLeft, maxScrollLeft))
|
||||||
|
|
||||||
// 平滑滚动到目标位置
|
// 平滑滚动到目标位置
|
||||||
@@ -2001,6 +2046,9 @@ const links = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 初始化 timelineScrollWidth
|
||||||
|
timelineScrollWidth.value = totalTimelineWidth.value
|
||||||
|
|
||||||
// 等待下一帧,确保DOM和数据都已渲染
|
// 等待下一帧,确保DOM和数据都已渲染
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -2041,8 +2089,7 @@ onMounted(() => {
|
|||||||
const timelineBody = document.querySelector('.timeline-body') as HTMLElement
|
const timelineBody = document.querySelector('.timeline-body') as HTMLElement
|
||||||
const timelineContainer = document.querySelector('.timeline') as HTMLElement
|
const timelineContainer = document.querySelector('.timeline') as HTMLElement
|
||||||
if (timelineBody) {
|
if (timelineBody) {
|
||||||
timelineBodyHeight.value = timelineBody.clientHeight
|
// 使用ResizeObserver自动更新高度,避免直接读取clientHeight造成强制重排
|
||||||
|
|
||||||
resizeObserver = new ResizeObserver(entries => {
|
resizeObserver = new ResizeObserver(entries => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
timelineBodyHeight.value = entry.contentRect.height
|
timelineBodyHeight.value = entry.contentRect.height
|
||||||
@@ -2054,31 +2101,39 @@ onMounted(() => {
|
|||||||
|
|
||||||
// 初始化滚动位置信息,使用正确的滚动容器
|
// 初始化滚动位置信息,使用正确的滚动容器
|
||||||
if (timelineContainer) {
|
if (timelineContainer) {
|
||||||
timelineScrollLeft.value = timelineContainer.scrollLeft
|
// 使用ResizeObserver自动更新容器宽度,避免直接读取clientWidth造成强制重排
|
||||||
timelineContainerWidth.value = timelineContainer.clientWidth
|
// scrollLeft在第一次scroll事件时会自动更新,这里不需要初始化
|
||||||
|
|
||||||
// 为容器宽度变化创建独立的ResizeObserver
|
// 为容器宽度变化创建独立的ResizeObserver
|
||||||
const containerResizeObserver = new ResizeObserver(entries => {
|
containerResizeObserver = new ResizeObserver(entries => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const newWidth = entry.contentRect.width
|
const newWidth = entry.contentRect.width
|
||||||
// 当容器宽度发生变化时,立即更新宽度并重新计算半圆显示
|
|
||||||
|
// 当容器宽度发生变化时,立即更新宽度
|
||||||
|
// ⚠️ 即使在拖拽期间也要更新,因为TaskBar需要实时响应containerWidth变化
|
||||||
if (Math.abs(newWidth - timelineContainerWidth.value) > 1) {
|
if (Math.abs(newWidth - timelineContainerWidth.value) > 1) {
|
||||||
timelineContainerWidth.value = newWidth
|
timelineContainerWidth.value = newWidth
|
||||||
|
|
||||||
// 对于容器宽度变化,我们需要立即重新计算半圆状态
|
// scrollWidth 会在下一次滚动事件中自动更新
|
||||||
// 短时间隐藏后重新显示,让TaskBar重新计算边界
|
// 不在这里读取,避免触发强制重排
|
||||||
hideBubbles.value = true
|
|
||||||
|
|
||||||
// 清除之前的定时器,避免多次触发冲突
|
// ⚠️ 拖拽期间不触发半圆隐藏/显示动画,避免闪烁
|
||||||
if (hideBubblesTimeout) {
|
if (!isSplitterDragging.value) {
|
||||||
clearTimeout(hideBubblesTimeout)
|
// 对于容器宽度变化,我们需要立即重新计算半圆状态
|
||||||
|
// 短时间隐藏后重新显示,让TaskBar重新计算边界
|
||||||
|
hideBubbles.value = true
|
||||||
|
|
||||||
|
// 清除之前的定时器,避免多次触发冲突
|
||||||
|
if (hideBubblesTimeout) {
|
||||||
|
clearTimeout(hideBubblesTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟恢复显示,确保宽度变化完全生效
|
||||||
|
hideBubblesTimeout = setTimeout(() => {
|
||||||
|
hideBubbles.value = false
|
||||||
|
hideBubblesTimeout = null
|
||||||
|
}, 300) // 增加到300ms,确保resize完全结束
|
||||||
}
|
}
|
||||||
|
|
||||||
// 延迟恢复显示,确保宽度变化完全生效
|
|
||||||
hideBubblesTimeout = setTimeout(() => {
|
|
||||||
hideBubbles.value = false
|
|
||||||
hideBubblesTimeout = null
|
|
||||||
}, 300) // 增加到300ms,确保resize完全结束
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -2088,6 +2143,16 @@ onMounted(() => {
|
|||||||
if (!resizeObserver) {
|
if (!resizeObserver) {
|
||||||
resizeObserver = containerResizeObserver
|
resizeObserver = containerResizeObserver
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听bodyContent宽度变化,避免在updateSvgSize中读取offsetWidth造成强制重排
|
||||||
|
if (bodyContentRef.value) {
|
||||||
|
bodyContentResizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
svgWidth.value = entry.contentRect.width
|
||||||
|
}
|
||||||
|
})
|
||||||
|
bodyContentResizeObserver.observe(bodyContentRef.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2284,14 +2349,15 @@ const handleTimelineScroll = (event: Event) => {
|
|||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
|
||||||
|
// 使用缓存的值避免强制重排,只读取scrollLeft(这是滚动事件必需的)
|
||||||
const scrollLeft = target.scrollLeft
|
const scrollLeft = target.scrollLeft
|
||||||
const scrollWidth = target.scrollWidth
|
const scrollWidth = timelineScrollWidth.value
|
||||||
const clientWidth = target.clientWidth
|
const clientWidth = timelineContainerWidth.value
|
||||||
const maxScroll = scrollWidth - clientWidth
|
const maxScroll = scrollWidth - clientWidth
|
||||||
|
|
||||||
// 立即更新关键滚动位置信息(用于虚拟滚动)
|
// 立即更新关键滚动位置信息(用于虚拟滚动)
|
||||||
timelineScrollLeft.value = scrollLeft
|
timelineScrollLeft.value = scrollLeft
|
||||||
timelineContainerWidth.value = clientWidth
|
// timelineContainerWidth 已经通过ResizeObserver更新,这里不需要再次赋值
|
||||||
|
|
||||||
// 标记初始化完成(第一次滚动后)
|
// 标记初始化完成(第一次滚动后)
|
||||||
if (isInitialLoad.value && scrollLeft > 0) {
|
if (isInitialLoad.value && scrollLeft > 0) {
|
||||||
@@ -2349,7 +2415,8 @@ const startAutoScroll = (direction: 'left' | 'right') => {
|
|||||||
if (!timelineContainer.value || !isAutoScrolling.value) return
|
if (!timelineContainer.value || !isAutoScrolling.value) return
|
||||||
|
|
||||||
const currentScrollLeft = timelineContainer.value.scrollLeft
|
const currentScrollLeft = timelineContainer.value.scrollLeft
|
||||||
const maxScrollLeft = timelineContainer.value.scrollWidth - timelineContainer.value.clientWidth
|
// 使用缓存的值避免强制重排
|
||||||
|
const maxScrollLeft = timelineScrollWidth.value - timelineContainerWidth.value
|
||||||
|
|
||||||
let newScrollLeft
|
let newScrollLeft
|
||||||
if (direction === 'left') {
|
if (direction === 'left') {
|
||||||
@@ -2404,8 +2471,7 @@ const handleDragBoundaryCheck = (event: CustomEvent) => {
|
|||||||
startAutoScroll('left')
|
startAutoScroll('left')
|
||||||
} else if (
|
} else if (
|
||||||
relativeX >= containerRect.width - EDGE_SCROLL_ZONE &&
|
relativeX >= containerRect.width - EDGE_SCROLL_ZONE &&
|
||||||
timelineContainer.value.scrollLeft <
|
timelineContainer.value.scrollLeft < timelineScrollWidth.value - timelineContainerWidth.value
|
||||||
timelineContainer.value.scrollWidth - timelineContainer.value.clientWidth
|
|
||||||
) {
|
) {
|
||||||
// 检查是否在右边界滚动区域
|
// 检查是否在右边界滚动区域
|
||||||
startAutoScroll('right')
|
startAutoScroll('right')
|
||||||
@@ -2442,6 +2508,12 @@ onUnmounted(() => {
|
|||||||
resizeObserver = null
|
resizeObserver = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理bodyContentResizeObserver
|
||||||
|
if (bodyContentResizeObserver) {
|
||||||
|
bodyContentResizeObserver.disconnect()
|
||||||
|
bodyContentResizeObserver = null
|
||||||
|
}
|
||||||
|
|
||||||
// 清理window事件监听器
|
// 清理window事件监听器
|
||||||
window.removeEventListener('resize', updateSvgSize)
|
window.removeEventListener('resize', updateSvgSize)
|
||||||
|
|
||||||
@@ -2580,7 +2652,6 @@ watch(
|
|||||||
(newWidth, oldWidth) => {
|
(newWidth, oldWidth) => {
|
||||||
// ⚠️ 拖拽 splitter 时跳过重新计算,避免频繁生成 timelineData
|
// ⚠️ 拖拽 splitter 时跳过重新计算,避免频繁生成 timelineData
|
||||||
if (isSplitterDragging.value) return
|
if (isSplitterDragging.value) return
|
||||||
|
|
||||||
// 只在容器宽度从 0 变为有效值,或容器宽度发生显著变化时重新计算
|
// 只在容器宽度从 0 变为有效值,或容器宽度发生显著变化时重新计算
|
||||||
if (!oldWidth || oldWidth === 0 || Math.abs(newWidth - oldWidth) > 50) {
|
if (!oldWidth || oldWidth === 0 || Math.abs(newWidth - oldWidth) > 50) {
|
||||||
if (newWidth > 0) {
|
if (newWidth > 0) {
|
||||||
@@ -2620,9 +2691,11 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// 监听timelineData或容器宽度变化,强制TaskBar重新渲染以更新关系线位置
|
// ⚠️ 监听timelineData变化,强制TaskBar重新渲染以更新关系线位置
|
||||||
watch([timelineData, timelineContainerWidth], () => {
|
// 注意:只监听timelineData,不监听timelineContainerWidth
|
||||||
// ⚠️ 拖拽 splitter 时跳过 TaskBar 重新渲染,避免频繁更新
|
// 因为TaskBar会通过computed自动响应containerWidth变化,不需要强制重新渲染
|
||||||
|
watch(timelineData, () => {
|
||||||
|
// 拖拽 splitter 时跳过 TaskBar 重新渲染
|
||||||
if (isSplitterDragging.value) return
|
if (isSplitterDragging.value) return
|
||||||
|
|
||||||
// 清空位置信息
|
// 清空位置信息
|
||||||
@@ -2658,38 +2731,27 @@ watch(
|
|||||||
const handleMilestoneClickLocate = (event: CustomEvent) => {
|
const handleMilestoneClickLocate = (event: CustomEvent) => {
|
||||||
const { scrollLeft, smooth } = event.detail
|
const { scrollLeft, smooth } = event.detail
|
||||||
|
|
||||||
// 获取Timeline容器 - 尝试两个可能的滚动容器
|
// 使用缓存的timelineContainer,避免querySelector
|
||||||
const timelineMain = document.querySelector('.timeline') as HTMLElement
|
if (timelineContainer.value) {
|
||||||
const timelineBody = document.querySelector('.timeline-body') as HTMLElement
|
// 使用缓存的值避免强制重排
|
||||||
|
const maxScrollLeft = timelineScrollWidth.value - timelineContainerWidth.value
|
||||||
// 选择有滚动能力的容器
|
|
||||||
let scrollContainer: HTMLElement | null = null
|
|
||||||
if (timelineMain && timelineMain.scrollWidth > timelineMain.clientWidth) {
|
|
||||||
scrollContainer = timelineMain
|
|
||||||
} else if (timelineBody && timelineBody.scrollWidth > timelineBody.clientWidth) {
|
|
||||||
scrollContainer = timelineBody
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollContainer) {
|
|
||||||
// 确保滚动位置在有效范围内
|
|
||||||
const maxScrollLeft = scrollContainer.scrollWidth - scrollContainer.clientWidth
|
|
||||||
const targetScrollLeft = Math.min(Math.max(0, scrollLeft), maxScrollLeft)
|
const targetScrollLeft = Math.min(Math.max(0, scrollLeft), maxScrollLeft)
|
||||||
|
|
||||||
if (smooth) {
|
if (smooth) {
|
||||||
// 平滑滚动
|
// 平滑滚动
|
||||||
scrollContainer.scrollTo({
|
timelineContainer.value.scrollTo({
|
||||||
left: targetScrollLeft,
|
left: targetScrollLeft,
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 立即滚动
|
// 立即滚动
|
||||||
scrollContainer.scrollLeft = targetScrollLeft
|
timelineContainer.value.scrollLeft = targetScrollLeft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成月度视图时间轴数据
|
// 生成月度视图时间轴数据
|
||||||
const generateMonthTimelineData = () => {
|
function generateMonthTimelineData() {
|
||||||
// 根据时间刻度动态调整时间范围
|
// 根据时间刻度动态调整时间范围
|
||||||
let startDate: Date, endDate: Date
|
let startDate: Date, endDate: Date
|
||||||
|
|
||||||
@@ -2820,7 +2882,7 @@ const generateMonthTimelineData = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成季度视图时间轴数据
|
// 生成季度视图时间轴数据
|
||||||
const generateQuarterTimelineData = () => {
|
function generateQuarterTimelineData() {
|
||||||
// 使用从 GanttChart 传入的日期范围(已包含正确的 buffer 和容器填充逻辑)
|
// 使用从 GanttChart 传入的日期范围(已包含正确的 buffer 和容器填充逻辑)
|
||||||
const startDate = timelineConfig.value.startDate
|
const startDate = timelineConfig.value.startDate
|
||||||
const endDate = timelineConfig.value.endDate
|
const endDate = timelineConfig.value.endDate
|
||||||
@@ -2865,14 +2927,14 @@ const generateQuarterTimelineData = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 判断季度是否包含今天
|
// 判断季度是否包含今天
|
||||||
const isQuarterContainsToday = (startDate: Date, endDate: Date) => {
|
function isQuarterContainsToday(startDate: Date, endDate: Date) {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
return today >= startDate && today <= endDate
|
return today >= startDate && today <= endDate
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成年度视图时间轴数据
|
// 生成年度视图时间轴数据
|
||||||
const generateYearTimelineData = () => {
|
function generateYearTimelineData() {
|
||||||
// 使用从 GanttChart 传入的日期范围(已包含正确的 buffer 和容器填充逻辑)
|
// 使用从 GanttChart 传入的日期范围(已包含正确的 buffer 和容器填充逻辑)
|
||||||
const startDate = timelineConfig.value.startDate
|
const startDate = timelineConfig.value.startDate
|
||||||
const endDate = timelineConfig.value.endDate
|
const endDate = timelineConfig.value.endDate
|
||||||
@@ -3557,7 +3619,7 @@ const handleAddSuccessor = (task: Task) => {
|
|||||||
:container-width="timelineContainerWidth"
|
:container-width="timelineContainerWidth"
|
||||||
:hide-bubbles="hideBubbles"
|
:hide-bubbles="hideBubbles"
|
||||||
:timeline-data="
|
:timeline-data="
|
||||||
currentTimeScale === TimelineScale.HOUR ? optimizedTimelineData : timelineData
|
currentTimeScale === TimelineScale.HOUR ? [] : timelineData
|
||||||
"
|
"
|
||||||
:current-time-scale="currentTimeScale"
|
:current-time-scale="currentTimeScale"
|
||||||
:task-bar-config="props.taskBarConfig"
|
:task-bar-config="props.taskBarConfig"
|
||||||
|
|||||||
Reference in New Issue
Block a user