diff --git a/CHANGELOG.md b/CHANGELOG.md index 662b811..77fd36d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.3] - 2026-03-05 + +### Added +- 🎉 新增:GanttChart 新增 enableTaskDrawerAutoClose 属性,设置为 false 时禁用 TaskDrawer 的自动关闭行为 +- 🎉 Added: GanttChart new prop enableTaskDrawerAutoClose — set to false to disable the automatic closing behavior of the TaskDrawer + +### Fixed +- 🔧 修复:周视图下拖拽 TaskBar 左右移动后任务宽度(持续时间)自动缩短的问题 +- 🔧 Fixed: TaskBar width (duration) shrinking after dragging left and right in weekly view + ## [1.9.2] - 2026-02-26 ### Added diff --git a/README-EN.md b/README-EN.md index b019726..2430ffe 100644 --- a/README-EN.md +++ b/README-EN.md @@ -226,6 +226,7 @@ npm run dev | `showTaskbarTab` ![v1.9.0](https://img.shields.io/badge/v1.9.0-409EFF?style=flat-square&labelColor=ECF5FF) | `boolean` | `true` | Whether to display resource tab on TaskBar (shows resource allocation label on TaskBar in resource view) | | `enableTaskListCollapsible` ![v1.9.2](https://img.shields.io/badge/v1.9.2-409EFF?style=flat-square&labelColor=ECF5FF) | `boolean` | `true` | Whether to allow collapsing/expanding the TaskList panel. When `false`: forcibly hides TaskList, SplitterBar and collapse button; Timeline takes full width | | `taskListVisible` ![v1.9.2](https://img.shields.io/badge/v1.9.2-409EFF?style=flat-square&labelColor=ECF5FF) | `boolean` | `true` | Controls TaskList visibility (reactive). Only effective when `enableTaskListCollapsible=true` | +| `enableTaskDrawerAutoClose` ![v1.9.3](https://img.shields.io/badge/v1.9.3-409EFF?style=flat-square&labelColor=ECF5FF) | `boolean` | `true` | Whether to allow TaskDrawer to auto-close (closes on outside click or Esc key). Set to `false` to disable auto-close — the drawer can only be closed via its internal close button | #### TaskListColumn Component Props @@ -3637,7 +3638,13 @@ View the complete [Contributors list](./CONTRIBUTORS.md)。 --- -## 📄 Open Source License +## � Troubleshooting + +For common issues and solutions, please refer to [Troubleshooting-EN.md](./Troubleshooting-EN.md). + +--- + +## �📄 Open Source License [MIT License](./LICENSE) © 2025 JORDIUM.COM diff --git a/README.md b/README.md index 03807ce..0d9a1e2 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ npm run dev | `showTaskbarTab` ![v1.9.0](https://img.shields.io/badge/v1.9.0-409EFF?style=flat-square&labelColor=ECF5FF) | `boolean` | `true` | 是否显示TaskBar上的资源Tab标签(资源视图下TaskBar的资源分配标签) | | `enableTaskListCollapsible` ![v1.9.2](https://img.shields.io/badge/v1.9.2-409EFF?style=flat-square&labelColor=ECF5FF) | `boolean` | `true` | 是否允许折叠/展开 TaskList 面板。`false` 时强制隐藏 TaskList、SplitterBar 及折叠按钮,Timeline 独占全宽 | | `taskListVisible` ![v1.9.2](https://img.shields.io/badge/v1.9.2-409EFF?style=flat-square&labelColor=ECF5FF) | `boolean` | `true` | 控制 TaskList 的显隐状态(响应式)。仅在 `enableTaskListCollapsible=true` 时有效 | +| `enableTaskDrawerAutoClose` ![v1.9.3](https://img.shields.io/badge/v1.9.3-409EFF?style=flat-square&labelColor=ECF5FF) | `boolean` | `true` | 是否允许 TaskDrawer 自动关闭(外总点击或按 Esc 时自动关闭)。设为 `false` 时禁用自动关闭,仅可通过内部按钮手动关闭 | #### TaskListColumn 属性 @@ -3630,7 +3631,13 @@ TaskList 组件经过深度重构,采用模块化设计,提升了代码可 --- -## 📄 开源协议 +## � Troubleshooting + +常见问题与解决方案请参阅 [Troubleshooting.md](./Troubleshooting.md)。 + +--- + +## �📄 开源协议 [MIT License](./LICENSE) © 2025 JORDIUM.COM diff --git a/Troubleshooting-EN.md b/Troubleshooting-EN.md new file mode 100644 index 0000000..578ba2c --- /dev/null +++ b/Troubleshooting-EN.md @@ -0,0 +1,69 @@ +# Troubleshooting + +Common issues and solutions for `jordium-gantt-vue3`. + +--- + +## Table of Contents + +- [High Memory Usage / Browser Freeze with Large Datasets](#issue-13-high-memory-usage--browser-freeze-with-large-datasets) + +--- + +## Issue #13: High Memory Usage / Browser Freeze with Large Datasets + +**Labels**: `performance`, `answer` +**Status**: ✅ Resolved +**Issue**: [#13 Memory leak and high CPU use](https://github.com/nelson820125/jordium-gantt-vue3/issues/13) → converted to Discussion [#16](https://github.com/nelson820125/jordium-gantt-vue3/discussions/16) + +### Symptoms + +When loading ~80+ tasks, the page becomes severely sluggish, memory grows continuously, and the browser may crash. + +### Root Cause + +The problem is **not in the component itself**, but in how the caller binds data: + +1. **Using `reactive` to deeply wrap the task array** — Vue creates deep watchers for every object in the array. With a large dataset, the number of watchers explodes and memory can exceed 10 GB. +2. **Incorrect date format** — The API returns ISO datetime strings (e.g. `"2026-03-07T08:34:00.230"`), but the component expects plain date strings (`"2026-03-07"`). + +### Solution (credit: [@eldrift](https://github.com/eldrift)) + +#### 1. Use `shallowRef` instead of `reactive` to store the task list + +```ts +// ❌ Wrong — deep reactivity, performance disaster +const data = reactive({ tasks: [] as Task[] }) + +// ✅ Correct — Vue only watches the array reference, not each object deeply +const tasks = shallowRef([]) +``` + +#### 2. Truncate ISO datetime strings to plain date format + +```ts +tasks.value = res.data.map((t: Task) => ({ + ...t, + startDate: t.startDate?.substring(0, 10) ?? t.startDate, + endDate: t.endDate?.substring(0, 10) ?? t.endDate, +})) +``` + +#### 3. Use the correct Task type + +Ensure the data passed to the component conforms to the `Task` interface to avoid hidden computation overhead from type mismatches. + +### References + +- Full discussion & report: [Discussion #16](https://github.com/nelson820125/jordium-gantt-vue3/discussions/16) +- Original issue: [#13 Memory leak and high CPU use](https://github.com/nelson820125/jordium-gantt-vue3/issues/13) + +--- + +## Feedback + +If you encounter an issue not listed here, feel free to submit one: + +- [GitHub Issues](https://github.com/nelson820125/jordium-gantt-vue3/issues) +- [Gitee Issues](https://gitee.com/jordium/jordium-gantt-vue3/issues) +- 📧 ning.li@jordium.com diff --git a/Troubleshooting.md b/Troubleshooting.md new file mode 100644 index 0000000..931c261 --- /dev/null +++ b/Troubleshooting.md @@ -0,0 +1,69 @@ +# Troubleshooting + +常见问题与解决方案 / Common issues and solutions. + +--- + +## 目录 / Table of Contents + +- [内存占用过高 / 浏览器卡死(大数据量场景)](#issue-13-内存占用过高--浏览器卡死大数据量场景) + +--- + +## Issue #13: 内存占用过高 / 浏览器卡死(大数据量场景) + +**标签 / Labels**: `performance`, `answer` +**状态 / Status**: ✅ 已解决 / Resolved +**Issue 地址**: [#13 Memory leak and high CPU use](https://github.com/nelson820125/jordium-gantt-vue3/issues/13) → 已转为 Discussion [#16](https://github.com/nelson820125/jordium-gantt-vue3/discussions/16) + +### 现象 + +在加载约 80+ 条任务数据时,页面出现严重卡顿、内存持续增长甚至浏览器崩溃。 + +### 根本原因 + +问题**不在组件本身**,而在于调用方的数据绑定方式: + +1. **使用 `reactive` 深层包裹任务数组** — Vue 会为数组内每一个对象建立深层侦听器,数据量较大时侦听器数量爆炸,导致内存超过 10 GB。 +2. **日期格式错误** — API 返回 ISO 日期时间字符串(如 `"2026-03-07T08:34:00.230"`),但组件要求纯日期格式(`"2026-03-07"`)。 + +### 解决方案(来自 [@eldrift](https://github.com/eldrift)) + +#### 1. 使用 `shallowRef` 替代 `reactive` 存储任务列表 + +```ts +// ❌ 错误 — 深层响应性,性能灾难 +const data = reactive({ tasks: [] as Task[] }) + +// ✅ 正确 — Vue 只监听数组引用,不深入每个对象 +const tasks = shallowRef([]) +``` + +#### 2. 截断 ISO 日期时间为纯日期格式 + +```ts +tasks.value = res.data.map((t: Task) => ({ + ...t, + startDate: t.startDate?.substring(0, 10) ?? t.startDate, + endDate: t.endDate?.substring(0, 10) ?? t.endDate, +})) +``` + +#### 3. 使用正确的 Task 类型 + +确保传入组件的数据符合 `Task` 接口定义,避免类型不匹配导致的隐性计算开销。 + +### 参考 + +- 完整讨论与报告:[Discussion #16](https://github.com/nelson820125/jordium-gantt-vue3/discussions/16) +- 原始 Issue:[#13 Memory leak and high CPU use](https://github.com/nelson820125/jordium-gantt-vue3/issues/13) + +--- + +## 反馈 / Feedback + +如果遇到未收录的问题,欢迎提交 Issue: + +- [GitHub Issues](https://github.com/nelson820125/jordium-gantt-vue3/issues) +- [Gitee Issues](https://gitee.com/jordium/jordium-gantt-vue3/issues) +- 📧 ning.li@jordium.com diff --git a/demo/App.vue b/demo/App.vue index e4ccafc..8a89321 100644 --- a/demo/App.vue +++ b/demo/App.vue @@ -2377,6 +2377,7 @@ const handleCustomMenuAction = (action: string, task: Task) => { :complete-task-background-color="completeTaskBackgroundColor" :ongoing-task-background-color="ongoingTaskBackgroundColor" :use-default-drawer="useDefaultDrawer" + :enable-task-drawer-auto-close="false" @milestone-saved="handleMilestoneSaved" @milestone-deleted="handleMilestoneDeleted" @milestone-icon-changed="handleMilestoneIconChanged" diff --git a/demo/version-history.json b/demo/version-history.json index 17638d0..d20246f 100644 --- a/demo/version-history.json +++ b/demo/version-history.json @@ -576,5 +576,17 @@ "🔧 Fixed: GanttToolbar showViewMode:false had no effect due to missing v-if guard", "Special thanks to Andrés Jorge from Berlin, Germany for their use and feedback" ] + }, + { + "version": "1.9.3", + "date": "2026-03-05", + "notes": [ + "🎉 新增:GanttChart 新增 enableTaskDrawerAutoClose 属性,设置为 false 时禁用 TaskDrawer 的自动关闭行为", + "🔧 修复:周视图下拖拽 TaskBar 左右移动后任务宽度(持续时间)自动缩短的问题", + "特别感谢来自xuhang513@gitee的使用与反馈", + "🎉 Added: GanttChart new prop enableTaskDrawerAutoClose — set to false to disable the automatic closing behavior of the TaskDrawer", + "🔧 Fixed: TaskBar width (duration) shrinking after dragging left and right in weekly view", + "Special thanks to xuhang513@gitee for their use and feedback" + ] } ] diff --git a/package.json b/package.json index 3cc6b2e..3abb684 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jordium-gantt-vue3", - "version": "1.9.2", + "version": "1.9.3", "type": "module", "main": "npm-package/dist/jordium-gantt-vue3.cjs.js", "module": "npm-package/dist/jordium-gantt-vue3.es.js", diff --git a/src/components/GanttChart.vue b/src/components/GanttChart.vue index 5dda254..3425902 100644 --- a/src/components/GanttChart.vue +++ b/src/components/GanttChart.vue @@ -77,6 +77,7 @@ const props = withDefaults(defineProps(), { theme: undefined, // 不设置默认值,允许自动检测系统主题 enableTaskListCollapsible: true, taskListVisible: true, + enableTaskDrawerAutoClose: true, }) const emit = defineEmits([ @@ -559,6 +560,12 @@ interface Props { * 可通过 prop 传入或通过 defineExpose 暴露的方法控制 */ taskListVisible?: boolean + /** + * 是否允许点击遮罩层关闭 TaskDrawer,默认为 true(保持原有行为) + * 设置为 false 可防止 TaskDrawer 在失去焦点(点击遮罩区域)时自动关闭 + * 仅在 useDefaultDrawer=true 时有效 + */ + enableTaskDrawerAutoClose?: boolean } // TaskList的固定总长度(所有列的最小宽度之和 + 边框等额外空间) @@ -1601,31 +1608,30 @@ const tasksForTimeline = computed(() => { } // 递归更新任务树中所有父任务的时间范围(从叶子节点开始向上) + // 优化:叶子任务直接返回原引用,避免无效 { ...task } 对象创建 + // (后续 smartFlattenTasks 会在 spread 时附加 level,不会污染原对象) const updateParentDateRanges = (tasks: Task[]): Task[] => { return tasks.map(task => { - let updatedTask = { ...task } - - // 先递归更新子任务 - if (task.children && task.children.length > 0) { - updatedTask.children = updateParentDateRanges(task.children) + // 叶子任务:无需创建新对象,直接返回原引用 + if (!task.children || task.children.length === 0) { + return task } - // 基于任务类型判断是否为父任务 - const isParent = - task.type === 'story' || (updatedTask.children && updatedTask.children.length > 0) - updatedTask.isParent = isParent + // 父任务:先递归处理子任务 + const updatedChildren = updateParentDateRanges(task.children) + const isParent = task.type === 'story' || true - // 如果是父任务且有子任务,重新计算时间范围 - if (isParent && updatedTask.children && updatedTask.children.length > 0) { - const { startDate, endDate } = calculateParentDateRange(updatedTask) - updatedTask = { - ...updatedTask, - startDate, - endDate, - } + // 重新计算父任务时间范围 + const taskWithChildren = { ...task, children: updatedChildren } + const { startDate, endDate } = calculateParentDateRange(taskWithChildren) + + return { + ...task, + children: updatedChildren, + isParent, + startDate, + endDate, } - - return updatedTask }) } @@ -3530,6 +3536,7 @@ defineExpose({ :delay-task-background-color="props.delayTaskBackgroundColor" :complete-task-background-color="props.completeTaskBackgroundColor" :ongoing-task-background-color="props.ongoingTaskBackgroundColor" + :enable-close-on-overlay-click="props.enableTaskDrawerAutoClose" @submit="handleTaskDrawerSubmit" @close="taskDrawerVisible = false" @start-timer="handleStartTimer" diff --git a/src/components/TaskBar.vue b/src/components/TaskBar.vue index bff5006..7d1eb6a 100644 --- a/src/components/TaskBar.vue +++ b/src/components/TaskBar.vue @@ -1569,8 +1569,14 @@ const handleMouseMove = (e: MouseEvent) => { } else { // 其他情况:使用原有的简单计算 const newStartDate = addDaysToLocalDate(props.startDate, newLeft / props.dayWidth) - const duration = dragStartWidth.value / props.dayWidth - const newEndDate = addDaysToLocalDate(newStartDate, duration - 1) + + // 从原始任务日期计算持续天数,避免从像素宽度反推(dragStartWidth / dayWidth) + // 导致的精度损失(WEEK视图 dayWidth=60/7 非整数,parseInt截断后往返计算误差会缩短任务) + const originalStartDate = createLocalDate(props.task.startDate) || props.startDate + const originalEndDate = createLocalDate(props.task.endDate) || props.startDate + const durationMs = originalEndDate.getTime() - originalStartDate.getTime() + const duration = Math.round(durationMs / (1000 * 60 * 60 * 24)) + const newEndDate = addDaysToLocalDate(newStartDate, duration) // 只更新临时数据,不触发事件 tempTaskData.value = { diff --git a/src/components/TaskDrawer.vue b/src/components/TaskDrawer.vue index 1fe1c11..0a5f3d3 100644 --- a/src/components/TaskDrawer.vue +++ b/src/components/TaskDrawer.vue @@ -27,6 +27,8 @@ interface Props { delayTaskBackgroundColor?: string completeTaskBackgroundColor?: string ongoingTaskBackgroundColor?: string + /** 是否允许点击遮罩层关闭抽屉,默认为 true(保持原有行为)。设置为 false 可防止失去焦点时自动关闭 */ + enableCloseOnOverlayClick?: boolean } const props = withDefaults(defineProps(), { @@ -45,6 +47,7 @@ const props = withDefaults(defineProps(), { delayTaskBackgroundColor: '#f56c6c', completeTaskBackgroundColor: '#67c23a', ongoingTaskBackgroundColor: '#409eff', + enableCloseOnOverlayClick: true, }) const emit = defineEmits<{ @@ -484,7 +487,9 @@ const handleClose = () => { // 点击遮罩层关闭 const handleOverlayClick = () => { - handleClose() + if (props.enableCloseOnOverlayClick) { + handleClose() + } } // 提交表单 diff --git a/src/components/TaskList/composables/taskList/useTaskListLayout.ts b/src/components/TaskList/composables/taskList/useTaskListLayout.ts index 05c04fd..d280962 100644 --- a/src/components/TaskList/composables/taskList/useTaskListLayout.ts +++ b/src/components/TaskList/composables/taskList/useTaskListLayout.ts @@ -65,41 +65,31 @@ export function useTaskListLayout(tasks: Ref) { const flattenedTasks = computed(() => getFlattenedVisibleTasks(tasks.value)) /** - * v1.9.0 计算每个任务/资源的累计高度位置(支持动态行高) + * 资源视图专用:累计高度数组(动态行高,需要数组+二分查找) + * 任务视图固定行高,直接用公式计算,不构造此数组,避免 O(n) 分配 */ const cumulativeHeights = computed(() => { - if (viewMode.value === 'resource') { - // 资源视图:使用每个资源的实际高度 - const resources = dataSource.value as Resource[] - const heights: number[] = [0] // 第一个位置是0 - let cumulative = 0 + if (viewMode.value !== 'resource') return [] - resources.forEach(resource => { - const layout = resourceTaskLayouts.value.get(resource.id) - const height = layout?.totalHeight || ROW_HEIGHT - cumulative += height - heights.push(cumulative) - }) - - return heights - } else { - // 任务视图:使用固定行高 - const heights: number[] = [0] - for (let i = 1; i <= flattenedTasks.value.length; i++) { - heights.push(i * ROW_HEIGHT) - } - return heights - } + const resources = dataSource.value as Resource[] + const heights: number[] = [0] + let cumulative = 0 + resources.forEach(resource => { + const layout = resourceTaskLayouts.value.get(resource.id) + const height = layout?.totalHeight || ROW_HEIGHT + cumulative += height + heights.push(cumulative) + }) + return heights }) /** - * 根据滚动位置查找对应的任务/资源索引(二分查找) + * 资源视图专用:根据滚动位置查找资源索引(二分查找) */ const findIndexByScrollTop = (scrollTop: number): number => { const heights = cumulativeHeights.value let left = 0 let right = heights.length - 1 - while (left < right) { const mid = Math.floor((left + right) / 2) if (heights[mid] <= scrollTop) { @@ -108,38 +98,37 @@ export function useTaskListLayout(tasks: Ref) { right = mid } } - return Math.max(0, left - 1) } /** - * 计算可视区域任务范围(支持动态行高) + * 计算可视区域任务范围 + * - 任务视图:固定行高,O(1) 公式直接计算(不依赖 cumulativeHeights) + * - 资源视图:动态行高,使用累计高度数组 + 二分查找 */ const visibleTaskRange = computed(() => { const scrollTop = taskListScrollTop.value const containerHeight = taskListBodyHeight.value || 600 - const heights = cumulativeHeights.value - if (heights.length <= 1) { - return { startIndex: 0, endIndex: 0 } - } + if (viewMode.value === 'resource') { + const heights = cumulativeHeights.value + if (heights.length <= 1) return { startIndex: 0, endIndex: 0 } - // 找到起始索引(考虑缓冲区) - let startIndex = findIndexByScrollTop(scrollTop) - startIndex = Math.max(0, startIndex - VERTICAL_BUFFER) - - // 找到结束索引(考虑缓冲区) - const scrollBottom = scrollTop + containerHeight - let endIndex = findIndexByScrollTop(scrollBottom) - endIndex = Math.min(heights.length - 1, endIndex + VERTICAL_BUFFER + 1) - - const total = viewMode.value === 'resource' - ? (dataSource.value as Resource[]).length - : flattenedTasks.value.length - - return { - startIndex: Math.min(startIndex, total), - endIndex: Math.min(endIndex, total), + const total = (dataSource.value as Resource[]).length + let startIndex = Math.max(0, findIndexByScrollTop(scrollTop) - VERTICAL_BUFFER) + const scrollBottom = scrollTop + containerHeight + let endIndex = Math.min(heights.length - 1, findIndexByScrollTop(scrollBottom) + VERTICAL_BUFFER + 1) + return { + startIndex: Math.min(startIndex, total), + endIndex: Math.min(endIndex, total), + } + } else { + // 任务视图:固定行高 ROW_HEIGHT,O(1) 直接计算,不访问 cumulativeHeights + const total = flattenedTasks.value.length + if (total === 0) return { startIndex: 0, endIndex: 0 } + const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER) + const endIndex = Math.min(total, Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER) + return { startIndex, endIndex } } }) @@ -149,7 +138,6 @@ export function useTaskListLayout(tasks: Ref) { const visibleTasks = computed(() => { const { startIndex, endIndex } = visibleTaskRange.value const slicedTasks = flattenedTasks.value.slice(startIndex, endIndex) - // 添加 rowIndex(基于在扁平化列表中的实际索引) return slicedTasks.map((item, index) => ({ ...item, rowIndex: startIndex + index, @@ -157,22 +145,31 @@ export function useTaskListLayout(tasks: Ref) { }) /** - * Spacer 高度用于撑起滚动区域(支持动态行高) + * Spacer 高度撑起滚动区域 + * 任务视图:O(1) 乘法;资源视图:从累计高度数组读取 */ const totalContentHeight = computed(() => { - const heights = cumulativeHeights.value - return heights.length > 0 ? heights[heights.length - 1] : 0 + if (viewMode.value === 'resource') { + const heights = cumulativeHeights.value + return heights.length > 0 ? heights[heights.length - 1] : 0 + } + return flattenedTasks.value.length * ROW_HEIGHT }) const startSpacerHeight = computed(() => { - const startIdx = visibleTaskRange.value.startIndex - return cumulativeHeights.value[startIdx] || 0 + if (viewMode.value === 'resource') { + return cumulativeHeights.value[visibleTaskRange.value.startIndex] || 0 + } + return visibleTaskRange.value.startIndex * ROW_HEIGHT }) const endSpacerHeight = computed(() => { - const endIdx = visibleTaskRange.value.endIndex - const endHeight = cumulativeHeights.value[endIdx] || 0 - return Math.max(0, totalContentHeight.value - endHeight) + if (viewMode.value === 'resource') { + const endIdx = visibleTaskRange.value.endIndex + const endHeight = cumulativeHeights.value[endIdx] || 0 + return Math.max(0, totalContentHeight.value - endHeight) + } + return Math.max(0, (flattenedTasks.value.length - visibleTaskRange.value.endIndex) * ROW_HEIGHT) }) return {