diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b07e74..dabca2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ 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.4.3] - 2025-11-28 + +### Added +- 大数据加载演示 +- Large data loading demonstration + +### Enhancement +- 性能优化:TaskList和Timeline增加虚拟滚动支持,大幅降低节点数和重绘开销 +- 性能优化:大数据量任务渲染性能优化 +- 性能优化:虚拟渲染优化 +- Performance Optimization: TaskList and Timeline add virtualized scrolling support, greatly reducing node count and redraw overhead +- Performance Optimization: Large data volume task rendering performance optimization +- Performance Optimization: Virtualized rendering optimization + +### Fixed +- jspdf弱点升级 +- jspdf vulnerabilies + ## [1.4.2-patch3] - 2025-11-13 ### Added diff --git a/demo/App.vue b/demo/App.vue index f9c5fe9..17b505f 100644 --- a/demo/App.vue +++ b/demo/App.vue @@ -3,7 +3,8 @@ import { ref, computed, onMounted, nextTick } from 'vue' import GanttChart from '../src/components/GanttChart.vue' // import TaskDrawer from '../src/components/TaskDrawer.vue' // 移除 import MilestoneDialog from '../src/components/MilestoneDialog.vue' -import demoData from './data.json' +import normalData from './data.json' +import largeData from './data-large-1m.json' import packageInfo from '../package.json' // 导入主题变量 import '../src/styles/theme-variables.css' @@ -22,6 +23,100 @@ const { t, formatTranslation } = useI18n() const tasks = ref([]) const milestones = ref([]) +const rawDataSources = [ + { + key: 'normal', + fileName: 'data.json', + payload: normalData, + }, + { + key: 'large', + fileName: 'data-large-1m.json', + payload: largeData, + }, +] as const + +type DataSourceKey = (typeof rawDataSources)[number]['key'] +type RawDataSource = (typeof rawDataSources)[number] + +const dataSourceOptions = computed(() => { + const dsTranslations = t.value.dataSourceSwitch?.sources ?? {} + return rawDataSources.map(source => { + const optionTexts = dsTranslations[source.key as keyof typeof dsTranslations] || {} + return { + ...source, + label: optionTexts.label || source.fileName, + description: optionTexts.description || '', + badge: optionTexts.badge || source.fileName, + } + }) +}) + +const currentDataSource = ref('large') +const dataLoading = ref(false) + +const cloneData = (data: T): T => { + const globalClone = (globalThis as unknown as { structuredClone?: (value: K) => K }).structuredClone + if (typeof globalClone === 'function') { + return globalClone(data) + } + return JSON.parse(JSON.stringify(data)) as T +} + +const findRawDataSource = (key: DataSourceKey) => rawDataSources.find(source => source.key === key) + +const applyDataSource = (source: RawDataSource) => { + const payload = source.payload as { tasks?: Task[]; milestones?: Task[] } + tasks.value = cloneData(payload.tasks ?? []) + milestones.value = cloneData(payload.milestones ?? []) +} + +const getSourceTexts = (key: DataSourceKey) => { + const dsTranslations = t.value.dataSourceSwitch?.sources ?? {} + return dsTranslations[key as keyof typeof dsTranslations] || { label: key } +} + +const switchDataSource = async ( + key: DataSourceKey, + options: { silent?: boolean; force?: boolean } = {}, +) => { + if (dataLoading.value) return + + const source = findRawDataSource(key) + if (!source) return + + const sourceTexts = getSourceTexts(key) + const displayName = sourceTexts.label || source.fileName + + if (!options.force && currentDataSource.value === key && tasks.value.length > 0) { + showMessage(formatTranslation('dataSourceAlreadyLoaded', { name: displayName }), 'info', { + closable: true, + }) + return + } + + currentDataSource.value = key + dataLoading.value = true + + try { + await nextTick() + applyDataSource(source) + if (!options.silent) { + showMessage(formatTranslation('dataSourceLoadSuccess', { name: displayName }), 'success', { + closable: false, + }) + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('切换数据源失败', error) + showMessage(formatTranslation('dataSourceLoadFailed', { name: displayName }), 'error', { + closable: true, + }) + } finally { + dataLoading.value = false + } +} + // MilestoneDialog状态管理 const showMilestoneDialog = ref(false) const currentMilestone = ref(null) @@ -110,6 +205,7 @@ const taskBarConfig = computed(() => ({ // 配置面板折叠状态 const isConfigPanelCollapsed = ref(false) +const isDataSourcePanelCollapsed = ref(false) // TaskList 配置区域折叠状态(默认收起) const isTaskListConfigCollapsed = ref(true) @@ -122,6 +218,10 @@ const toggleConfigPanel = () => { isConfigPanelCollapsed.value = !isConfigPanelCollapsed.value } +const toggleDataSourcePanel = () => { + isDataSourcePanelCollapsed.value = !isDataSourcePanelCollapsed.value +} + // 切换 TaskList 配置区域 const toggleTaskListConfig = () => { isTaskListConfigCollapsed.value = !isTaskListConfigCollapsed.value @@ -377,8 +477,7 @@ function handleMilestoneDragEnd(newMilestone) { } onMounted(() => { - tasks.value = demoData.tasks as Task[] - milestones.value = demoData.milestones as Task[] + switchDataSource(currentDataSource.value, { silent: true, force: true }) }) // 清理被删除任务的predecessor依赖关系 @@ -480,6 +579,64 @@ function taskDebug(item: any) { +
+
+

+ + + + {{ t.dataSourceSwitch?.title }} - {{ t.dataSourceSwitch?.subtitle }} +

+ +
+ +
+
+ + + {{ t.dataSourceSwitch?.loading }} + +
+
+
+
+
@@ -505,7 +662,7 @@ function taskDebug(item: any) { xmlns="http://www.w3.org/2000/svg" > 伦理审查", "assignee": "方案设计师 李明", - "avatar": "https://i.pravatar.cc/150?img=1", + "avatar": "https://i.pravatar.cc/50?img=1", "startDate": "2025-01-01", "endDate": "2025-02-28", "progress": 100, @@ -46,7 +46,7 @@ "id": 1102, "name": "受试者招募与筛选", "assignee": "招募专员 张丽", - "avatar": "https://i.pravatar.cc/150?img=5", + "avatar": "https://i.pravatar.cc/50?img=5", "startDate": "2025-03-01", "endDate": "2025-04-30", "progress": 90, @@ -62,7 +62,7 @@ "id": 1103, "name": "药物给药与安全性监测", "assignee": "临床医生 Dr. Liu", - "avatar": "https://i.pravatar.cc/150?img=8", + "avatar": "https://i.pravatar.cc/50?img=8", "startDate": "2025-05-01", "endDate": "2025-08-31", "progress": 40, @@ -95,7 +95,7 @@ "id": 1201, "name": "多中心试验启动", "assignee": "项目经理 王芳", - "avatar": "https://i.pravatar.cc/150?img=10", + "avatar": "https://i.pravatar.cc/50?img=10", "startDate": "2025-09-01", "endDate": "2025-11-30", "progress": 25, @@ -111,7 +111,7 @@ "id": 1202, "name": "患者入组与随机化", "assignee": "数据管理员 陈静", - "avatar": "https://i.pravatar.cc/150?img=20", + "avatar": "https://i.pravatar.cc/50?img=20", "startDate": "2025-12-01", "endDate": "2026-03-31", "progress": 0, @@ -127,7 +127,7 @@ "id": 1203, "name": "疗效评估与数据收集", "assignee": "统计师 赵磊", - "avatar": "https://i.pravatar.cc/150?img=15", + "avatar": "https://i.pravatar.cc/50?img=15", "startDate": "2026-01-01", "endDate": "2026-08-31", "progress": 0, diff --git a/demo/version-history.json b/demo/version-history.json index ef93df1..0c50962 100644 --- a/demo/version-history.json +++ b/demo/version-history.json @@ -306,5 +306,21 @@ "特别感谢 @qiuchengw的专业贡献", "Special thanks to @qiuchengw for his professional contribution" ] + }, + { + "version": "1.4.3", + "date": "2025-11-28", + "notes": [ + "性能优化:TaskList和Timeline增加虚拟滚动支持,大幅降低节点数和重绘开销", + "性能优化:大数据量任务渲染性能优化", + "性能优化:虚拟渲染优化", + "新增:大数据加载演示", + "修复:jspdf弱点升级", + "Performance Optimization: TaskList and Timeline add virtualized scrolling support, greatly reducing node count and redraw overhead", + "Performance Optimization: Large data volume task rendering performance optimization", + "Performance Optimization: Virtualized rendering optimization", + "Added: Large data loading demonstration", + "Fix: jspdf vulnerabilies" + ] } ] diff --git a/package-lock.json b/package-lock.json index 48ddde8..b8e08a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jordium-gantt-vue3", - "version": "1.4.2", + "version": "1.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jordium-gantt-vue3", - "version": "1.4.2", + "version": "1.4.3", "license": "MIT", "dependencies": { "date-fns": "^4.1.0", @@ -60,9 +60,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "engines": { "node": ">=6.9.0" } @@ -957,6 +957,11 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==" + }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz", @@ -1488,17 +1493,6 @@ "node": ">=8" } }, - "node_modules/atob": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/atob/-/atob-2.1.2.tgz", - "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", - "bin": { - "atob": "bin/atob.js" - }, - "engines": { - "node": ">= 4.5.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1540,17 +1534,6 @@ "node": ">=8" } }, - "node_modules/btoa": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/btoa/-/btoa-1.2.1.tgz", - "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", - "bin": { - "btoa": "bin/btoa.js" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", @@ -2090,6 +2073,16 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz", @@ -2375,6 +2368,11 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2421,9 +2419,9 @@ "dev": true }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "dependencies": { "argparse": "^2.0.1" @@ -2451,13 +2449,12 @@ "dev": true }, "node_modules/jspdf": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-3.0.1.tgz", - "integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz", + "integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==", "dependencies": { - "@babel/runtime": "^7.26.7", - "atob": "^2.1.2", - "btoa": "^1.2.1", + "@babel/runtime": "^7.28.4", + "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { @@ -2676,6 +2673,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", @@ -3213,9 +3215,9 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmmirror.com/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "dependencies": { "esbuild": "^0.25.0", diff --git a/package.json b/package.json index d2141c5..5293dea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jordium-gantt-vue3", - "version": "1.4.2-patch.3", + "version": "1.4.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/GanttLinks.vue b/src/components/GanttLinks.vue index c0e76b2..a5523cc 100644 --- a/src/components/GanttLinks.vue +++ b/src/components/GanttLinks.vue @@ -24,6 +24,7 @@ interface Props { width: number height: number offsetLeft?: number // Canvas 在全局坐标系中的偏移量(用于虚拟渲染) + offsetTop?: number // Canvas 在垂直方向的偏移量(用于虚拟渲染) highlightedTaskId: number | null highlightedTaskIds: Set hoveredTaskId: number | null @@ -36,6 +37,7 @@ const props = withDefaults(defineProps(), { verticalLines: () => [], showVerticalLines: true, offsetLeft: 0, + offsetTop: 0, }) // Canvas 引用 @@ -124,6 +126,8 @@ const drawLinks = () => { // 虚拟渲染:计算 Canvas 覆盖的范围 const canvasStartX = props.offsetLeft const canvasEndX = props.offsetLeft + displayWidth + const canvasStartY = props.offsetTop + const canvasEndY = props.offsetTop + displayHeight // 定义线条数据类型 interface LineData { @@ -157,16 +161,6 @@ const drawLinks = () => { continue } - // 虚拟渲染:跳过不在 Canvas 覆盖范围内的关系线 - // 如果起点和终点都在 Canvas 外,跳过 - const fromX = fromBar.left + fromBar.width - const toX = toBar.left - const lineMinX = Math.min(fromX, toX) - const lineMaxX = Math.max(fromX, toX) - if (lineMaxX < canvasStartX || lineMinX > canvasEndX) { - continue // 完全在 Canvas 外,跳过 - } - // 判断高亮状态 const fromIsPrimary = props.highlightedTaskId === predecessorId const toIsPrimary = props.highlightedTaskId === task.id @@ -189,11 +183,26 @@ const drawLinks = () => { const globalX2 = toBar.left const globalY2 = toBar.top + toBar.height / 2 + toYOffset + // 虚拟渲染:跳过不在 Canvas 覆盖范围内的关系线 + // 如果起点和终点都在 Canvas 外,跳过 + const lineMinX = Math.min(globalX1, globalX2) + const lineMaxX = Math.max(globalX1, globalX2) + const lineMinY = Math.min(globalY1, globalY2) + const lineMaxY = Math.max(globalY1, globalY2) + if ( + lineMaxX < canvasStartX || + lineMinX > canvasEndX || + lineMaxY < canvasStartY || + lineMinY > canvasEndY + ) { + continue // 完全在 Canvas 外,跳过 + } + // 转换为 Canvas 局部坐标 const x1 = globalX1 - props.offsetLeft - const y1 = globalY1 + const y1 = globalY1 - props.offsetTop const x2 = globalX2 - props.offsetLeft - const y2 = globalY2 + const y2 = globalY2 - props.offsetTop const c1x = x1 + 40 const c1y = y1 @@ -369,6 +378,7 @@ watch( () => props.verticalLines, () => props.showVerticalLines, () => props.offsetLeft, // 监听虚拟渲染的偏移量变化 + () => props.offsetTop, ], () => { // 使用 RAF 调度重绘,合并连续的多次变化为单次绘制 @@ -430,7 +440,7 @@ defineExpose({ top: 0, width: `${width}px`, height: `${height}px`, - transform: `translateX(${offsetLeft}px)`, + transform: `translate(${offsetLeft}px, ${offsetTop}px)`, zIndex: highlightedTaskId !== null ? 1001 : 25, pointerEvents: 'none', }" diff --git a/src/components/TaskBar.vue b/src/components/TaskBar.vue index d704af0..6a97827 100644 --- a/src/components/TaskBar.vue +++ b/src/components/TaskBar.vue @@ -119,6 +119,25 @@ const createLocalToday = (): Date => { return new Date(now.getFullYear(), now.getMonth(), now.getDate()) } +// 缓存今天的日期,避免频繁创建 +// 每分钟更新一次缓存(对于日期判断来说足够了) +const cachedToday = ref(createLocalToday()) +let todayCacheTimer: number | null = null + +onMounted(() => { + // 每60秒更新一次今天的日期缓存 + todayCacheTimer = window.setInterval(() => { + cachedToday.value = createLocalToday() + }, 60000) +}) + +onUnmounted(() => { + if (todayCacheTimer !== null) { + clearInterval(todayCacheTimer) + todayCacheTimer = null + } +}) + const formatDateToLocalString = (date: Date): string => { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') @@ -223,7 +242,7 @@ const taskBarStyle = computed(() => { const startDate = createLocalDate(currentStartDate) const endDate = createLocalDate(currentEndDate) - const baseStart = createLocalDate(props.startDate) + const baseStart = parsedBaseStartDate.value if (!startDate || !endDate || !baseStart) { return { left: '0px', @@ -392,6 +411,24 @@ const taskBarStyle = computed(() => { } }) +// 缓存 TaskBar 的位置信息,减少 DOM 读取频率 +const cachedPosition = ref({ + left: 0, + top: 0, + width: 0, + height: 0, + timestamp: 0, +}) + +// 位置缓存有效期(毫秒) +const POSITION_CACHE_TTL = 100 // 100ms 内使用缓存值 + +// 缓存解析后的结束日期,避免在 taskStatus 中重复解析 +const parsedEndDate = computed(() => createLocalDate(props.task.endDate || '')) + +// 缓存解析后的基准开始日期 +const parsedBaseStartDate = computed(() => createLocalDate(props.startDate)) + // 计算任务状态和颜色 const taskStatus = computed(() => { // 父级任务(Story类型)使用与新建按钮一致的配色 @@ -404,8 +441,9 @@ const taskStatus = computed(() => { } } - const today = createLocalToday() - const endDate = createLocalDate(props.task.endDate || '') + // 使用缓存的今天日期,避免频繁创建日期对象 + const today = cachedToday.value + const endDate = parsedEndDate.value const progress = props.task.progress || 0 // 已完成 @@ -556,6 +594,8 @@ const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-r const timelineContainer = document.querySelector('.timeline') as HTMLElement if (!timelineContainer || !barRef.value) return + // 在 mousedown 事件中读取位置是合理的(不是高频操作) + // 这个值用于计算拖拽偏移量,只在开始拖拽时读取一次 const barRect = barRef.value.getBoundingClientRect() // 计算鼠标相对于TaskBar的位置 @@ -595,17 +635,57 @@ const handleAutoScroll = (event: CustomEvent) => { } } +// 使用缓存机制减少 DOM 读取频率,但保证位置准确性 +let reportPositionScheduled = false + function reportBarPosition() { - if (barRef.value) { + // 如果已经安排了本帧的位置报告,则跳过 + if (reportPositionScheduled) return + + reportPositionScheduled = true + + requestAnimationFrame(() => { + reportPositionScheduled = false + + if (!barRef.value) return + + const now = Date.now() + + // 如果缓存未过期,使用缓存值 + if (now - cachedPosition.value.timestamp < POSITION_CACHE_TTL) { + emit('bar-mounted', { + id: props.task.id, + left: cachedPosition.value.left, + top: cachedPosition.value.top, + width: cachedPosition.value.width, + height: cachedPosition.value.height, + }) + return + } + + // 缓存过期或首次调用,读取 DOM 并更新缓存 + // TaskBar 传递绝对位置(相对于视口),Timeline 会将其转换为相对位置 const rect = barRef.value.getBoundingClientRect() - emit('bar-mounted', { - id: props.task.id, + + // 计算并缓存位置 + const position = { left: rect.left, top: rect.top, width: rect.width, height: rect.height, + } + + // 更新缓存 + cachedPosition.value = { + ...position, + timestamp: now, + } + + emit('bar-mounted', { + id: props.task.id, + ...position, }) - } + }) } // 拖拽时的实时日期提示框状态 @@ -1123,8 +1203,20 @@ const handleMouseUp = () => { onMounted(() => { nextTick(() => { reportBarPosition() + + // 使用 ResizeObserver 监听任务名称宽度变化 if (taskBarNameRef.value) { - nameTextWidth.value = taskBarNameRef.value.getBoundingClientRect().width + const nameResizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + nameTextWidth.value = entry.contentRect.width + } + }) + nameResizeObserver.observe(taskBarNameRef.value) + + // 组件卸载时清理 + onUnmounted(() => { + nameResizeObserver.disconnect() + }) } }) @@ -1395,7 +1487,7 @@ const stickyStyles = computed(() => { } else if (nameNeedsRightSticky) { const offset = rightBoundary - taskLeft - nameWidth // name 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度 - nameLeft = `${offset - handleWidth - 3}px` // 考虑手柄宽度 + 间距 + nameLeft = `${offset - handleWidth - 10}px` // 考虑手柄宽度 + 间距 namePosition = 'absolute' nameTop = '2px' } @@ -1431,7 +1523,7 @@ const stickyStyles = computed(() => { progressTop = '18px' } else if (progressNeedsRightSticky) { const offset = rightBoundary - taskLeft - progressWidth - // progress 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度 + // 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度 progressLeft = `${offset - handleWidth - 3}px` // 考虑手柄宽度 + 间距 progressPosition = 'absolute' progressTop = '18px' diff --git a/src/components/TaskList.vue b/src/components/TaskList.vue index 555f8bb..695483e 100644 --- a/src/components/TaskList.vue +++ b/src/components/TaskList.vue @@ -35,6 +35,20 @@ const { t } = useI18n() // TaskList 容器引用 const taskListRef = ref(null) +const taskListBodyRef = ref(null) + +// 缓存容器宽度,避免频繁读取 offsetWidth 导致强制重排 +const cachedContainerWidth = ref(0) + +// 使用 ResizeObserver 监听容器宽度变化 +let containerResizeObserver: ResizeObserver | null = null +let bodyResizeObserver: ResizeObserver | null = null + +// 纵向虚拟滚动相关状态 +const ROW_HEIGHT = 51 // 每行高度(与TaskList Row一致) +const VERTICAL_BUFFER = 5 // 上下额外渲染的缓冲行数 +const taskListScrollTop = ref(0) +const taskListBodyHeight = ref(0) // 获取列宽度样式(百分比转像素) const getColumnWidthStyle = (column: { width?: number | string }) => { @@ -44,7 +58,7 @@ const getColumnWidthStyle = (column: { width?: number | string }) => { // 如果是百分比,转换为像素 if (typeof column.width === 'string' && column.width.includes('%')) { - const containerWidth = taskListRef.value?.offsetWidth || 0 + const containerWidth = cachedContainerWidth.value || 0 if (containerWidth > 0) { const percentage = parseFloat(column.width) / 100 const pixels = Math.floor(containerWidth * percentage) @@ -89,6 +103,14 @@ const handleSplitterDragStart = () => { // 处理拖拽结束事件 const handleSplitterDragEnd = () => { isSplitterDragging.value = false + + // ⚠️ 拖拽结束后,手动更新一次容器宽度,触发列宽重新计算 + if (taskListRef.value) { + const newWidth = taskListRef.value.offsetWidth + if (Math.abs(newWidth - cachedContainerWidth.value) > 1) { + cachedContainerWidth.value = newWidth + } + } } // 处理任务行悬停事件 @@ -228,6 +250,61 @@ const getAllTasks = (taskList: Task[]): Task[] => { return allTasks } +// 获取当前折叠状态下的可见任务列表 +const getFlattenedVisibleTasks = ( + taskList: Task[], + level = 0, +): Array<{ task: Task; level: number }> => { + const result: Array<{ task: Task; level: number }> = [] + + for (const task of taskList) { + result.push({ task, level }) + + const isMilestoneGroup = task.type === 'milestone-group' + + if (!isMilestoneGroup && task.children && task.children.length > 0 && !task.collapsed) { + result.push(...getFlattenedVisibleTasks(task.children, level + 1)) + } + } + + return result +} + +// 扁平化后的可见任务列表 +const flattenedTasks = computed(() => getFlattenedVisibleTasks(localTasks.value)) + +// 计算可视区域任务范围 +const visibleTaskRange = computed(() => { + const scrollTop = taskListScrollTop.value + const containerHeight = taskListBodyHeight.value || 600 + + const startIndex = Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER + const endIndex = Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER + + const total = flattenedTasks.value.length + const clampedStart = Math.min(Math.max(0, startIndex), total) + const clampedEnd = Math.min(total, Math.max(clampedStart + 1, endIndex)) + + return { + startIndex: clampedStart, + endIndex: clampedEnd, + } +}) + +// 虚拟列表中需要渲染的任务 +const visibleTasks = computed(() => { + const { startIndex, endIndex } = visibleTaskRange.value + return flattenedTasks.value.slice(startIndex, endIndex) +}) + +// Spacer 高度用于撑起滚动区域 +const totalContentHeight = computed(() => flattenedTasks.value.length * ROW_HEIGHT) +const startSpacerHeight = computed(() => visibleTaskRange.value.startIndex * ROW_HEIGHT) +const endSpacerHeight = computed(() => { + const visibleHeight = visibleTasks.value.length * ROW_HEIGHT + return Math.max(0, totalContentHeight.value - startSpacerHeight.value - visibleHeight) +}) + // 监听外部传入的 tasks 数据变化 watch( () => props.tasks, @@ -331,16 +408,23 @@ const handleTaskListScroll = (event: Event) => { const scrollTop = target.scrollTop + taskListScrollTop.value = scrollTop + // 同步垂直滚动到Timeline window.dispatchEvent( new CustomEvent('task-list-vertical-scroll', { detail: { scrollTop }, }), ) -} // 处理Timeline垂直滚动同步 +} + +// 处理Timeline垂直滚动同步 const handleTimelineVerticalScroll = (event: CustomEvent) => { const { scrollTop } = event.detail - const taskListBodyElement = document.querySelector('.task-list-body') as HTMLElement + const taskListBodyElement = taskListBodyRef.value + + taskListScrollTop.value = scrollTop + if (taskListBodyElement && Math.abs(taskListBodyElement.scrollTop - scrollTop) > 1) { // 使用更精确的比较,避免1px以内的细微差异导致的循环触发 taskListBodyElement.scrollTop = scrollTop @@ -358,6 +442,7 @@ const handleTaskRowContextMenu = (event: { task: Task; position: { x: number; y: }), ) } catch (error) { + // eslint-disable-next-line no-console console.error('TaskList - Failed to dispatch context-menu event', error) } } @@ -386,6 +471,37 @@ const handleTaskDelete = (task: Task, deleteChildren?: boolean) => { } onMounted(async () => { + // 初始化 ResizeObserver 监听容器宽度变化 + if (taskListRef.value) { + containerResizeObserver = new ResizeObserver((entries) => { + // ⚠️ 拖拽期间跳过更新,避免高频重新计算列宽 + // TaskList的列宽不需要在拖拽期间实时更新,只需在拖拽结束后更新一次 + if (isSplitterDragging.value) { + return + } + + for (const entry of entries) { + // 使用 contentRect.width 避免强制重排 + cachedContainerWidth.value = entry.contentRect.width + } + }) + containerResizeObserver.observe(taskListRef.value) + } + + // 监听TaskList body高度变化,提供虚拟滚动所需尺寸 + if (taskListBodyRef.value) { + taskListBodyHeight.value = taskListBodyRef.value.clientHeight + taskListScrollTop.value = taskListBodyRef.value.scrollTop + + bodyResizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + taskListBodyHeight.value = entry.contentRect.height + } + }) + + bodyResizeObserver.observe(taskListBodyRef.value) + } + window.addEventListener('task-updated', handleTaskUpdated as EventListener) window.addEventListener('task-added', handleTaskAdded as EventListener) window.addEventListener('request-task-list', handleRequestTaskList as EventListener) @@ -401,6 +517,17 @@ onMounted(async () => { }) onUnmounted(() => { + // 清理 ResizeObserver + if (containerResizeObserver) { + containerResizeObserver.disconnect() + containerResizeObserver = null + } + + if (bodyResizeObserver) { + bodyResizeObserver.disconnect() + bodyResizeObserver = null + } + window.removeEventListener('task-updated', handleTaskUpdated as EventListener) window.removeEventListener('task-added', handleTaskAdded as EventListener) window.removeEventListener('request-task-list', handleRequestTaskList as EventListener) @@ -433,17 +560,20 @@ onUnmounted(() => { {{ (t as any)[column.key] || column.label }}
-
- +
+ + { + +
@@ -509,14 +641,18 @@ onUnmounted(() => { width: max-content; background: var(--gantt-bg-primary); flex: 1; - overflow-x: hidden; /* 让body部分可以滚动 */ - overflow-y: auto; /* 允许垂直滚动 */ + overflow-x: hidden; /* 允许横向滚动,确保列完整展示 */ + overflow-y: auto; /* Webkit浏览器滚动条样式 */ scrollbar-width: thin; scrollbar-color: var(--gantt-scrollbar-thumb) transparent; } +.task-list-body-spacer { + width: 100%; +} + .task-list-body::-webkit-scrollbar { width: 8px; height: 8px; diff --git a/src/components/TaskRow.vue b/src/components/TaskRow.vue index d83e211..e5d2d6c 100644 --- a/src/components/TaskRow.vue +++ b/src/components/TaskRow.vue @@ -1,6 +1,6 @@