Compare commits
10
Commits
3837621d4c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6c13c7807 | ||
|
|
2d0461319a | ||
|
|
b6de910baa | ||
|
|
389fe60a8f | ||
|
|
c6a7edf502 | ||
|
|
aad991a1ea | ||
|
|
960a3b2e6e | ||
|
|
fb21be2cf9 | ||
|
|
58edf95f5a | ||
|
|
5c93f901ee |
@@ -5,6 +5,36 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.10.0] - 2026-03-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 🔧 优化:优化算法,提升插件性能,更好兼容移动设备的访问
|
||||||
|
- 🔧 优化:来自Github一位用户的共同探讨,带来此次性能的飞跃,同时他也提供了另外一种应用层的优化方案,请参考: [discussion #16](https://github.com/nelson820125/jordium-gantt-vue3/discussions/16)
|
||||||
|
- 🔧 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: [discussion #16](https://github.com/nelson820125/jordium-gantt-vue3/discussions/16)
|
||||||
|
|
||||||
|
## [1.9.4] - 2026-03-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 🔧 修复:日期视图下,SplitterBar拖拽以及TaskList收起/展开后,计算导致2月份周期丢失问题
|
||||||
|
- 🔧 修复:任务视图切换至资源视图后今日定位偏移问题
|
||||||
|
- 🔧 修复:任务视图切换至资源视图后最大和最小日期范围计算问题
|
||||||
|
- 🔧 修复:资源视图下,小时/日/周视图切换后,最大值和最小值范围问题
|
||||||
|
- 🔧 Fixed: In date view, after dragging the SplitterBar and collapsing/expanding the TaskList, the calculation caused the February cycle to be lost
|
||||||
|
- 🔧 Fixed: After switching from task view to resource view, the today marker offset issue
|
||||||
|
- 🔧 Fixed: After switching from task view to resource view, the max and min date range calculation issue
|
||||||
|
- 🔧 Fixed: In resource view, after switching between hourly/daily/weekly views, the max and min date range issue
|
||||||
|
|
||||||
|
## [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
|
## [1.9.2] - 2026-02-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+9
-2
@@ -162,7 +162,7 @@ const milestones = ref([
|
|||||||
{
|
{
|
||||||
id: 101,
|
id: 101,
|
||||||
name: 'Project Approval',
|
name: 'Project Approval',
|
||||||
date: '2025-01-01',
|
startDate: '2025-01-01',
|
||||||
type: 'milestone',
|
type: 'milestone',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -226,6 +226,7 @@ npm run dev
|
|||||||
| `showTaskbarTab`  | `boolean` | `true` | Whether to display resource tab on TaskBar (shows resource allocation label on TaskBar in resource view) |
|
| `showTaskbarTab`  | `boolean` | `true` | Whether to display resource tab on TaskBar (shows resource allocation label on TaskBar in resource view) |
|
||||||
| `enableTaskListCollapsible`  | `boolean` | `true` | Whether to allow collapsing/expanding the TaskList panel. When `false`: forcibly hides TaskList, SplitterBar and collapse button; Timeline takes full width |
|
| `enableTaskListCollapsible`  | `boolean` | `true` | Whether to allow collapsing/expanding the TaskList panel. When `false`: forcibly hides TaskList, SplitterBar and collapse button; Timeline takes full width |
|
||||||
| `taskListVisible`  | `boolean` | `true` | Controls TaskList visibility (reactive). Only effective when `enableTaskListCollapsible=true` |
|
| `taskListVisible`  | `boolean` | `true` | Controls TaskList visibility (reactive). Only effective when `enableTaskListCollapsible=true` |
|
||||||
|
| `enableTaskDrawerAutoClose`  | `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
|
#### 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
|
[MIT License](./LICENSE) © 2025 JORDIUM.COM
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ npm run dev
|
|||||||
| `showTaskbarTab`  | `boolean` | `true` | 是否显示TaskBar上的资源Tab标签(资源视图下TaskBar的资源分配标签) |
|
| `showTaskbarTab`  | `boolean` | `true` | 是否显示TaskBar上的资源Tab标签(资源视图下TaskBar的资源分配标签) |
|
||||||
| `enableTaskListCollapsible`  | `boolean` | `true` | 是否允许折叠/展开 TaskList 面板。`false` 时强制隐藏 TaskList、SplitterBar 及折叠按钮,Timeline 独占全宽 |
|
| `enableTaskListCollapsible`  | `boolean` | `true` | 是否允许折叠/展开 TaskList 面板。`false` 时强制隐藏 TaskList、SplitterBar 及折叠按钮,Timeline 独占全宽 |
|
||||||
| `taskListVisible`  | `boolean` | `true` | 控制 TaskList 的显隐状态(响应式)。仅在 `enableTaskListCollapsible=true` 时有效 |
|
| `taskListVisible`  | `boolean` | `true` | 控制 TaskList 的显隐状态(响应式)。仅在 `enableTaskListCollapsible=true` 时有效 |
|
||||||
|
| `enableTaskDrawerAutoClose`  | `boolean` | `true` | 是否允许 TaskDrawer 自动关闭(外总点击或按 Esc 时自动关闭)。设为 `false` 时禁用自动关闭,仅可通过内部按钮手动关闭 |
|
||||||
|
|
||||||
#### TaskListColumn 属性
|
#### TaskListColumn 属性
|
||||||
|
|
||||||
@@ -3630,7 +3631,13 @@ TaskList 组件经过深度重构,采用模块化设计,提升了代码可
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 开源协议
|
## � Troubleshooting
|
||||||
|
|
||||||
|
常见问题与解决方案请参阅 [Troubleshooting.md](./Troubleshooting.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## �📄 开源协议
|
||||||
|
|
||||||
[MIT License](./LICENSE) © 2025 JORDIUM.COM
|
[MIT License](./LICENSE) © 2025 JORDIUM.COM
|
||||||
|
|
||||||
|
|||||||
@@ -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<Task[]>([])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 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
|
||||||
@@ -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<Task[]>([])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 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
|
||||||
+2
-1
@@ -1312,7 +1312,7 @@ const handleCustomMenuAction = (action: string, task: Task) => {
|
|||||||
</svg>
|
</svg>
|
||||||
Jordium Gantt Vue3 Demo
|
Jordium Gantt Vue3 Demo
|
||||||
<span class="version-badge" style="cursor: pointer" @click="showVersionDrawer = true">{{
|
<span class="version-badge" style="cursor: pointer" @click="showVersionDrawer = true">{{
|
||||||
packageInfo.version
|
packageInfo.version + ' ' + demoMessages.releaseNotes
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="title-center">
|
<!-- <div class="title-center">
|
||||||
@@ -2377,6 +2377,7 @@ const handleCustomMenuAction = (action: string, task: Task) => {
|
|||||||
:complete-task-background-color="completeTaskBackgroundColor"
|
:complete-task-background-color="completeTaskBackgroundColor"
|
||||||
:ongoing-task-background-color="ongoingTaskBackgroundColor"
|
:ongoing-task-background-color="ongoingTaskBackgroundColor"
|
||||||
:use-default-drawer="useDefaultDrawer"
|
:use-default-drawer="useDefaultDrawer"
|
||||||
|
:enable-task-drawer-auto-close="false"
|
||||||
@milestone-saved="handleMilestoneSaved"
|
@milestone-saved="handleMilestoneSaved"
|
||||||
@milestone-deleted="handleMilestoneDeleted"
|
@milestone-deleted="handleMilestoneDeleted"
|
||||||
@milestone-icon-changed="handleMilestoneIconChanged"
|
@milestone-icon-changed="handleMilestoneIconChanged"
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": 1001,
|
||||||
|
"name": "上午会议",
|
||||||
|
"assignee": "测试用户",
|
||||||
|
"startDate": "2026-03-02 09:00",
|
||||||
|
"endDate": "2026-03-10 10:30",
|
||||||
|
"progress": 50,
|
||||||
|
"estimatedHours": 1.5,
|
||||||
|
"actualHours": 0.75,
|
||||||
|
"type": "task"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1002,
|
||||||
|
"name": "午后开发",
|
||||||
|
"assignee": "开发者",
|
||||||
|
"startDate": "2026-03-02 09:00",
|
||||||
|
"endDate": "2026-03-06 16:15",
|
||||||
|
"progress": 30,
|
||||||
|
"estimatedHours": 2.25,
|
||||||
|
"actualHours": 1.33,
|
||||||
|
"type": "task"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1003,
|
||||||
|
"name": "测试任务",
|
||||||
|
"assignee": "测试员",
|
||||||
|
"startDate": "2026-03-02 09:00",
|
||||||
|
"endDate": "2026-03-08 17:45",
|
||||||
|
"progress": 0,
|
||||||
|
"estimatedHours": 1.25,
|
||||||
|
"actualHours": 0,
|
||||||
|
"type": "task"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -148,5 +148,6 @@
|
|||||||
"label": "Enable TaskRow Drag and Drop",
|
"label": "Enable TaskRow Drag and Drop",
|
||||||
"hint": "Allow dragging TaskRow to adjust task hierarchy and order"
|
"hint": "Allow dragging TaskRow to adjust task hierarchy and order"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"releaseNotes": "Click to view release notes"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,5 +148,6 @@
|
|||||||
"label": "启用TaskRow拖拽移动",
|
"label": "启用TaskRow拖拽移动",
|
||||||
"hint": "允许通过拖拽TaskRow来调整任务的层级和顺序"
|
"hint": "允许通过拖拽TaskRow来调整任务的层级和顺序"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"releaseNotes": "点击查看更新日志"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ User-agent: *
|
|||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
# Sitemap location
|
# Sitemap location
|
||||||
Sitemap: https://jordium.gitee.io/jordium-gantt-vue3/sitemap.xml
|
Sitemap: https://nelson820125.github.io/jordium-gantt-vue3/sitemap.xml
|
||||||
|
|
||||||
# Crawl-delay for polite crawling
|
# Crawl-delay for polite crawling
|
||||||
Crawl-delay: 1
|
Crawl-delay: 1
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url>
|
<url>
|
||||||
<loc>https://jordium.gitee.io/jordium-gantt-vue3/</loc>
|
<loc>https://nelson820125.github.io/jordium-gantt-vue3/</loc>
|
||||||
<lastmod>2025-12-14</lastmod>
|
<lastmod>2026-02-26</lastmod>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>1.6.1</priority>
|
<priority>1.9.2</priority>
|
||||||
</url>
|
</url>
|
||||||
</urlset>
|
</urlset>
|
||||||
|
|||||||
@@ -576,5 +576,47 @@
|
|||||||
"🔧 Fixed: GanttToolbar showViewMode:false had no effect due to missing v-if guard",
|
"🔧 Fixed: GanttToolbar showViewMode:false had no effect due to missing v-if guard",
|
||||||
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to Andrés Jorge from Berlin, Germany for their use and feedback</span>"
|
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to Andrés Jorge from Berlin, Germany for their use and feedback</span>"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.9.3",
|
||||||
|
"date": "2026-03-05",
|
||||||
|
"notes": [
|
||||||
|
"🎉 新增:GanttChart 新增 enableTaskDrawerAutoClose 属性,设置为 false 时禁用 TaskDrawer 的自动关闭行为",
|
||||||
|
"🔧 修复:周视图下拖拽 TaskBar 左右移动后任务宽度(持续时间)自动缩短的问题",
|
||||||
|
"<span style=\"font-weight: bold; color: #f00;\">特别感谢来自xuhang513@gitee的使用与反馈</span>",
|
||||||
|
"🎉 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",
|
||||||
|
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to xuhang513@gitee for their use and feedback</span>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.9.4",
|
||||||
|
"date": "2026-03-06",
|
||||||
|
"notes": [
|
||||||
|
"🔧 修复:日期视图下,SplitterBar拖拽以及TaskList收起/展开后,计算导致2月份周期丢失问题",
|
||||||
|
"🔧 修复:任务视图切换至资源视图后今日定位偏移问题",
|
||||||
|
"🔧 修复:任务视图切换至资源视图后最大和最小日期范围计算问题",
|
||||||
|
"🔧 修复:资源视图下,小时/日/周视图切换后,最大值和最小值范围问题",
|
||||||
|
"🔧 修复:包弱点处理,jspdf增加了依赖包dompurify,用于HTML的XSS消毒",
|
||||||
|
"<span style=\"font-weight: bold; color: #f00;\">特别感谢来自jwuxin@gitee的使用与反馈</span>",
|
||||||
|
"🔧 Fixed: In date view, after dragging the SplitterBar and collapsing/expanding the TaskList, the calculation caused the February cycle to be lost",
|
||||||
|
"🔧 Fixed: After switching from task view to resource view, the today marker offset issue",
|
||||||
|
"🔧 Fixed: After switching from task view to resource view, the max and min date range calculation issue",
|
||||||
|
"🔧 Fixed: In resource view, after switching between hourly/daily/weekly views, the max and min date range issue",
|
||||||
|
"🔧 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.10.0",
|
||||||
|
"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>"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+698
-299
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jordium-gantt-vue3",
|
"name": "jordium-gantt-vue3",
|
||||||
"version": "1.9.2",
|
"version": "1.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "npm-package/dist/jordium-gantt-vue3.cjs.js",
|
"main": "npm-package/dist/jordium-gantt-vue3.cjs.js",
|
||||||
"module": "npm-package/dist/jordium-gantt-vue3.es.js",
|
"module": "npm-package/dist/jordium-gantt-vue3.es.js",
|
||||||
@@ -75,13 +75,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.3.2",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^4.0.0",
|
"jspdf": "^4.0.0",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||||
"@typescript-eslint/parser": "^6.21.0",
|
"@typescript-eslint/parser": "^8.56.1",
|
||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
"@vitest/coverage-v8": "^4.0.16",
|
"@vitest/coverage-v8": "^4.0.16",
|
||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
theme: undefined, // 不设置默认值,允许自动检测系统主题
|
theme: undefined, // 不设置默认值,允许自动检测系统主题
|
||||||
enableTaskListCollapsible: true,
|
enableTaskListCollapsible: true,
|
||||||
taskListVisible: true,
|
taskListVisible: true,
|
||||||
|
enableTaskDrawerAutoClose: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -559,6 +560,12 @@ interface Props {
|
|||||||
* 可通过 prop 传入或通过 defineExpose 暴露的方法控制
|
* 可通过 prop 传入或通过 defineExpose 暴露的方法控制
|
||||||
*/
|
*/
|
||||||
taskListVisible?: boolean
|
taskListVisible?: boolean
|
||||||
|
/**
|
||||||
|
* 是否允许点击遮罩层关闭 TaskDrawer,默认为 true(保持原有行为)
|
||||||
|
* 设置为 false 可防止 TaskDrawer 在失去焦点(点击遮罩区域)时自动关闭
|
||||||
|
* 仅在 useDefaultDrawer=true 时有效
|
||||||
|
*/
|
||||||
|
enableTaskDrawerAutoClose?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskList的固定总长度(所有列的最小宽度之和 + 边框等额外空间)
|
// TaskList的固定总长度(所有列的最小宽度之和 + 边框等额外空间)
|
||||||
@@ -806,6 +813,10 @@ const dragging = ref(false)
|
|||||||
// v1.9.5 P2-4优化 - 提供Split Bar拖拽状态给子组件
|
// v1.9.5 P2-4优化 - 提供Split Bar拖拽状态给子组件
|
||||||
provide('isSplitBarDragging', dragging)
|
provide('isSplitBarDragging', dragging)
|
||||||
|
|
||||||
|
// TaskList 切换动画进行中的标志(提供给 Timeline,避免 toggle 期间误触发 resize 重算)
|
||||||
|
const isTaskListToggling = ref(false)
|
||||||
|
provide('isTaskListToggling', isTaskListToggling)
|
||||||
|
|
||||||
function onMouseDown(e: MouseEvent) {
|
function onMouseDown(e: MouseEvent) {
|
||||||
// 检查事件目标是否是task-list-toggle按钮或其子元素
|
// 检查事件目标是否是task-list-toggle按钮或其子元素
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
@@ -984,6 +995,9 @@ const toggleTaskList = () => {
|
|||||||
// 如果正在动画中,忽略点击
|
// 如果正在动画中,忽略点击
|
||||||
if (isAnimating.value) return
|
if (isAnimating.value) return
|
||||||
|
|
||||||
|
// 标记 toggle 进行中,阻止 Timeline 内 containerResizeObserver 误触发日期范围重算
|
||||||
|
isTaskListToggling.value = true
|
||||||
|
|
||||||
// 触发撞击动画
|
// 触发撞击动画
|
||||||
isAnimating.value = true
|
isAnimating.value = true
|
||||||
|
|
||||||
@@ -1005,7 +1019,9 @@ const toggleTaskList = () => {
|
|||||||
isAnimating.value = false
|
isAnimating.value = false
|
||||||
animationClass.value = ''
|
animationClass.value = ''
|
||||||
|
|
||||||
// 手动切换TaskList后,通知Timeline重新计算半圆
|
// toggle 结束:先清除标志(Timeline watch 会响应并只更新 sticky/bubble,跳过日期范围重算)
|
||||||
|
// 再通知 Timeline 重新计算半圆和 TaskBar 位置
|
||||||
|
isTaskListToggling.value = false
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('timeline-container-resized', {
|
new CustomEvent('timeline-container-resized', {
|
||||||
@@ -1018,11 +1034,14 @@ const toggleTaskList = () => {
|
|||||||
|
|
||||||
// 监听Timeline的TaskList切换事件
|
// 监听Timeline的TaskList切换事件
|
||||||
const handleToggleTaskList = (event: CustomEvent) => {
|
const handleToggleTaskList = (event: CustomEvent) => {
|
||||||
|
// 标记 toggle 进行中,阻止 Timeline 内 containerResizeObserver 误触发日期范围重算
|
||||||
|
isTaskListToggling.value = true
|
||||||
isTaskListVisible.value = event.detail
|
isTaskListVisible.value = event.detail
|
||||||
|
|
||||||
// TaskList切换会改变Timeline容器宽度,需要通知Timeline重新计算半圆
|
// TaskList切换会改变Timeline容器宽度,需要通知Timeline重新计算半圆
|
||||||
// 派发事件通知Timeline容器宽度发生了变化
|
// toggle 结束:清除标志后再派发事件,Timeline watch 响应时只更新 sticky/bubble
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
isTaskListToggling.value = false
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('timeline-container-resized', {
|
new CustomEvent('timeline-container-resized', {
|
||||||
detail: { source: 'task-list-toggle' },
|
detail: { source: 'task-list-toggle' },
|
||||||
@@ -1062,11 +1081,16 @@ onMounted(() => {
|
|||||||
|
|
||||||
// 监听 timeline 容器宽度变化
|
// 监听 timeline 容器宽度变化
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
// 监听右侧面板(timeline 的可视容器)的宽度
|
// 监听 gantt-body(整体布局容器)宽度变化作为日期范围规划的基准
|
||||||
const rightPanel = document.querySelector('.gantt-panel-right')
|
// 使用 .gantt-body 而非 .gantt-panel-right,原因:
|
||||||
if (rightPanel) {
|
// 1. 拖拽 SplitterBar 和 TaskList toggle 只改变左右面板的内部分割,.gantt-body 总宽度不变
|
||||||
|
// → 不会误触发日期范围重算
|
||||||
|
// 2. 窗口 resize / 全屏切换才改变 .gantt-body 宽度 → 正确触发重算
|
||||||
|
// 3. 作为「最大可能宽度」兜底:TaskList 隐藏时 Timeline 最大可展开至此宽度
|
||||||
|
const ganttBody = document.querySelector('.gantt-body')
|
||||||
|
if (ganttBody) {
|
||||||
// 初始化宽度
|
// 初始化宽度
|
||||||
timelineContainerWidth.value = rightPanel.clientWidth
|
timelineContainerWidth.value = ganttBody.clientWidth
|
||||||
|
|
||||||
// 使用 ResizeObserver 监听宽度变化
|
// 使用 ResizeObserver 监听宽度变化
|
||||||
resizeObserver = new ResizeObserver(entries => {
|
resizeObserver = new ResizeObserver(entries => {
|
||||||
@@ -1074,7 +1098,7 @@ onMounted(() => {
|
|||||||
timelineContainerWidth.value = entry.contentRect.width
|
timelineContainerWidth.value = entry.contentRect.width
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
resizeObserver.observe(rightPanel)
|
resizeObserver.observe(ganttBody)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1601,31 +1625,30 @@ const tasksForTimeline = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 递归更新任务树中所有父任务的时间范围(从叶子节点开始向上)
|
// 递归更新任务树中所有父任务的时间范围(从叶子节点开始向上)
|
||||||
|
// 优化:叶子任务直接返回原引用,避免无效 { ...task } 对象创建
|
||||||
|
// (后续 smartFlattenTasks 会在 spread 时附加 level,不会污染原对象)
|
||||||
const updateParentDateRanges = (tasks: Task[]): Task[] => {
|
const updateParentDateRanges = (tasks: Task[]): Task[] => {
|
||||||
return tasks.map(task => {
|
return tasks.map(task => {
|
||||||
let updatedTask = { ...task }
|
// 叶子任务:无需创建新对象,直接返回原引用
|
||||||
|
if (!task.children || task.children.length === 0) {
|
||||||
// 先递归更新子任务
|
return task
|
||||||
if (task.children && task.children.length > 0) {
|
|
||||||
updatedTask.children = updateParentDateRanges(task.children)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基于任务类型判断是否为父任务
|
// 父任务:先递归处理子任务
|
||||||
const isParent =
|
const updatedChildren = updateParentDateRanges(task.children)
|
||||||
task.type === 'story' || (updatedTask.children && updatedTask.children.length > 0)
|
const isParent = task.type === 'story' || true
|
||||||
updatedTask.isParent = isParent
|
|
||||||
|
|
||||||
// 如果是父任务且有子任务,重新计算时间范围
|
// 重新计算父任务时间范围
|
||||||
if (isParent && updatedTask.children && updatedTask.children.length > 0) {
|
const taskWithChildren = { ...task, children: updatedChildren }
|
||||||
const { startDate, endDate } = calculateParentDateRange(updatedTask)
|
const { startDate, endDate } = calculateParentDateRange(taskWithChildren)
|
||||||
updatedTask = {
|
|
||||||
...updatedTask,
|
return {
|
||||||
|
...task,
|
||||||
|
children: updatedChildren,
|
||||||
|
isParent,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return updatedTask
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1714,8 +1737,18 @@ const timelineDateRange = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let allTasks: Task[] = []
|
let allTasks: Task[] = []
|
||||||
|
|
||||||
|
if (currentViewMode.value === 'resource' && props.resources) {
|
||||||
|
// 资源视图:从 resource.tasks 提取日期,避免传入基于任务视图的错误范围
|
||||||
|
for (const resource of (props.resources as Resource[])) {
|
||||||
|
if (resource.tasks && Array.isArray(resource.tasks)) {
|
||||||
|
allTasks = allTasks.concat(flattenTasks(resource.tasks as Task[]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (props.tasks) allTasks = allTasks.concat(flattenTasks(props.tasks))
|
if (props.tasks) allTasks = allTasks.concat(flattenTasks(props.tasks))
|
||||||
if (props.milestones) allTasks = allTasks.concat(props.milestones)
|
if (props.milestones) allTasks = allTasks.concat(props.milestones)
|
||||||
|
}
|
||||||
|
|
||||||
// 过滤出有日期的任务
|
// 过滤出有日期的任务
|
||||||
const startDates = allTasks
|
const startDates = allTasks
|
||||||
@@ -3530,6 +3563,7 @@ defineExpose({
|
|||||||
:delay-task-background-color="props.delayTaskBackgroundColor"
|
:delay-task-background-color="props.delayTaskBackgroundColor"
|
||||||
:complete-task-background-color="props.completeTaskBackgroundColor"
|
:complete-task-background-color="props.completeTaskBackgroundColor"
|
||||||
:ongoing-task-background-color="props.ongoingTaskBackgroundColor"
|
:ongoing-task-background-color="props.ongoingTaskBackgroundColor"
|
||||||
|
:enable-close-on-overlay-click="props.enableTaskDrawerAutoClose"
|
||||||
@submit="handleTaskDrawerSubmit"
|
@submit="handleTaskDrawerSubmit"
|
||||||
@close="taskDrawerVisible = false"
|
@close="taskDrawerVisible = false"
|
||||||
@start-timer="handleStartTimer"
|
@start-timer="handleStartTimer"
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ interface Props {
|
|||||||
verticalLines?: VerticalLine[]
|
verticalLines?: VerticalLine[]
|
||||||
showVerticalLines?: boolean
|
showVerticalLines?: boolean
|
||||||
// 主题模式通过inject获取,不再需要props
|
// 主题模式通过inject获取,不再需要props
|
||||||
|
// 滚动优化:垂直滚动时跳过 canvas 重绘,等滚动停止后一次性重绘
|
||||||
|
isScrolling?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -40,6 +42,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
showVerticalLines: true,
|
showVerticalLines: true,
|
||||||
offsetLeft: 0,
|
offsetLeft: 0,
|
||||||
offsetTop: 0,
|
offsetTop: 0,
|
||||||
|
isScrolling: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 通过inject获取主题
|
// 通过inject获取主题
|
||||||
@@ -54,6 +57,8 @@ const canvasManager = new CanvasContextManager()
|
|||||||
// requestAnimationFrame 防抖控制
|
// requestAnimationFrame 防抖控制
|
||||||
let rafId: number | null = null
|
let rafId: number | null = null
|
||||||
let pendingRedraw = false
|
let pendingRedraw = false
|
||||||
|
// 滚动期间跳过重绘,滚动停止后补一次
|
||||||
|
let pendingRedrawOnScrollStop = false
|
||||||
|
|
||||||
// 当前主题(用于分隔线颜色)
|
// 当前主题(用于分隔线颜色)
|
||||||
const isDarkTheme = computed(() => injectedTheme.value === 'dark')
|
const isDarkTheme = computed(() => injectedTheme.value === 'dark')
|
||||||
@@ -406,8 +411,15 @@ const drawArrowOptimized = (
|
|||||||
/**
|
/**
|
||||||
* 使用 requestAnimationFrame 优化的重绘调度器
|
* 使用 requestAnimationFrame 优化的重绘调度器
|
||||||
* 合并多个连续的重绘请求为单次绘制
|
* 合并多个连续的重绘请求为单次绘制
|
||||||
|
* 垂直滚动期间跳过重绘,等滚动停止后补绘一次(避免每帧重绘 canvas)
|
||||||
*/
|
*/
|
||||||
const scheduleRedraw = () => {
|
const scheduleRedraw = () => {
|
||||||
|
// 滚动期间:延迟至滚动停止后再重绘
|
||||||
|
if (props.isScrolling) {
|
||||||
|
pendingRedrawOnScrollStop = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (pendingRedraw) {
|
if (pendingRedraw) {
|
||||||
// 已有待处理的重绘请求,跳过
|
// 已有待处理的重绘请求,跳过
|
||||||
return
|
return
|
||||||
@@ -428,6 +440,17 @@ const scheduleRedraw = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听滚动结束:滚动期间有待重绘时,在停止后立即补绘一次
|
||||||
|
watch(
|
||||||
|
() => props.isScrolling,
|
||||||
|
(scrolling) => {
|
||||||
|
if (!scrolling && pendingRedrawOnScrollStop) {
|
||||||
|
pendingRedrawOnScrollStop = false
|
||||||
|
scheduleRedraw()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 监听相关状态变化,自动重绘 Canvas
|
// 监听相关状态变化,自动重绘 Canvas
|
||||||
watch(
|
watch(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1569,8 +1569,14 @@ const handleMouseMove = (e: MouseEvent) => {
|
|||||||
} else {
|
} else {
|
||||||
// 其他情况:使用原有的简单计算
|
// 其他情况:使用原有的简单计算
|
||||||
const newStartDate = addDaysToLocalDate(props.startDate, newLeft / props.dayWidth)
|
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 = {
|
tempTaskData.value = {
|
||||||
@@ -3688,6 +3694,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
touch-action: none; /* JS 完全接管拖拽手势,不影响父层水平滚动 */
|
||||||
/* 阴影通过JS动态设置,使用TaskBar的颜色 */
|
/* 阴影通过JS动态设置,使用TaskBar的颜色 */
|
||||||
transition:
|
transition:
|
||||||
box-shadow 0.2s,
|
box-shadow 0.2s,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface Props {
|
|||||||
delayTaskBackgroundColor?: string
|
delayTaskBackgroundColor?: string
|
||||||
completeTaskBackgroundColor?: string
|
completeTaskBackgroundColor?: string
|
||||||
ongoingTaskBackgroundColor?: string
|
ongoingTaskBackgroundColor?: string
|
||||||
|
/** 是否允许点击遮罩层关闭抽屉,默认为 true(保持原有行为)。设置为 false 可防止失去焦点时自动关闭 */
|
||||||
|
enableCloseOnOverlayClick?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -45,6 +47,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
delayTaskBackgroundColor: '#f56c6c',
|
delayTaskBackgroundColor: '#f56c6c',
|
||||||
completeTaskBackgroundColor: '#67c23a',
|
completeTaskBackgroundColor: '#67c23a',
|
||||||
ongoingTaskBackgroundColor: '#409eff',
|
ongoingTaskBackgroundColor: '#409eff',
|
||||||
|
enableCloseOnOverlayClick: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -484,8 +487,10 @@ const handleClose = () => {
|
|||||||
|
|
||||||
// 点击遮罩层关闭
|
// 点击遮罩层关闭
|
||||||
const handleOverlayClick = () => {
|
const handleOverlayClick = () => {
|
||||||
|
if (props.enableCloseOnOverlayClick) {
|
||||||
handleClose()
|
handleClose()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 提交表单
|
// 提交表单
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
|||||||
@@ -142,6 +142,9 @@ export function useTaskListEventHandlers(options: TaskListEventHandlersOptions)
|
|||||||
|
|
||||||
// ==================== 滚动同步事件 ====================
|
// ==================== 滚动同步事件 ====================
|
||||||
|
|
||||||
|
// 滚动同步标志位:防止 Timeline → TaskList 写入后反向再次派发(2跳链路优化)
|
||||||
|
let _isSyncingScrollFromTimeline = false
|
||||||
|
|
||||||
const handleTaskListScroll = (event: Event) => {
|
const handleTaskListScroll = (event: Event) => {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (!target) return
|
if (!target) return
|
||||||
@@ -149,6 +152,9 @@ export function useTaskListEventHandlers(options: TaskListEventHandlersOptions)
|
|||||||
const scrollTop = target.scrollTop
|
const scrollTop = target.scrollTop
|
||||||
taskListScrollTop.value = scrollTop
|
taskListScrollTop.value = scrollTop
|
||||||
|
|
||||||
|
// 由 Timeline → TaskList 同步写入触发时,不再反向派发,避免 2 跳循环
|
||||||
|
if (_isSyncingScrollFromTimeline) return
|
||||||
|
|
||||||
// 滚动时关闭所有右键菜单
|
// 滚动时关闭所有右键菜单
|
||||||
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
|
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
|
||||||
|
|
||||||
@@ -166,7 +172,11 @@ export function useTaskListEventHandlers(options: TaskListEventHandlersOptions)
|
|||||||
taskListScrollTop.value = scrollTop
|
taskListScrollTop.value = scrollTop
|
||||||
|
|
||||||
if (taskListBodyElement && Math.abs(taskListBodyElement.scrollTop - scrollTop) > 1) {
|
if (taskListBodyElement && Math.abs(taskListBodyElement.scrollTop - scrollTop) > 1) {
|
||||||
|
// 设置标志位:当前是由 Timeline 触发的同步写入
|
||||||
|
// 防止 handleTaskListScroll 被触发后反向再次派发事件(2跳链路)
|
||||||
|
_isSyncingScrollFromTimeline = true
|
||||||
taskListBodyElement.scrollTop = scrollTop
|
taskListBodyElement.scrollTop = scrollTop
|
||||||
|
Promise.resolve().then(() => { _isSyncingScrollFromTimeline = false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,41 +65,31 @@ export function useTaskListLayout(tasks: Ref<Task[]>) {
|
|||||||
const flattenedTasks = computed(() => getFlattenedVisibleTasks(tasks.value))
|
const flattenedTasks = computed(() => getFlattenedVisibleTasks(tasks.value))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v1.9.0 计算每个任务/资源的累计高度位置(支持动态行高)
|
* 资源视图专用:累计高度数组(动态行高,需要数组+二分查找)
|
||||||
|
* 任务视图固定行高,直接用公式计算,不构造此数组,避免 O(n) 分配
|
||||||
*/
|
*/
|
||||||
const cumulativeHeights = computed<number[]>(() => {
|
const cumulativeHeights = computed<number[]>(() => {
|
||||||
if (viewMode.value === 'resource') {
|
if (viewMode.value !== 'resource') return []
|
||||||
// 资源视图:使用每个资源的实际高度
|
|
||||||
const resources = dataSource.value as Resource[]
|
|
||||||
const heights: number[] = [0] // 第一个位置是0
|
|
||||||
let cumulative = 0
|
|
||||||
|
|
||||||
|
const resources = dataSource.value as Resource[]
|
||||||
|
const heights: number[] = [0]
|
||||||
|
let cumulative = 0
|
||||||
resources.forEach(resource => {
|
resources.forEach(resource => {
|
||||||
const layout = resourceTaskLayouts.value.get(resource.id)
|
const layout = resourceTaskLayouts.value.get(resource.id)
|
||||||
const height = layout?.totalHeight || ROW_HEIGHT
|
const height = layout?.totalHeight || ROW_HEIGHT
|
||||||
cumulative += height
|
cumulative += height
|
||||||
heights.push(cumulative)
|
heights.push(cumulative)
|
||||||
})
|
})
|
||||||
|
|
||||||
return heights
|
return heights
|
||||||
} else {
|
|
||||||
// 任务视图:使用固定行高
|
|
||||||
const heights: number[] = [0]
|
|
||||||
for (let i = 1; i <= flattenedTasks.value.length; i++) {
|
|
||||||
heights.push(i * ROW_HEIGHT)
|
|
||||||
}
|
|
||||||
return heights
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据滚动位置查找对应的任务/资源索引(二分查找)
|
* 资源视图专用:根据滚动位置查找资源索引(二分查找)
|
||||||
*/
|
*/
|
||||||
const findIndexByScrollTop = (scrollTop: number): number => {
|
const findIndexByScrollTop = (scrollTop: number): number => {
|
||||||
const heights = cumulativeHeights.value
|
const heights = cumulativeHeights.value
|
||||||
let left = 0
|
let left = 0
|
||||||
let right = heights.length - 1
|
let right = heights.length - 1
|
||||||
|
|
||||||
while (left < right) {
|
while (left < right) {
|
||||||
const mid = Math.floor((left + right) / 2)
|
const mid = Math.floor((left + right) / 2)
|
||||||
if (heights[mid] <= scrollTop) {
|
if (heights[mid] <= scrollTop) {
|
||||||
@@ -108,39 +98,38 @@ export function useTaskListLayout(tasks: Ref<Task[]>) {
|
|||||||
right = mid
|
right = mid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.max(0, left - 1)
|
return Math.max(0, left - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算可视区域任务范围(支持动态行高)
|
* 计算可视区域任务范围
|
||||||
|
* - 任务视图:固定行高,O(1) 公式直接计算(不依赖 cumulativeHeights)
|
||||||
|
* - 资源视图:动态行高,使用累计高度数组 + 二分查找
|
||||||
*/
|
*/
|
||||||
const visibleTaskRange = computed<VisibleTaskRange>(() => {
|
const visibleTaskRange = computed<VisibleTaskRange>(() => {
|
||||||
const scrollTop = taskListScrollTop.value
|
const scrollTop = taskListScrollTop.value
|
||||||
const containerHeight = taskListBodyHeight.value || 600
|
const containerHeight = taskListBodyHeight.value || 600
|
||||||
|
|
||||||
|
if (viewMode.value === 'resource') {
|
||||||
const heights = cumulativeHeights.value
|
const heights = cumulativeHeights.value
|
||||||
|
if (heights.length <= 1) return { startIndex: 0, endIndex: 0 }
|
||||||
|
|
||||||
if (heights.length <= 1) {
|
const total = (dataSource.value as Resource[]).length
|
||||||
return { startIndex: 0, endIndex: 0 }
|
let startIndex = Math.max(0, findIndexByScrollTop(scrollTop) - VERTICAL_BUFFER)
|
||||||
}
|
|
||||||
|
|
||||||
// 找到起始索引(考虑缓冲区)
|
|
||||||
let startIndex = findIndexByScrollTop(scrollTop)
|
|
||||||
startIndex = Math.max(0, startIndex - VERTICAL_BUFFER)
|
|
||||||
|
|
||||||
// 找到结束索引(考虑缓冲区)
|
|
||||||
const scrollBottom = scrollTop + containerHeight
|
const scrollBottom = scrollTop + containerHeight
|
||||||
let endIndex = findIndexByScrollTop(scrollBottom)
|
let endIndex = Math.min(heights.length - 1, findIndexByScrollTop(scrollBottom) + VERTICAL_BUFFER + 1)
|
||||||
endIndex = Math.min(heights.length - 1, endIndex + VERTICAL_BUFFER + 1)
|
|
||||||
|
|
||||||
const total = viewMode.value === 'resource'
|
|
||||||
? (dataSource.value as Resource[]).length
|
|
||||||
: flattenedTasks.value.length
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startIndex: Math.min(startIndex, total),
|
startIndex: Math.min(startIndex, total),
|
||||||
endIndex: Math.min(endIndex, 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<Task[]>) {
|
|||||||
const visibleTasks = computed<TaskWithLevelAndIndex[]>(() => {
|
const visibleTasks = computed<TaskWithLevelAndIndex[]>(() => {
|
||||||
const { startIndex, endIndex } = visibleTaskRange.value
|
const { startIndex, endIndex } = visibleTaskRange.value
|
||||||
const slicedTasks = flattenedTasks.value.slice(startIndex, endIndex)
|
const slicedTasks = flattenedTasks.value.slice(startIndex, endIndex)
|
||||||
// 添加 rowIndex(基于在扁平化列表中的实际索引)
|
|
||||||
return slicedTasks.map((item, index) => ({
|
return slicedTasks.map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
rowIndex: startIndex + index,
|
rowIndex: startIndex + index,
|
||||||
@@ -157,22 +145,31 @@ export function useTaskListLayout(tasks: Ref<Task[]>) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spacer 高度用于撑起滚动区域(支持动态行高)
|
* Spacer 高度撑起滚动区域
|
||||||
|
* 任务视图:O(1) 乘法;资源视图:从累计高度数组读取
|
||||||
*/
|
*/
|
||||||
const totalContentHeight = computed(() => {
|
const totalContentHeight = computed(() => {
|
||||||
|
if (viewMode.value === 'resource') {
|
||||||
const heights = cumulativeHeights.value
|
const heights = cumulativeHeights.value
|
||||||
return heights.length > 0 ? heights[heights.length - 1] : 0
|
return heights.length > 0 ? heights[heights.length - 1] : 0
|
||||||
|
}
|
||||||
|
return flattenedTasks.value.length * ROW_HEIGHT
|
||||||
})
|
})
|
||||||
|
|
||||||
const startSpacerHeight = computed(() => {
|
const startSpacerHeight = computed(() => {
|
||||||
const startIdx = visibleTaskRange.value.startIndex
|
if (viewMode.value === 'resource') {
|
||||||
return cumulativeHeights.value[startIdx] || 0
|
return cumulativeHeights.value[visibleTaskRange.value.startIndex] || 0
|
||||||
|
}
|
||||||
|
return visibleTaskRange.value.startIndex * ROW_HEIGHT
|
||||||
})
|
})
|
||||||
|
|
||||||
const endSpacerHeight = computed(() => {
|
const endSpacerHeight = computed(() => {
|
||||||
|
if (viewMode.value === 'resource') {
|
||||||
const endIdx = visibleTaskRange.value.endIndex
|
const endIdx = visibleTaskRange.value.endIndex
|
||||||
const endHeight = cumulativeHeights.value[endIdx] || 0
|
const endHeight = cumulativeHeights.value[endIdx] || 0
|
||||||
return Math.max(0, totalContentHeight.value - endHeight)
|
return Math.max(0, totalContentHeight.value - endHeight)
|
||||||
|
}
|
||||||
|
return Math.max(0, (flattenedTasks.value.length - visibleTaskRange.value.endIndex) * ROW_HEIGHT)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { onMounted, onUnmounted, type Ref } from 'vue'
|
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||||
import type { Task } from '../../../../models/classes/Task'
|
import type { Task } from '../../../../models/classes/Task'
|
||||||
|
import { registerDragOver, unregisterDragOver } from '../../../../utils/dragOverRegistry'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TaskRow 事件处理和拖拽交互
|
* TaskRow 事件处理和拖拽交互
|
||||||
@@ -98,27 +99,25 @@ export function useTaskRowEventHandlers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理全局拖拽悬停事件
|
// 处理全局拖拽悬停事件(通过 dragOverRegistry 委托,不再直接监听 window)
|
||||||
const handleTaskRowDragOver = (event: CustomEvent) => {
|
// 优化:从每行独立注册 window 监听器改为全局单一监听器 + Map 直接查找 O(1)
|
||||||
|
const handleTaskRowDragOver = (mouseEvent: MouseEvent) => {
|
||||||
if (!enableDrag.value || !taskRowRef.value || !dragOver) return
|
if (!enableDrag.value || !taskRowRef.value || !dragOver) return
|
||||||
|
|
||||||
const { taskId, event: mouseEvent } = event.detail
|
|
||||||
if (taskId === task.value.id) {
|
|
||||||
dragOver(task.value, taskRowRef.value, mouseEvent)
|
dragOver(task.value, taskRowRef.value, mouseEvent)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 生命周期钩子 - 注册事件监听器
|
// 生命周期钩子 - 注册事件监听器
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('splitter-drag-start', handleSplitterDragStart)
|
window.addEventListener('splitter-drag-start', handleSplitterDragStart)
|
||||||
window.addEventListener('splitter-drag-end', handleSplitterDragEnd)
|
window.addEventListener('splitter-drag-end', handleSplitterDragEnd)
|
||||||
window.addEventListener('task-row-drag-over', handleTaskRowDragOver as EventListener)
|
// 使用 dragOverRegistry 委托注册,避免每个 TaskRow 各自占用一个 window 监听器
|
||||||
|
registerDragOver(task.value.id, handleTaskRowDragOver)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('splitter-drag-start', handleSplitterDragStart)
|
window.removeEventListener('splitter-drag-start', handleSplitterDragStart)
|
||||||
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd)
|
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd)
|
||||||
window.removeEventListener('task-row-drag-over', handleTaskRowDragOver as EventListener)
|
unregisterDragOver(task.value.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+220
-39
@@ -168,6 +168,9 @@ provide('positionCache', positionCache)
|
|||||||
// v1.9.5 P2-4优化 - 注入Split Bar拖拽状态
|
// v1.9.5 P2-4优化 - 注入Split Bar拖拽状态
|
||||||
const isSplitBarDragging = inject<Ref<boolean>>('isSplitBarDragging', ref(false))
|
const isSplitBarDragging = inject<Ref<boolean>>('isSplitBarDragging', ref(false))
|
||||||
|
|
||||||
|
// 注入 TaskList toggle 状态(toggle 期间阻止 containerResizeObserver 误触发日期范围重算)
|
||||||
|
const isTaskListToggling = inject<Ref<boolean>>('isTaskListToggling', ref(false))
|
||||||
|
|
||||||
// v1.9.5 注入showConflicts配置
|
// v1.9.5 注入showConflicts配置
|
||||||
const showConflicts = inject<ComputedRef<boolean>>('gantt-show-conflicts', computed(() => true))
|
const showConflicts = inject<ComputedRef<boolean>>('gantt-show-conflicts', computed(() => true))
|
||||||
|
|
||||||
@@ -175,6 +178,11 @@ const showConflicts = inject<ComputedRef<boolean>>('gantt-show-conflicts', compu
|
|||||||
const ROW_HEIGHT = 51 // 每行高度51px (50px + 1px border)
|
const ROW_HEIGHT = 51 // 每行高度51px (50px + 1px border)
|
||||||
const VERTICAL_BUFFER = 5 // 纵向缓冲区行数
|
const VERTICAL_BUFFER = 5 // 纵向缓冲区行数
|
||||||
const timelineBodyScrollTop = ref(0) // 纵向滚动位置
|
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) // 容器高度状态管理
|
const timelineBodyHeight = ref(0) // 容器高度状态管理
|
||||||
|
|
||||||
// v1.9.9 优化:注入GanttChart提供的资源布局,避免重复计算
|
// v1.9.9 优化:注入GanttChart提供的资源布局,避免重复计算
|
||||||
@@ -292,7 +300,10 @@ const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
|
|||||||
|
|
||||||
// 响应外部props变化,动态更新timelineConfig
|
// 响应外部props变化,动态更新timelineConfig
|
||||||
watch([timelineStartDate, timelineEndDate], ([newStart, newEnd]) => {
|
watch([timelineStartDate, timelineEndDate], ([newStart, newEnd]) => {
|
||||||
// 所有视图都正常响应props变化
|
// 资源视图下不响应 props 变化:
|
||||||
|
// GanttChart 的 timelineDateRange 计算可能传入基于任务视图的日期范围(已 computed 修复,但保留守卫作为二重保险)
|
||||||
|
// 资源视图的日期范围由 updateTimeScale / debouncedUpdateTimelineRange 内部管理
|
||||||
|
if (viewMode.value === 'resource') return
|
||||||
if (props.startDate || props.endDate) {
|
if (props.startDate || props.endDate) {
|
||||||
if (!isUpdatingTimelineConfig) {
|
if (!isUpdatingTimelineConfig) {
|
||||||
timelineConfig.value.startDate = newStart
|
timelineConfig.value.startDate = newStart
|
||||||
@@ -496,9 +507,13 @@ const getDayTimelineRange = () => {
|
|||||||
|
|
||||||
const { minDate, maxDate } = taskRange
|
const { minDate, maxDate } = taskRange
|
||||||
|
|
||||||
// 开始日期:任务最小开始日期-30天
|
// 开始日期:取任务最小日期所在月的前一个月月初(第1天)
|
||||||
let startDate = new Date(minDate)
|
// 采用月运算而非减30天,原因:
|
||||||
startDate.setDate(startDate.getDate() - 30)
|
// 减30天后若落在月末(如1月31日),normalize 到月初会多出一整个月(1月→1月1日),
|
||||||
|
// 而语义上只需在任务月份前保留一个完整月作为 buffer。
|
||||||
|
// 直接用 new Date(year, month-1, 1) 使 startDate 始终为月初且精确为前一个月,
|
||||||
|
// generateDayTimelineData 以月为单位迭代时 setMonth(+1) 不会因日期溢出跳过任何月份。
|
||||||
|
let startDate = new Date(minDate.getFullYear(), minDate.getMonth() - 1, 1)
|
||||||
|
|
||||||
// 结束日期:任务最大结束日期+30天
|
// 结束日期:任务最大结束日期+30天
|
||||||
let endDate = new Date(maxDate)
|
let endDate = new Date(maxDate)
|
||||||
@@ -1418,6 +1433,14 @@ let scrollTimeout: number | null = null
|
|||||||
const timelineScrollLeft = ref(0)
|
const timelineScrollLeft = ref(0)
|
||||||
const timelineContainerWidth = ref(0)
|
const timelineContainerWidth = ref(0)
|
||||||
|
|
||||||
|
// 跳过 debouncedUpdateTimelineRange 的一次性标志
|
||||||
|
// 用于 drag-end / toggle-end 配置 trigger 的宽度更新,只需要更新 sticky/bubble,无需重算日期范围
|
||||||
|
let skipTimelineRangeUpdate = false
|
||||||
|
|
||||||
|
// 允许 updateTimelineRange 在资源视图下执行一次的一次性标志
|
||||||
|
// 只在任务视图 → 资源视图切换时设置,计算完成后自动清除
|
||||||
|
let forceTimelineRangeInResourceView = false
|
||||||
|
|
||||||
// 半圆气泡控制状态
|
// 半圆气泡控制状态
|
||||||
const hideBubbles = ref(true) // 初始时隐藏半圆,等待初始滚动完成
|
const hideBubbles = ref(true) // 初始时隐藏半圆,等待初始滚动完成
|
||||||
const isInitialScrolling = ref(true) // 跟踪初始滚动状态
|
const isInitialScrolling = ref(true) // 跟踪初始滚动状态
|
||||||
@@ -1727,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)
|
// v1.9.6 Sprint2(P1+P4) - 资源视图TaskBar分批渲染队列(优化:批次从20改为5)
|
||||||
const RESOURCE_BATCH_SIZE = 5 // 方案4: 更小批次,从20改为5
|
const RESOURCE_BATCH_SIZE = 5 // 方案4: 更小批次,从20改为5
|
||||||
const FRAME_TIME_BUDGET = 8 // 每帧最多8ms用于TaskBar渲染
|
const FRAME_TIME_BUDGET = 8 // 每帧最多8ms用于TaskBar渲染
|
||||||
@@ -2352,10 +2443,13 @@ const getOtherMilestonesInfo = (currentId: number) => {
|
|||||||
watch(isSplitBarDragging, (dragging) => {
|
watch(isSplitBarDragging, (dragging) => {
|
||||||
if (!dragging) {
|
if (!dragging) {
|
||||||
// v1.9.9 拖拽结束后,手动触发一次容器尺寸更新(因为拖拽期间ResizeObserver被暂停)
|
// v1.9.9 拖拽结束后,手动触发一次容器尺寸更新(因为拖拽期间ResizeObserver被暂停)
|
||||||
|
// 设置 skip 标志:拖拽不改变日期范围,只更新 sticky/bubble
|
||||||
if (timelineContainerElement.value) {
|
if (timelineContainerElement.value) {
|
||||||
const newWidth = timelineContainerElement.value.clientWidth
|
const newWidth = timelineContainerElement.value.clientWidth
|
||||||
if (Math.abs(newWidth - timelineContainerWidth.value) > 1) {
|
if (Math.abs(newWidth - timelineContainerWidth.value) > 1) {
|
||||||
|
skipTimelineRangeUpdate = true
|
||||||
timelineContainerWidth.value = newWidth
|
timelineContainerWidth.value = newWidth
|
||||||
|
nextTick(() => { skipTimelineRangeUpdate = false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2388,6 +2482,23 @@ const handleSplitterDragEnd = () => {
|
|||||||
// 清理工作已移至watch(isSplitBarDragging)
|
// 清理工作已移至watch(isSplitBarDragging)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听 TaskList toggle 结束,手动更新 Timeline 内部容器宽度
|
||||||
|
// 跳过日期范围重算,只让 sticky/bubble 重算和 TaskBar 重渲染生效
|
||||||
|
watch(isTaskListToggling, (toggling) => {
|
||||||
|
if (!toggling) {
|
||||||
|
// toggle 结束:读取 .timeline 滚动容器的真实新宽度
|
||||||
|
// 使用 skip 标志,避免 watch(timelineContainerWidth) 触发 debouncedUpdateTimelineRange
|
||||||
|
if (timelineContainerElement.value) {
|
||||||
|
const newWidth = timelineContainerElement.value.clientWidth
|
||||||
|
if (Math.abs(newWidth - timelineContainerWidth.value) > 1) {
|
||||||
|
skipTimelineRangeUpdate = true
|
||||||
|
timelineContainerWidth.value = newWidth
|
||||||
|
nextTick(() => { skipTimelineRangeUpdate = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 处理Timeline容器resize事件(如TaskList切换等)
|
// 处理Timeline容器resize事件(如TaskList切换等)
|
||||||
const handleTimelineContainerResized = () => {
|
const handleTimelineContainerResized = () => {
|
||||||
// Timeline容器大小发生变化,需要强制重新计算半圆显示状态
|
// Timeline容器大小发生变化,需要强制重新计算半圆显示状态
|
||||||
@@ -3518,12 +3629,18 @@ const canvasHeight = ref(0)
|
|||||||
const canvasOffsetLeft = ref(0) // Canvas 在全局坐标系中的偏移量
|
const canvasOffsetLeft = ref(0) // Canvas 在全局坐标系中的偏移量
|
||||||
const canvasOffsetTop = ref(0)
|
const canvasOffsetTop = ref(0)
|
||||||
|
|
||||||
// 虚拟渲染 Canvas 的安全宽度(防止超过浏览器限制)
|
// 虚拟渲染 Canvas 的安全尺寸(动态计算,防止超过浏览器限制和移动端 OOM)
|
||||||
// 可根据实际需求调整:
|
// 策略:以当前可见视口的倍数为基准,全屏/分辨率变化时 updateSvgSize() 自动重算
|
||||||
// - 5000: 最小内存 (~30MB),适合低端设备,但滚动时更频繁更新
|
// - 宽度 = clamp(视口宽度 × 3, 2000, 8000):覆盖日视图滑动 2 屏 + 关系线缓冲
|
||||||
// - 10000: 平衡选择 (~60MB),覆盖小时视图 10 天,周视图 2 年
|
// - 高度 = clamp(视口高度 × 3, 1200, 4000):覆盖 3 屏任务行,移动端从 100MB→15MB
|
||||||
const SAFE_CANVAS_WIDTH = 5000 // 平衡性能和覆盖范围
|
function computeSafeCanvasDimensions(): { width: number; height: number } {
|
||||||
const SAFE_CANVAS_HEIGHT = 5000
|
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() {
|
function updateSvgSize() {
|
||||||
if (bodyContentRef.value) {
|
if (bodyContentRef.value) {
|
||||||
@@ -3536,8 +3653,11 @@ function updateSvgSize() {
|
|||||||
const scrollLeft = timelineScrollLeft.value
|
const scrollLeft = timelineScrollLeft.value
|
||||||
const scrollTop = timelineBodyScrollTop.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
|
canvasWidth.value = SAFE_CANVAS_WIDTH
|
||||||
|
|
||||||
// 计算 Canvas 覆盖的起始位置
|
// 计算 Canvas 覆盖的起始位置
|
||||||
@@ -3576,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: {
|
function handleBarMounted(payload: {
|
||||||
id: number
|
id: number
|
||||||
left: number
|
left: number
|
||||||
@@ -3584,22 +3710,51 @@ function handleBarMounted(payload: {
|
|||||||
height: number
|
height: number
|
||||||
}) {
|
}) {
|
||||||
if (!bodyContentRef.value) return
|
if (!bodyContentRef.value) return
|
||||||
const baseRect = bodyContentRef.value.getBoundingClientRect()
|
// 使用已有的 cachedBodyRect,避免每次挂载都触发强制 reflow
|
||||||
// 统一坐标系:以bodyContent为基准
|
// bodyRectInvalidated 在滚动时被置为 true,确保坐标转换准确
|
||||||
// 注意:使用 shallowRef 时,需要触发整个对象引用的变化
|
if (!cachedBodyRect || bodyRectInvalidated) {
|
||||||
taskBarPositions.value = {
|
cachedBodyRect = bodyContentRef.value.getBoundingClientRect()
|
||||||
...taskBarPositions.value,
|
bodyRectCacheTime = Date.now()
|
||||||
[payload.id]: {
|
bodyRectInvalidated = false
|
||||||
|
}
|
||||||
|
const baseRect = cachedBodyRect
|
||||||
|
// 先缓存到 pending,不立即触发响应式
|
||||||
|
_pendingBarUpdates[payload.id] = {
|
||||||
left: payload.left - baseRect.left,
|
left: payload.left - baseRect.left,
|
||||||
top: payload.top - baseRect.top,
|
top: payload.top - baseRect.top,
|
||||||
width: payload.width,
|
width: payload.width,
|
||||||
height: payload.height,
|
height: payload.height,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
if (!_barUpdateScheduled) {
|
||||||
updateSvgSize()
|
// 滚动期间跳过 flush:_pendingBarUpdates 继续累积,等滚动停止后统一写入
|
||||||
}, 200)
|
// 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 拖拽/拉伸事件
|
// 向上传递 TaskBar 拖拽/拉伸事件
|
||||||
const handleTaskBarDragEnd = (updatedTask: Task) => {
|
const handleTaskBarDragEnd = (updatedTask: Task) => {
|
||||||
@@ -3763,6 +3918,11 @@ onMounted(() => {
|
|||||||
if (isSplitBarDragging.value) {
|
if (isSplitBarDragging.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// TaskList toggle 期间不处理宽度变化,外层 .gantt-body 已屏蔽日期范围重算
|
||||||
|
// toggle 结束后由 watch(isTaskListToggling) 手动更新宽度
|
||||||
|
if (isTaskListToggling.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const newWidth = entry.contentRect.width
|
const newWidth = entry.contentRect.width
|
||||||
@@ -3816,8 +3976,12 @@ const handleTaskListVerticalScroll = (event: CustomEvent) => {
|
|||||||
debouncedUpdateCanvasPosition()
|
debouncedUpdateCanvasPosition()
|
||||||
|
|
||||||
if (timelineBodyElement.value && Math.abs(timelineBodyElement.value.scrollTop - scrollTop) > 1) {
|
if (timelineBodyElement.value && Math.abs(timelineBodyElement.value.scrollTop - scrollTop) > 1) {
|
||||||
// 使用更精确的比较,避免1px以内的细微差异导致的循环触发
|
// 设置标志位:当前是由 TaskList 触发的同步写入
|
||||||
|
// 防止 handleTimelineBodyScroll 被触发后反向再次派发事件(2跳链路)
|
||||||
|
_isSyncingScrollFromTaskList = true
|
||||||
timelineBodyElement.value.scrollTop = scrollTop
|
timelineBodyElement.value.scrollTop = scrollTop
|
||||||
|
// scroll 事件会同步触发,Promise.resolve 在其后执行,确保标志在返回相义务后重置
|
||||||
|
Promise.resolve().then(() => { _isSyncingScrollFromTaskList = false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3834,11 +3998,22 @@ const handleTimelineBodyScroll = (event: Event) => {
|
|||||||
// 优化:滚动时失效 bodyRect 缓存(用于连接线拖拽)
|
// 优化:滚动时失效 bodyRect 缓存(用于连接线拖拽)
|
||||||
bodyRectInvalidated = true
|
bodyRectInvalidated = true
|
||||||
|
|
||||||
|
// GanttLinks 性能优化:设置滚动标志,暂停 canvas 重绘
|
||||||
|
isTimelineScrolling.value = true
|
||||||
|
if (_scrollIdleTimer !== null) clearTimeout(_scrollIdleTimer)
|
||||||
|
_scrollIdleTimer = setTimeout(() => {
|
||||||
|
_scrollIdleTimer = null
|
||||||
|
isTimelineScrolling.value = false
|
||||||
|
}, 150)
|
||||||
|
|
||||||
debouncedUpdateCanvasPosition()
|
debouncedUpdateCanvasPosition()
|
||||||
|
|
||||||
// 拖拽时不同步滚动事件,避免性能问题
|
// 拖拽时不同步滚动事件,避免性能问题
|
||||||
if (isDragging.value) return
|
if (isDragging.value) return
|
||||||
|
|
||||||
|
// 由 TaskList → Timeline 同步写入触发时,不再反向派发,避免 2 跳循环
|
||||||
|
if (_isSyncingScrollFromTaskList) return
|
||||||
|
|
||||||
// 同步垂直滚动到TaskList
|
// 同步垂直滚动到TaskList
|
||||||
if (scrollTop >= 0) {
|
if (scrollTop >= 0) {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
@@ -4587,10 +4762,11 @@ const debouncedUpdateTimelineRange = (delay = 50) => {
|
|||||||
const updateTimelineRange = () => {
|
const updateTimelineRange = () => {
|
||||||
// 资源视图下不自动调整时间范围,保持Timeline背景层(header + 竖列)稳定
|
// 资源视图下不自动调整时间范围,保持Timeline背景层(header + 竖列)稳定
|
||||||
// 避免滚动时重新生成timelineData导致header闪烁
|
// 避免滚动时重新生成timelineData导致header闪烁
|
||||||
// 虚拟滚动通过visibleTimeRange过滤TaskBar即可
|
// 例外:任务视图 → 资源视图切换时,需要一次性重算以实现基于资源任务的日期范围
|
||||||
if (viewMode.value === 'resource') {
|
if (viewMode.value === 'resource' && !forceTimelineRangeInResourceView) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
forceTimelineRangeInResourceView = false
|
||||||
|
|
||||||
let newRange: { startDate: Date; endDate: Date } | null = null
|
let newRange: { startDate: Date; endDate: Date } | null = null
|
||||||
|
|
||||||
@@ -4625,6 +4801,9 @@ watch(
|
|||||||
// 拖拽 splitter 时跳过重新计算
|
// 拖拽 splitter 时跳过重新计算
|
||||||
if (isSplitterDragging.value) return
|
if (isSplitterDragging.value) return
|
||||||
|
|
||||||
|
// drag-end / toggle-end 触发的宽度更新:只需要 sticky/bubble/TaskBar 重渲染,跳过日期范围重算
|
||||||
|
if (skipTimelineRangeUpdate) 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) {
|
||||||
@@ -4672,12 +4851,13 @@ watch(
|
|||||||
debouncedUpdateTimelineRange()
|
debouncedUpdateTimelineRange()
|
||||||
} else if (newViewMode === 'resource') {
|
} else if (newViewMode === 'resource') {
|
||||||
if (viewModeChanged) {
|
if (viewModeChanged) {
|
||||||
// bugfix: 切换到资源视图时直接调用 scrollToTodayCenter
|
// 切换到资源视图:基于资源任务重算日期范围,重算完成后由 watch([timelineData,...]) 触发滚到今日
|
||||||
// 原因:updateTimelineRange() 在 resource 视图下直接返回(不更新 timelineData/timelineConfig),
|
// 重置 hasInitialAutoScroll,确保 watch([timelineData, timelineConfig...]) 在重算完成后能滚到今日
|
||||||
// 导致内层 watch 无法触发,timeline 保留任务视图中拖拽后的偏移位置
|
// 不在此处直接调用 scrollToTodayCenter(),因为 debouncedUpdateTimelineRange 有 50ms 延迟,
|
||||||
// 注意:切换视图不重新挂载 DOM,timelineContainerElement 始终可用,
|
// nextTick 会在 50ms 之前就执行,导致用旧的 task 视图坐标滚到错误位置
|
||||||
// nextTick 后直接调用即可,scrollToTodayCenter 内部有重试机制无需额外 setTimeout
|
hasInitialAutoScroll = false
|
||||||
nextTick(() => scrollToTodayCenter())
|
forceTimelineRangeInResourceView = true
|
||||||
|
debouncedUpdateTimelineRange()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4688,19 +4868,19 @@ watch(
|
|||||||
() => tasks.value,
|
() => tasks.value,
|
||||||
newTasks => {
|
newTasks => {
|
||||||
invalidateTaskDateRangeCache()
|
invalidateTaskDateRangeCache()
|
||||||
// 优化:使用 for 循环直接构建 Set,避免 map 创建临时数组
|
// P2 修复:task.id 可为 number 或 string(资源视图),统一转为 string 比较
|
||||||
const currentTaskIds = new Set<number>()
|
// 原来使用 parseInt(taskIdStr) 会将 "res-1" → NaN,导致字符串 ID 永远无法匹配,位置信息堆积
|
||||||
|
const currentTaskIdStrs = new Set<string>()
|
||||||
for (const task of newTasks) {
|
for (const task of newTasks) {
|
||||||
currentTaskIds.add(task.id)
|
currentTaskIdStrs.add(String(task.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优化:使用 for...in 循环代替 Object.keys().forEach()
|
|
||||||
// 注意:shallowRef 需要创建新对象来触发响应式
|
// 注意:shallowRef 需要创建新对象来触发响应式
|
||||||
const newPositions: Record<number, any> = {}
|
const newPositions: Record<number, any> = {}
|
||||||
for (const taskIdStr in taskBarPositions.value) {
|
for (const taskIdStr in taskBarPositions.value) {
|
||||||
const taskId = parseInt(taskIdStr)
|
// 对象键本身就是字符串,直接与 string Set 比较,避免 parseInt 对非数字 ID 返回 NaN
|
||||||
if (currentTaskIds.has(taskId)) {
|
if (currentTaskIdStrs.has(taskIdStr)) {
|
||||||
newPositions[taskId] = taskBarPositions.value[taskId]
|
newPositions[taskIdStr as unknown as number] = taskBarPositions.value[taskIdStr as unknown as number]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
taskBarPositions.value = newPositions
|
taskBarPositions.value = newPositions
|
||||||
@@ -5222,6 +5402,7 @@ const handleAddSuccessor = (task: Task) => {
|
|||||||
:hovered-task-id="hoveredTaskId"
|
:hovered-task-id="hoveredTaskId"
|
||||||
:vertical-lines="monthFirstVerticalLines"
|
:vertical-lines="monthFirstVerticalLines"
|
||||||
:show-vertical-lines="currentTimeScale === TimelineScale.WEEK"
|
:show-vertical-lines="currentTimeScale === TimelineScale.WEEK"
|
||||||
|
:is-scrolling="isTimelineScrolling"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 连接线拖拽引导线 - 🚀 优化:使用命令式 API,由 RAF 直接调用 draw() -->
|
<!-- 连接线拖拽引导线 - 🚀 优化:使用命令式 API,由 RAF 直接调用 draw() -->
|
||||||
@@ -5410,9 +5591,9 @@ const handleAddSuccessor = (task: Task) => {
|
|||||||
<!-- 同时需要考虑左侧TaskList包含1px的bottom border -->
|
<!-- 同时需要考虑左侧TaskList包含1px的bottom border -->
|
||||||
<div class="task-bar-container" :style="{ height: `${contentHeight}px` }">
|
<div class="task-bar-container" :style="{ height: `${contentHeight}px` }">
|
||||||
<div class="task-rows" :style="{ height: `${contentHeight}px` }">
|
<div class="task-rows" :style="{ height: `${contentHeight}px` }">
|
||||||
<!-- 任务视图:使用虚拟滚动渲染可见任务 -->
|
<!-- 任务视图:使用虚拟滚动分批渲染可见任务(taskRenderedItems 每帧限流 3 行新增)-->
|
||||||
<div
|
<div
|
||||||
v-for="{ task, originalIndex } in visibleTasks"
|
v-for="{ task, originalIndex } in taskRenderedItems"
|
||||||
v-if="viewMode === 'task'"
|
v-if="viewMode === 'task'"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
class="task-row"
|
class="task-row"
|
||||||
|
|||||||
@@ -91,7 +91,21 @@ const canvasStyle = computed(() => ({
|
|||||||
// 监听任务列表变化
|
// 监听任务列表变化
|
||||||
watch(() => props.tasks, () => {
|
watch(() => props.tasks, () => {
|
||||||
if (isDraggingTaskBar.value) {
|
if (isDraggingTaskBar.value) {
|
||||||
|
// 拖拽进行中:标记需要重算,等拖拽结束后再执行
|
||||||
needsRecalculation.value = true
|
needsRecalculation.value = true
|
||||||
|
} else {
|
||||||
|
// 拖拽结束后的已提交数据变更:isDraggingTaskBar 已先于数据更新变为 false,
|
||||||
|
// 此处直接触发增量/全量重算,避免冲突层不刷新。
|
||||||
|
nextTick(() => {
|
||||||
|
if (lastChangedTaskId.value !== null) {
|
||||||
|
recalculateConflictsIncremental(lastChangedTaskId.value)
|
||||||
|
lastChangedTaskId.value = null
|
||||||
|
} else {
|
||||||
|
texturePatterns.value = { light: null, medium: null, severe: null }
|
||||||
|
coordsCache.clear()
|
||||||
|
recalculateConflicts()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
|||||||
@@ -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