diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9f972..bf23f44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,45 @@ 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.8.0] - 2026-02-05 +## [1.9.0] - 2026-02-07 + +### Added +- 🎉 新增:GanttChart支持资源视图模式,通过viewMode属性切换任务视图和资源视图 +- 🎉 新增:资源视图以资源为行维度展示,支持一行多任务条布局 +- 🎉 新增:Task对象增加resources字段,支持配置每个资源的投入占比(10%-100%) +- 🎉 新增:TaskBar高度按资源占比动态缩放,支持占比文字标注和Tooltip显示 +- 🎉 新增:基于占比累计的资源超负荷检测和视觉预警 +- 🎉 新增:TaskDrawer增加资源占比配置控件,支持输入校验 +- 🎉 新增:资源视图支持任务拖拽、拉伸、计时等交互操作 +- 🎉 新增:资源列表支持声明式组件配置列 +- 🎉 新增:Resource数据模型类和useResourceLayout、useViewMode等composables +- 🎉 Added: GanttChart supports resource view mode, switch via viewMode property +- 🎉 Added: Resource view displays by resource dimension with multiple task bars per row +- 🎉 Added: Task object adds resources field for configuring resource allocation percentage (10%-100%) +- 🎉 Added: TaskBar height scales by percentage with text label and Tooltip support +- 🎉 Added: Resource overload detection and visual alerts based on percentage accumulation +- 🎉 Added: TaskDrawer adds resource percentage configuration with input validation +- 🎉 Added: Resource view supports task drag, resize, timing and other interactions +- 🎉 Added: Resource list supports declarative component column configuration +- 🎉 Added: Resource model class and composables including useResourceLayout, useViewMode + +### Enhancement +- 🎉 优化:抽取资源布局计算逻辑到独立composable,避免组件职责膨胀 +- 🎉 优化:扩展v-memo优化覆盖范围,提升渲染性能 +- 🎉 优化:重构provide/inject依赖传递,简化组件依赖关系 +- 🎉 优化:恢复滚动防抖机制,平衡响应性和性能 +- 🎉 优化:历史数据向后兼容,未配置占比时默认为100% +- 🎉 Optimized: Extracted resource layout logic to independent composable +- 🎉 Optimized: Extended v-memo optimization coverage for better performance +- 🎉 Optimized: Refactored provide/inject dependency passing +- 🎉 Optimized: Restored scroll debounce mechanism +- 🎉 Optimized: Backward compatible with historical data, defaults to 100% + +### Fixed +- 🔧 修复: 将Theme设置从HTML root变更到Gantt Component root +- 🔧 Fixed: Changed Theme setting from HTML root to Gantt Component root + +## [1.8.1] - 2026-02-05 ### Enhancement - 🎉 优化:重构组件,Theme作用域从全局调整至局部.gantt-root diff --git a/demo/App.vue b/demo/App.vue index 5e55512..6fa81d7 100644 --- a/demo/App.vue +++ b/demo/App.vue @@ -17,7 +17,7 @@ import { useI18n } from '../src/composables/useI18n' import { useDemoLocale } from './useDemoLocale' import { getPredecessorIds, predecessorIdsToString } from '../src/utils/predecessorUtils' import type { Task } from '../src/models/Task' -import type { Resource } from '../src/models/types/Resource' +import type { Resource } from '../src/models/classes/Resource' import { createResource, addTaskToResource, updateResourceUtilization } from '../src/utils/resourceUtils' import type { TaskListConfig, TaskListColumnConfig } from '../src/models/configs/TaskListConfig' import type { ResourceListConfig } from '../src/models/configs/ResourceListConfig' @@ -626,7 +626,7 @@ const handleTaskClick = (task: Task) => { } // v1.9.7 处理任务双击事件(资源视图下显示资源编辑提示) -const handleTaskDoubleClick = (taskOrResource: Task | Resource) => { +const handleTaskDoubleClick = (taskOrResource: Task | Resource) => { // 使用类型守卫严格判断是否为Resource对象 if (viewMode.value === 'resource' && isResource(taskOrResource)) { // 这是Resource对象,显示资源编辑提示 @@ -635,7 +635,7 @@ const handleTaskDoubleClick = (taskOrResource: Task | Resource) => { resourceEditHintVisible.value = true return } - + // 对于真正的Task对象,GanttChart会正常打开TaskDrawer // useDefaultDrawer保持为true,确保新建任务和TaskBar双击都能正常工作 } @@ -643,9 +643,9 @@ const handleTaskDoubleClick = (taskOrResource: Task | Resource) => { // v1.9.7 类型守卫:判断是否为Resource对象 // Resource独有的特征:有tasks数组属性,且没有resources属性 const isResource = (obj: Task | Resource): obj is Resource => { - return obj && - typeof obj === 'object' && - 'tasks' in obj && + return obj && + typeof obj === 'object' && + 'tasks' in obj && Array.isArray((obj as Resource).tasks) && !('resources' in obj) // Task有resources属性,Resource没有 } @@ -1169,7 +1169,7 @@ const confirmResourceDrag = () => { task.resources.push({ id: targetResource.id, name: targetResource.name, - capacity: oldCapacity + capacity: oldCapacity, }) } } @@ -3453,196 +3453,196 @@ const handleCustomMenuAction = (action: string, task: Task) => { } /* 全局暗色主题支持 */ -:global(html[data-theme='dark']) { +:global(.gantt-root[data-theme='dark']) { background: #1e1e1e !important; } -:global(html[data-theme='dark']) body { +:global(.gantt-root[data-theme='dark']) body { background: #1e1e1e !important; color: #e5e5e5 !important; } /* 暗色主题下的页面标题 */ -:global(html[data-theme='dark']) .page-title { +:global(.gantt-root[data-theme='dark']) .page-title { background: #1e1e1e; color: #e5e5e5; } /* 暗色主题下的配置面板样式 */ -:global(html[data-theme='dark']) .config-panel { +:global(.gantt-root[data-theme='dark']) .config-panel { background: var(--gantt-bg-primary, #2d3748); border-color: var(--gantt-border-color, #4a5568); } -:global(html[data-theme='dark']) .data-source-panel { +:global(.gantt-root[data-theme='dark']) .data-source-panel { background: var(--gantt-bg-primary, #2d3748); border-color: var(--gantt-border-color, #4a5568); box-shadow: none; } -:global(html[data-theme='dark']) .data-source-sub { +:global(.gantt-root[data-theme='dark']) .data-source-sub { color: var(--gantt-text-secondary, #a0aec0); } -:global(html[data-theme='dark']) .data-source-button { +:global(.gantt-root[data-theme='dark']) .data-source-button { background: var(--gantt-bg-secondary, #1a202c); border-color: var(--gantt-border-color, #4a5568); } -:global(html[data-theme='dark']) .data-source-button.active { +:global(.gantt-root[data-theme='dark']) .data-source-button.active { background: rgba(64, 158, 255, 0.18); border-color: var(--gantt-primary-color, #66b3ff); } -:global(html[data-theme='dark']) .ds-label, -:global(html[data-theme='dark']) .ds-desc, -:global(html[data-theme='dark']) .ds-file { +:global(.gantt-root[data-theme='dark']) .ds-label, +:global(.gantt-root[data-theme='dark']) .ds-desc, +:global(.gantt-root[data-theme='dark']) .ds-file { color: var(--gantt-text-primary, #e2e8f0); } -:global(html[data-theme='dark']) .ds-file { +:global(.gantt-root[data-theme='dark']) .ds-file { color: var(--gantt-text-muted, #a0aec0); } -:global(html[data-theme='dark']) .config-title { +:global(.gantt-root[data-theme='dark']) .config-title { color: var(--gantt-text-primary, #e2e8f0); } -:global(html[data-theme='dark']) .section-title { +:global(.gantt-root[data-theme='dark']) .section-title { color: var(--gantt-text-primary, #e2e8f0); border-bottom-color: var(--gantt-primary-color, #66b3ff); } -:global(html[data-theme='dark']) .section-header { +:global(.gantt-root[data-theme='dark']) .section-header { border-bottom-color: var(--gantt-primary-color, #66b3ff); } -:global(html[data-theme='dark']) .section-header:hover { +:global(.gantt-root[data-theme='dark']) .section-header:hover { border-bottom-color: var(--gantt-primary-color-light, #74c0fc); } -:global(html[data-theme='dark']) .section-header-title { +:global(.gantt-root[data-theme='dark']) .section-header-title { color: var(--gantt-text-primary, #e2e8f0); } -:global(html[data-theme='dark']) .section-collapse-button { +:global(.gantt-root[data-theme='dark']) .section-collapse-button { color: var(--gantt-text-secondary, #a0aec0); } -:global(html[data-theme='dark']) .section-collapse-button:hover { +:global(.gantt-root[data-theme='dark']) .section-collapse-button:hover { color: var(--gantt-primary-color, #66b3ff); } -:global(html[data-theme='dark']) .section-icon { +:global(.gantt-root[data-theme='dark']) .section-icon { color: var(--gantt-primary-color, #66b3ff); } -:global(html[data-theme='dark']) .subsection { +:global(.gantt-root[data-theme='dark']) .subsection { border-left-color: var(--gantt-border-color, #4a5568); } -:global(html[data-theme='dark']) .subsection-title { +:global(.gantt-root[data-theme='dark']) .subsection-title { color: var(--gantt-text-secondary, #a0aec0); } -:global(html[data-theme='dark']) .section-subtitle { +:global(.gantt-root[data-theme='dark']) .section-subtitle { color: var(--gantt-primary-color, #66b3ff); background: rgba(102, 179, 255, 0.15); border-left-color: var(--gantt-primary-color, #66b3ff); } -:global(html[data-theme='dark']) .subsection-title::before { +:global(.gantt-root[data-theme='dark']) .subsection-title::before { background-color: var(--gantt-primary-color, #66b3ff); } -:global(html[data-theme='dark']) .column-control { +:global(.gantt-root[data-theme='dark']) .column-control { background: var(--gantt-bg-secondary, #1a202c); } -:global(html[data-theme='dark']) .column-control:hover { +:global(.gantt-root[data-theme='dark']) .column-control:hover { background: var(--gantt-hover-bg, #2d3748); } -:global(html[data-theme='dark']) .column-label { +:global(.gantt-root[data-theme='dark']) .column-label { color: var(--gantt-text-primary, #e2e8f0); } -:global(html[data-theme='dark']) .column-control:hover .column-label { +:global(.gantt-root[data-theme='dark']) .column-control:hover .column-label { color: var(--gantt-primary-color, #66b3ff); } /* 暗色主题下的TaskBar配置样式 */ -:global(html[data-theme='dark']) .taskbar-control { +:global(.gantt-root[data-theme='dark']) .taskbar-control { background: var(--gantt-bg-secondary, #1a202c); } -:global(html[data-theme='dark']) .taskbar-control:hover { +:global(.gantt-root[data-theme='dark']) .taskbar-control:hover { background: var(--gantt-hover-bg, #2d3748); } -:global(html[data-theme='dark']) .taskbar-label { +:global(.gantt-root[data-theme='dark']) .taskbar-label { color: var(--gantt-text-primary, #e2e8f0); } -:global(html[data-theme='dark']) .taskbar-control:hover .taskbar-label { +:global(.gantt-root[data-theme='dark']) .taskbar-control:hover .taskbar-label { color: var(--gantt-primary-color, #66b3ff); } /* 暗色主题下的 TaskBar 高级配置 */ -:global(html[data-theme='dark']) .control-row { +:global(.gantt-root[data-theme='dark']) .control-row { background: var(--gantt-bg-secondary, #1a202c); border-color: var(--gantt-border-color, #4a5568); } -:global(html[data-theme='dark']) .control-label { +:global(.gantt-root[data-theme='dark']) .control-label { color: var(--gantt-text-secondary, #a0aec0); } -:global(html[data-theme='dark']) .control-input { +:global(.gantt-root[data-theme='dark']) .control-input { background: var(--gantt-bg-primary, #2d3748); color: var(--gantt-text-primary, #e2e8f0); border-color: var(--gantt-border-color, #4a5568); } -:global(html[data-theme='dark']) .control-hint { +:global(.gantt-root[data-theme='dark']) .control-hint { color: var(--gantt-text-muted, #718096); } -:global(html[data-theme='dark']) .taskbar-field-control { +:global(.gantt-root[data-theme='dark']) .taskbar-field-control { background: var(--gantt-bg-secondary, #1a202c); border-color: var(--gantt-border-color, #4a5568); } -:global(html[data-theme='dark']) .field-label { +:global(.gantt-root[data-theme='dark']) .field-label { color: var(--gantt-text-secondary, #a0aec0); } -:global(html[data-theme='dark']) .field-select { +:global(.gantt-root[data-theme='dark']) .field-select { background: var(--gantt-bg-primary, #2d3748); color: var(--gantt-text-primary, #e2e8f0); border-color: var(--gantt-border-color, #4a5568); } /* 暗色主题下的折叠面板样式 */ -:global(html[data-theme='dark']) .config-header { +:global(.gantt-root[data-theme='dark']) .config-header { border-bottom-color: var(--gantt-border-color, #4a5568); } -:global(html[data-theme='dark']) .config-header:hover { +:global(.gantt-root[data-theme='dark']) .config-header:hover { background-color: var(--gantt-hover-bg, #2d3748); } -:global(html[data-theme='dark']) .collapse-button { +:global(.gantt-root[data-theme='dark']) .collapse-button { color: var(--gantt-text-secondary, #a0aec0); } -:global(html[data-theme='dark']) .collapse-button:hover { +:global(.gantt-root[data-theme='dark']) .collapse-button:hover { background-color: var(--gantt-hover-bg, #2d3748); color: var(--gantt-primary-color, #66b3ff); } /* 暗黑模式下的版本标签 */ -:global(html[data-theme='dark']) .version-badge { +:global(.gantt-root[data-theme='dark']) .version-badge { background: linear-gradient(135deg, #1a73e8 0%, #00bcd4 50%, #3f51b5 100%); box-shadow: 0 0 25px rgba(102, 177, 255, 0.4), @@ -3650,7 +3650,7 @@ const handleCustomMenuAction = (action: string, task: Task) => { inset 0 1px 0 rgba(255, 255, 255, 0.1); } -:global(html[data-theme='dark']) .version-badge:hover { +:global(.gantt-root[data-theme='dark']) .version-badge:hover { background: linear-gradient(135deg, #2196f3 0%, #00e5ff 50%, #5c6bc0 100%); box-shadow: 0 0 35px rgba(102, 177, 255, 0.6), @@ -3763,53 +3763,53 @@ const handleCustomMenuAction = (action: string, task: Task) => { /* 移除旧的基于 SVG color 的样式,现在使用 filter */ /* 暗黑模式下覆盖所有链接样式 */ -:global(html[data-theme='dark']) .doc-link { +:global(.gantt-root[data-theme='dark']) .doc-link { color: #ffffff; } -:global(html[data-theme='dark']) .doc-link:hover { +:global(.gantt-root[data-theme='dark']) .doc-link:hover { color: #ffffff; background-color: rgba(255, 255, 255, 0.1); } -:global(html[data-theme='dark']) .doc-link:nth-child(2) { +:global(.gantt-root[data-theme='dark']) .doc-link:nth-child(2) { color: #ffffff !important; } -:global(html[data-theme='dark']) .doc-link:nth-child(2):hover { +:global(.gantt-root[data-theme='dark']) .doc-link:nth-child(2):hover { color: #ffffff !important; background-color: rgba(199, 29, 35, 0.1); } /* 暗黑模式下图标样式 */ -:global(html[data-theme='dark']) .github-link .doc-icon { +:global(.gantt-root[data-theme='dark']) .github-link .doc-icon { filter: brightness(0) saturate(100%) invert(100%); } -:global(html[data-theme='dark']) .github-link:hover .doc-icon { +:global(.gantt-root[data-theme='dark']) .github-link:hover .doc-icon { filter: brightness(0) saturate(100%) invert(70%) sepia(50%) saturate(2000%) hue-rotate(190deg) brightness(1.2); } -:global(html[data-theme='dark']) .gitee-link .doc-icon { +:global(.gantt-root[data-theme='dark']) .gitee-link .doc-icon { filter: brightness(0) saturate(100%) invert(45%) sepia(100%) saturate(1500%) hue-rotate(340deg) brightness(1.1); } -:global(html[data-theme='dark']) .gitee-link:hover .doc-icon { +:global(.gantt-root[data-theme='dark']) .gitee-link:hover .doc-icon { filter: brightness(0) saturate(100%) invert(50%) sepia(100%) saturate(1800%) hue-rotate(340deg) brightness(1.2); } /* 暗黑模式下的里程碑图标发光效果 */ -:global(html[data-theme='dark']) .milestone-group-icon { +:global(.gantt-root[data-theme='dark']) .milestone-group-icon { color: var(--gantt-danger, #f67c7c); fill: var(--gantt-danger, #f67c7c); filter: drop-shadow(0 0 6px var(--gantt-danger, #f67c7c)); animation: milestone-icon-glow-dark 2.5s ease-in-out infinite alternate; } -:global(html[data-theme='dark']) .task-row:hover .milestone-group-icon { +:global(.gantt-root[data-theme='dark']) .task-row:hover .milestone-group-icon { filter: drop-shadow(0 0 10px var(--gantt-danger, #f67c7c)) drop-shadow(0 0 16px rgba(246, 124, 124, 0.4)); animation: milestone-icon-glow-intense-dark 1.8s ease-in-out infinite alternate; @@ -4019,28 +4019,28 @@ const handleCustomMenuAction = (action: string, task: Task) => { } /* 暗黑模式适配 */ -:global(html[data-theme='dark']) .task-click-dialog { +:global(.gantt-root[data-theme='dark']) .task-click-dialog { background: var(--gantt-bg-primary, #1a1a1a); } -:global(html[data-theme='dark']) .task-click-dialog-header h3 { +:global(.gantt-root[data-theme='dark']) .task-click-dialog-header h3 { color: var(--gantt-text-primary, #e0e0e0); } -:global(html[data-theme='dark']) .close-button { +:global(.gantt-root[data-theme='dark']) .close-button { color: var(--gantt-text-secondary, #b0b0b0); } -:global(html[data-theme='dark']) .close-button:hover { +:global(.gantt-root[data-theme='dark']) .close-button:hover { background: var(--gantt-bg-hover, #2a2a2a); color: var(--gantt-text-primary, #e0e0e0); } -:global(html[data-theme='dark']) .property-key { +:global(.gantt-root[data-theme='dark']) .property-key { color: var(--gantt-text-secondary, #b0b0b0); } -:global(html[data-theme='dark']) .property-value { +:global(.gantt-root[data-theme='dark']) .property-value { color: var(--gantt-text-primary, #e0e0e0); } @@ -4079,27 +4079,27 @@ const handleCustomMenuAction = (action: string, task: Task) => { } /* 暗黑模式下的任务行样式 */ -:global(html[data-theme='dark']) :deep(.task-row-success) { +:global(.gantt-root[data-theme='dark']) :deep(.task-row-success) { background: linear-gradient(90deg, #0a3a2a 0%, #1b4d3e 100%) !important; } -:global(html[data-theme='dark']) :deep(.task-row-success:hover) { +:global(.gantt-root[data-theme='dark']) :deep(.task-row-success:hover) { background: linear-gradient(90deg, #0f4d35 0%, #276749 100%) !important; } -:global(html[data-theme='dark']) :deep(.task-row-warning) { +:global(.gantt-root[data-theme='dark']) :deep(.task-row-warning) { background: linear-gradient(90deg, #3d2f1f 0%, #4d3b2a 100%) !important; } -:global(html[data-theme='dark']) :deep(.task-row-warning:hover) { +:global(.gantt-root[data-theme='dark']) :deep(.task-row-warning:hover) { background: linear-gradient(90deg, #4d3b26 0%, #5d4a35 100%) !important; } -:global(html[data-theme='dark']) :deep(.task-row-info) { +:global(.gantt-root[data-theme='dark']) :deep(.task-row-info) { background: linear-gradient(90deg, #2a2a2a 0%, #333333 100%) !important; } -:global(html[data-theme='dark']) :deep(.task-row-info:hover) { +:global(.gantt-root[data-theme='dark']) :deep(.task-row-info:hover) { background: linear-gradient(90deg, #353535 0%, #3d3d3d 100%) !important; } @@ -4151,34 +4151,34 @@ const handleCustomMenuAction = (action: string, task: Task) => { } /* 暗色主题下的自定义菜单 */ -:global(html[data-theme='dark']) .custom-menu { +:global(.gantt-root[data-theme='dark']) .custom-menu { background: #2a2a2a; border-color: #444; } -:global(html[data-theme='dark']) .custom-menu-header { +:global(.gantt-root[data-theme='dark']) .custom-menu-header { background: #1e1e1e; color: #e0e0e0; border-bottom-color: #444; } -:global(html[data-theme='dark']) .custom-menu-item { +:global(.gantt-root[data-theme='dark']) .custom-menu-item { color: #e0e0e0; } -:global(html[data-theme='dark']) .custom-menu-item:hover { +:global(.gantt-root[data-theme='dark']) .custom-menu-item:hover { background: #353535; } -:global(html[data-theme='dark']) .custom-menu-item.danger { +:global(.gantt-root[data-theme='dark']) .custom-menu-item.danger { color: #ff6b6b; } -:global(html[data-theme='dark']) .custom-menu-item.danger:hover { +:global(.gantt-root[data-theme='dark']) .custom-menu-item.danger:hover { background: #3a2020; } -:global(html[data-theme='dark']) .custom-menu-divider { +:global(.gantt-root[data-theme='dark']) .custom-menu-divider { background: #444; } @@ -4485,61 +4485,61 @@ const handleCustomMenuAction = (action: string, task: Task) => { } /* 暗色主题支持 */ -:global(html[data-theme='dark']) .status-panel { +:global(.gantt-root[data-theme='dark']) .status-panel { background: var(--gantt-bg-secondary, #1a202c); border-color: var(--gantt-border-color, #4a5568); } -:global(html[data-theme='dark']) .status-value { +:global(.gantt-root[data-theme='dark']) .status-value { background: var(--gantt-bg-secondary, #1a202c); color: var(--gantt-text-primary, #e2e8f0); border-color: var(--gantt-border-color, #4a5568); } -:global(html[data-theme='dark']) .status-value.active { +:global(.gantt-root[data-theme='dark']) .status-value.active { background: rgba(64, 158, 255, 0.15); border-color: var(--gantt-primary-color, #66b3ff); color: var(--gantt-primary-color, #66b3ff); } -:global(html[data-theme='dark']) .tool-button { +:global(.gantt-root[data-theme='dark']) .tool-button { background: var(--gantt-bg-secondary, #1a202c); color: var(--gantt-text-primary, #e2e8f0); border-color: var(--gantt-border-color, #4a5568); } -:global(html[data-theme='dark']) .tool-button:hover { +:global(.gantt-root[data-theme='dark']) .tool-button:hover { background: var(--gantt-hover-bg, #2d3748); border-color: var(--gantt-primary-color, #66b3ff); color: var(--gantt-primary-color, #66b3ff); } -:global(html[data-theme='dark']) .tool-button.primary { +:global(.gantt-root[data-theme='dark']) .tool-button.primary { background: var(--gantt-primary-color, #409eff); border-color: var(--gantt-primary-color, #409eff); color: white; } -:global(html[data-theme='dark']) .tool-button.primary:hover { +:global(.gantt-root[data-theme='dark']) .tool-button.primary:hover { background: var(--gantt-primary-hover, #66b1ff); border-color: var(--gantt-primary-hover, #66b1ff); } -:global(html[data-theme='dark']) .tool-divider { +:global(.gantt-root[data-theme='dark']) .tool-divider { background: var(--gantt-border-color, #4a5568); } -:global(html[data-theme='dark']) .tool-input { +:global(.gantt-root[data-theme='dark']) .tool-input { background: var(--gantt-bg-primary, #2d3748); color: var(--gantt-text-primary, #e2e8f0); border-color: var(--gantt-border-color, #4a5568); } -:global(html[data-theme='dark']) .tool-label { +:global(.gantt-root[data-theme='dark']) .tool-label { color: var(--gantt-text-secondary, #a0aec0); } -:global(html[data-theme='dark']) .tool-note { +:global(.gantt-root[data-theme='dark']) .tool-note { background: var(--gantt-bg-secondary, #1a202c); color: var(--gantt-text-secondary, #a0aec0); border-left-color: var(--gantt-primary-color, #66b3ff); diff --git a/demo/VersionHistoryDrawer.vue b/demo/VersionHistoryDrawer.vue index 56820f6..b8bd83d 100644 --- a/demo/VersionHistoryDrawer.vue +++ b/demo/VersionHistoryDrawer.vue @@ -268,65 +268,65 @@ onMounted(async () => { } /* 暗黑主题适配,完全对齐TaskDrawer风格 */ -:global(html[data-theme='dark']) .version-history-drawer { +:global(.gantt-root[data-theme='dark']) .version-history-drawer { background: var(--gantt-bg-primary, #6b6b6b) !important; box-shadow: 2px 0 24px rgba(0, 0, 0, 0.4) !important; } -:global(html[data-theme='dark']) .drawer-header { +:global(.gantt-root[data-theme='dark']) .drawer-header { background: var(--gantt-bg-secondary, #4b4b4b) !important; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important; } -:global(html[data-theme='dark']) .drawer-title { +:global(.gantt-root[data-theme='dark']) .drawer-title { color: var(--gantt-text-white, #fff) !important; } -:global(html[data-theme='dark']) .drawer-close-btn:hover { +:global(.gantt-root[data-theme='dark']) .drawer-close-btn:hover { background: var(--gantt-bg-hover, rgba(255, 255, 255, 0.1)) !important; border-radius: 4px; } -:global(html[data-theme='dark']) .drawer-body { +:global(.gantt-root[data-theme='dark']) .drawer-body { background: var(--gantt-bg-primary, #6b6b6b) !important; scrollbar-color: #888888 #4b4b4b !important; } -:global(html[data-theme='dark']) .drawer-body::-webkit-scrollbar-thumb { +:global(.gantt-root[data-theme='dark']) .drawer-body::-webkit-scrollbar-thumb { background: #888888 !important; } -:global(html[data-theme='dark']) .drawer-body::-webkit-scrollbar-track { +:global(.gantt-root[data-theme='dark']) .drawer-body::-webkit-scrollbar-track { background: #4b4b4b !important; } -:global(html[data-theme='dark']) .version-timeline-content.version-card { +:global(.gantt-root[data-theme='dark']) .version-timeline-content.version-card { background: var(--gantt-bg-secondary, #4b4b4b) !important; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.32) !important; color: var(--gantt-text-white, #fff) !important; } -:global(html[data-theme='dark']) +:global(.gantt-root[data-theme='dark']) .version-timeline-group:hover .version-timeline-content.version-card { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.44) !important; } -:global(html[data-theme='dark']) .version-timeline-content.version-card::before { +:global(.gantt-root[data-theme='dark']) .version-timeline-content.version-card::before { filter: drop-shadow(-2px 0 2px #222) !important; } -:global(html[data-theme='dark']) .dot-version { +:global(.gantt-root[data-theme='dark']) .dot-version { color: var(--gantt-primary, #3399ff) !important; text-shadow: 0 1px 4px rgba(51, 153, 255, 0.12) !important; } -:global(html[data-theme='dark']) .dot-date { +:global(.gantt-root[data-theme='dark']) .dot-date { color: #e0e0e0 !important; } -:global(html[data-theme='dark']) .version-notes { +:global(.gantt-root[data-theme='dark']) .version-notes { color: #e0e0e0 !important; } -:global(html[data-theme='dark']) .version-timeline-dot { +:global(.gantt-root[data-theme='dark']) .version-timeline-dot { background: var(--gantt-timeline-dot, #3399ff) !important; box-shadow: 0 0 0 2px #222 !important; } -:global(html[data-theme='dark']) .version-timeline-dot.latest { +:global(.gantt-root[data-theme='dark']) .version-timeline-dot.latest { background: var(--gantt-primary, #3399ff) !important; } -:global(html[data-theme='dark']) .version-timeline-group:hover .version-timeline-dot { +:global(.gantt-root[data-theme='dark']) .version-timeline-group:hover .version-timeline-dot { background: var(--gantt-primary, #3399ff) !important; } -:global(html[data-theme='dark']) .version-timeline::before { +:global(.gantt-root[data-theme='dark']) .version-timeline::before { background: repeating-linear-gradient(to bottom, #3399ff 0 8px, transparent 8px 16px) !important; } diff --git a/demo/version-history.json b/demo/version-history.json index f1c06f7..fd570d1 100644 --- a/demo/version-history.json +++ b/demo/version-history.json @@ -506,5 +506,43 @@ "🎉 Optimized: Refactored components, Perfectly supports Nuxt3, TailwindCSS and other frameworks", "Special thanks to andrijor@gmail.com for their professional feedback" ] + }, + { + "version": "1.9.0", + "date": "2026-02-07", + "notes": [ + "🎉 新增:GanttChart支持资源视图模式,通过viewMode属性切换任务视图和资源视图", + "🎉 新增:资源视图以资源为行维度展示,支持一行多任务条布局", + "🎉 新增:Task对象增加resources字段,支持配置每个资源的投入占比(10%-100%)", + "🎉 新增:TaskBar高度按资源占比动态缩放,支持占比文字标注和Tooltip显示", + "🎉 新增:基于占比累计的资源超负荷检测和视觉预警", + "🎉 新增:TaskDrawer增加资源占比配置控件,支持输入校验", + "🎉 新增:资源视图支持任务拖拽、拉伸、计时等交互操作", + "🎉 新增:资源列表支持声明式组件配置列", + "🎉 新增:Resource数据模型类和useResourceLayout、useViewMode等composables", + "🎉 优化:抽取资源布局计算逻辑到独立composable,避免组件职责膨胀", + "🎉 优化:扩展v-memo优化覆盖范围,提升渲染性能", + "🎉 优化:重构provide/inject依赖传递,简化组件依赖关系", + "🎉 优化:恢复滚动防抖机制,平衡响应性和性能", + "🎉 优化:历史数据向后兼容,未配置占比时默认为100%", + "🔧 修复: 将Theme设置从HTML root变更到Gantt Component root", + "特别感谢a_sunshine@gitee的使用与反馈", + "🎉 Added: GanttChart supports resource view mode, switch via viewMode property", + "🎉 Added: Resource view displays by resource dimension with multiple task bars per row", + "🎉 Added: Task object adds resources field for configuring resource allocation percentage (10%-100%)", + "🎉 Added: TaskBar height scales by percentage with text label and Tooltip support", + "🎉 Added: Resource overload detection and visual alerts based on percentage accumulation", + "🎉 Added: TaskDrawer adds resource percentage configuration with input validation", + "🎉 Added: Resource view supports task drag, resize, timing and other interactions", + "🎉 Added: Resource list supports declarative component column configuration", + "🎉 Added: Resource model class and composables including useResourceLayout, useViewMode", + "🎉 Optimized: Extracted resource layout logic to independent composable", + "🎉 Optimized: Extended v-memo optimization coverage for better performance", + "🎉 Optimized: Refactored provide/inject dependency passing", + "🎉 Optimized: Restored scroll debounce mechanism", + "🎉 Optimized: Backward compatible with historical data, defaults to 100%", + "🔧 Fixed: Changed Theme setting from HTML root to Gantt Component root", + "Special thanks to a_sunshine@gitee for their use and feedback" + ] } ] diff --git a/npm-demo/src/components/GanttTest.vue b/npm-demo/src/components/GanttTest.vue index 38dc54f..78c33e2 100644 --- a/npm-demo/src/components/GanttTest.vue +++ b/npm-demo/src/components/GanttTest.vue @@ -3,7 +3,7 @@ import { ref, computed, watch } from 'vue' import { GanttChart, TaskListColumn, useI18n, TaskListContextMenu, TaskBarContextMenu } from 'jordium-gantt-vue3' import 'jordium-gantt-vue3/dist/assets/jordium-gantt-vue3.css' -const { t, getTranslation } = useI18n() +const { t, getTranslation } = useI18n(); // GanttChart ref const ganttRef = ref(null) @@ -114,7 +114,7 @@ const tasks = ref([ department: '管理部', departmentCode: 'D001', type: 'task', - }, + } ]) const milestones = ref([ @@ -123,8 +123,234 @@ const milestones = ref([ name: '项目立项', startDate: '2025-10-29', type: 'milestone', - icon: 'diamond', + icon: 'diamond' + } +]) + +// 资源数据源 - 按照Resource类型结构定义 +const resources = ref([ + { + id: 'R001', + name: '张三', + type: '开发工程师', + avatar: 'https://i.pravatar.cc/150?img=1', + description: '前端开发专家,擅长Vue.js和React', + department: '技术部', + skills: ['Vue.js', 'React', 'TypeScript', 'Node.js'], + capacity: 85, + color: '#1890ff', + tasks: [ + { + id: 1000, + name: '前端框架搭建', + startDate: '2025-11-01', + endDate: '2025-11-05', + progress: 80, + type: 'task' + }, + { + id: 1001, + name: '组件库开发', + startDate: '2025-11-06', + endDate: '2025-11-15', + progress: 50, + type: 'task' + }, + { + id: 1002, + name: '页面开发', + startDate: '2025-11-16', + endDate: '2025-11-25', + progress: 20, + type: 'task' + } + ] }, + { + id: 'R002', + name: '李四', + type: '后端工程师', + avatar: 'https://i.pravatar.cc/150?img=2', + description: 'Java后端开发,熟悉Spring全家桶', + department: '技术部', + skills: ['Java', 'Spring Boot', 'MySQL', 'Redis'], + capacity: 85, + color: '#52c41a', + tasks: [ + { + id: 1003, + name: '数据库设计', + startDate: '2025-11-01', + endDate: '2025-11-03', + progress: 100, + type: 'task' + }, + { + id: 1004, + name: 'API接口开发', + startDate: '2025-11-04', + endDate: '2025-11-12', + progress: 60, + type: 'task' + }, + { + id: 1005, + name: '性能优化', + startDate: '2025-11-20', + endDate: '2025-11-28', + progress: 0, + type: 'task' + } + ] + }, + { + id: 'R003', + name: '王五', + type: 'UI设计师', + avatar: 'https://i.pravatar.cc/150?img=3', + description: '资深UI/UX设计师', + department: '设计部', + skills: ['Figma', 'Sketch', 'Photoshop', 'Illustrator'], + capacity: 8, + color: '#faad14', + tasks: [ + { + id: 1006, + name: 'UI原型设计', + startDate: '2025-10-28', + endDate: '2025-11-02', + progress: 100, + type: 'task' + }, + { + id: 1007, + name: '视觉规范制定', + startDate: '2025-11-03', + endDate: '2025-11-08', + progress: 90, + type: 'task' + }, + { + id: 1008, + name: '界面设计', + startDate: '2025-11-09', + endDate: '2025-11-18', + progress: 40, + type: 'task' + } + ] + }, + { + id: 'R004', + name: '赵六', + type: '测试工程师', + avatar: 'https://i.pravatar.cc/150?img=4', + description: '软件测试专家,自动化测试经验丰富', + department: '质量部', + skills: ['Selenium', 'Jest', 'Cypress', 'JMeter'], + capacity: 8, + color: '#f5222d', + tasks: [ + { + id: 1009, + name: '测试计划编写', + startDate: '2025-11-10', + endDate: '2025-11-12', + progress: 70, + type: 'task' + }, + { + id: 1010, + name: '自动化测试脚本', + startDate: '2025-11-13', + endDate: '2025-11-20', + progress: 30, + type: 'task' + }, + { + id: 1011, + name: '功能测试', + startDate: '2025-11-21', + endDate: '2025-11-30', + progress: 0, + type: 'task' + } + ] + }, + { + id: 'R005', + name: '钱七', + type: '产品经理', + avatar: 'https://i.pravatar.cc/150?img=5', + description: '5年产品经验,擅长用户需求分析', + department: '产品部', + skills: ['需求分析', 'Axure', 'PRD撰写', '用户研究'], + capacity: 8, + color: '#722ed1', + tasks: [ + { + id: 1012, + name: '需求调研', + startDate: '2025-10-25', + endDate: '2025-10-30', + progress: 100, + type: 'task' + }, + { + id: 1013, + name: 'PRD文档编写', + startDate: '2025-10-31', + endDate: '2025-11-05', + progress: 85, + type: 'task' + }, + { + id: 1014, + name: '产品验收', + startDate: '2025-11-25', + endDate: '2025-11-30', + progress: 0, + type: 'task' + } + ] + }, + { + id: 'R006', + name: '孙八', + type: '全栈工程师', + avatar: 'https://i.pravatar.cc/150?img=6', + description: '全栈开发,前后端通吃', + department: '技术部', + skills: ['Vue.js', 'Node.js', 'Python', 'Docker'], + capacity: 8, + color: '#13c2c2', + tasks: [ + { + id: 1015, + name: '服务器部署', + startDate: '2025-11-01', + endDate: '2025-11-04', + progress: 100, + type: 'task' + }, + { + id: 1016, + name: 'CI/CD配置', + startDate: '2025-11-05', + endDate: '2025-11-10', + progress: 75, + type: 'task' + }, + { + id: 1017, + name: '微服务架构', + startDate: '2025-11-11', + endDate: '2025-11-22', + progress: 35, + type: 'task' + } + ] + } ]) const customMessages = { @@ -139,7 +365,7 @@ const customMessages = { gantt: { planStartDate: '计划开始时间', //planEndDate: '计划结束时间', - }, + } }, 'en-US': { department: 'Department', @@ -152,16 +378,16 @@ const customMessages = { gantt: { planStartDate: 'Plan Start Date', planEndDate: 'Plan End Date', - }, - }, + } + } } // const tasks = ref([]) // const milestones = ref([]) -const showAddTaskDrawer = ref(false) -const showAddMilestoneDialog = ref(false) -const showTodayLocate = ref(true) +const showAddTaskDrawer = ref(false); +const showAddMilestoneDialog = ref(false); +const showTodayLocate = ref(true); // 定义可动态配置的列 const availableColumns = ref([ @@ -186,7 +412,7 @@ const taskListConfig = { defaultWidth: '50%', // 默认展开宽度50% minWidth: '300px', // 最小宽度300px(默认280px) maxWidth: '1200px', // 最大宽度1200px(默认1160px) - columns: availableColumns.value, + columns: availableColumns.value } // toolbar配置示例 @@ -201,17 +427,18 @@ const toolbarConfig: ToolbarConfig = { showFullscreen: true, // 显示全屏按钮 showTimeScale: true, // 显示时间刻度按钮组 timeScaleDimensions: [ // 显示所有时间刻度维度 - 'hour', 'day', 'week', 'month', 'quarter', 'year', + 'hour', 'day', 'week', 'month', 'quarter', 'year' ], defaultTimeScale: 'week', // 默认选中周视图 - showExpandCollapse: false, // 显示展开/折叠按钮 + showExpandCollapse: false // 显示展开/折叠按钮 } + const newTask = ref({ name: '', startDate: '', - endDate: '', -}) + endDate: '' +}); const addTask = () => { tasks.value.push({ @@ -220,10 +447,10 @@ const addTask = () => { startDate: newTask.value.startDate, endDate: newTask.value.endDate, progress: 0, - }) - newTask.value = { name: '', startDate: '', endDate: '' } - showAddTaskDrawer.value = false -} + }); + newTask.value = { name: '', startDate: '', endDate: '' }; + showAddTaskDrawer.value = false; +}; const addMilestone = () => { milestones.value.push({ @@ -232,11 +459,11 @@ const addMilestone = () => { startDate: newTask.value.startDate, progress: 0, type: 'milestone', - icon: 'diamond', - }) - //console.log('milestones: ', milestones.value) - newTask.value = { name: '', startDate: '', endDate: '' } - showAddMilestoneDialog.value = false + icon: 'diamond' + }); + console.log('milestones: ', milestones.value) + newTask.value = { name: '', startDate: '', endDate: '' }; + showAddMilestoneDialog.value = false; } const onTaskDblclick = (task) => { @@ -293,18 +520,18 @@ const handleTaskRowMoved = async (payload: { // 此回调仅用于补充业务逻辑,例如根据assignee填充assigneeName等 const onTaskAdded = (res) => { // 组件已自动添加任务,这里只需要找到并更新额外字段 - const addedTask = tasks.value.find(t => t.id === res.task.id) + const addedTask = tasks.value.find(t => t.id === res.task.id); if (addedTask && addedTask.assignee) { // 根据assignee值查找对应的label并赋值给assigneeName - const assigneeOption = assigneeOptions.value.find(option => option.value === addedTask.assignee) + const assigneeOption = assigneeOptions.value.find(option => option.value === addedTask.assignee); if (assigneeOption) { - addedTask.assigneeName = assigneeOption.label + addedTask.assigneeName = assigneeOption.label; } } // 不需要手动push,组件已处理 -} +}; // 自定义右键菜单操作处理 const handleCustomMenuAction = (action: string, task: Task, onClose: () => void) => { @@ -315,234 +542,16 @@ const handleCustomMenuAction = (action: string, task: Task, onClose: () => void) - - 🔧 External Control Demo - - - - - Fullscreen: - - {{ fullscreenStatus ? '✓ Yes' : '✗ No' }} - - - - Expand All: - - {{ expandStatus ? '✓ Yes' : '✗ No' }} - - - - Locale: - {{ currentLocaleStatus }} - - - Time Scale: - {{ currentScaleStatus }} - - - Theme: - {{ currentThemeStatus }} - - - Control Mode: - - {{ controlMode === 'props' ? '📝 Props' : '⚡ Expose' }} - - - - - - - 🎛️ Control Mode - - - ⚡ Expose Methods - - - 📝 Props Control - - - - - - - ⚡ Expose Methods Control - - - - Fullscreen: - Toggle Fullscreen - - - - Expand All: - Toggle Expand All - - - - Locale: - - 中文 - English - - - - - Time Scale: - - Day - Week - Month - - - - - Theme: - - ☀️ Light - 🌙 Dark - - - - - - - - 📝 Props Control - - - - Locale Prop: - - - 中文 - - - English - - - :locale="{{ propsLocale }}" - - - - Theme Prop: - - - ☀️ Light - - - 🌙 Dark - - - :theme="{{ propsTheme }}" - - - - Time Scale Prop: - - - Day - - - Week - - - Month - - - :time-scale="{{ propsTimeScale }}" - - - - Fullscreen Prop: - - - ✓ True - - - ✗ False - - - :fullscreen="{{ propsFullscreen }}" - - - - Expand All Prop: - - - ✓ True - - - ✗ False - - - :expand-all="{{ propsExpandAll }}" - - - - + void) @task-click="onTaskClick" @milestone-double-click="onMilestoneDblclick" @task-added="onTaskAdded" + @some-event="(e) => { + console.log('resourceConflicts:', /* 通过 ref 获取 */) + console.log('conflictTasks:', /* 检查传递的数据 */) + }" > @@ -655,8 +668,8 @@ const handleCustomMenuAction = (action: string, task: Task, onClose: () => void) diff --git a/npm-webpack-demo/src/App.vue b/npm-webpack-demo/src/App.vue index 4bc7581..a1556e4 100644 --- a/npm-webpack-demo/src/App.vue +++ b/npm-webpack-demo/src/App.vue @@ -1,287 +1,3 @@ - - @@ -511,6 +227,7 @@ const onTaskAdded = (res) => { ref="ganttRef" :tasks="tasks" :milestones="milestones" + :resources="resources" :locale="controlMode === 'props' ? propsLocale : undefined" :theme="controlMode === 'props' ? propsTheme : undefined" :time-scale="controlMode === 'props' ? propsTimeScale : undefined" @@ -574,8 +291,8 @@ const onTaskAdded = (res) => { @@ -599,6 +316,537 @@ const onTaskAdded = (res) => { + + diff --git a/src/components/TaskList/TaskList.vue b/src/components/TaskList/TaskList.vue index 43c3663..e969c1b 100644 --- a/src/components/TaskList/TaskList.vue +++ b/src/components/TaskList/TaskList.vue @@ -1,13 +1,12 @@ @@ -289,6 +331,7 @@ const assigneeDisplayData = computed(() => { :data-task-id="props.task.id" :class="{ 'task-row-hovered': isHovered, + 'task-type-resource': isResourceRow, /* v1.9.0 资源视图始终显示左边框 */ 'parent-task': isParentTask, 'milestone-group-row': isMilestoneGroup, 'task-type-story': isStoryTask, @@ -296,7 +339,7 @@ const assigneeDisplayData = computed(() => { 'task-type-milestone': isMilestoneTask, [customRowClass]: customRowClass, }" - :style="[customRowStyle, customBorderStyle]" + :style="[customRowStyle, customBorderStyle, { height: `${rowHeight}px` }]" @click="handleRowClick" @dblclick="handleTaskRowDoubleClick" @mousedown="handleMouseDown" @@ -358,11 +401,40 @@ const assigneeDisplayData = computed(() => { + + + + + + + + + + + {{ (props.task as any).name || '-' }} + + + { {{ column.formatter(props.task, column) }} - + + + {{ (props.task as any)[column.key] || '-' }} + + + {{ formatPredecessorDisplay(props.task.predecessor) }} @@ -534,30 +611,26 @@ const assigneeDisplayData = computed(() => { .task-row { display: flex; border-bottom: 1px solid var(--gantt-border-light); - height: 51px; /* 修改为51px,与Timeline中的task-row高度保持一致,包含border-bottom 1px */ + height: 51px; /* v1.9.1 任务视图51px(包含border-bottom 1px),资源视图动态高度(第一行56px,后续行46px) */ box-sizing: border-box; /* 确保border包含在高度计算中 */ background: var(--gantt-bg-primary); align-items: center; color: var(--gantt-text-secondary); cursor: pointer; - transition: all 0.3s ease; - transform: scale(1); - transform-origin: 5px center; /* 从左侧偏右5px的位置作为放大中心 */ + transition: background-color 0.15s ease, box-shadow 0.15s ease; z-index: 1; position: relative; } .task-row:hover { background-color: var(--gantt-bg-hover); - transform: scale(1.02); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15); z-index: 10; } .task-row-hovered { background-color: var(--gantt-bg-hover) !important; - transform: scale(1.02) !important; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important; + box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15) !important; z-index: 10 !important; } @@ -568,15 +641,13 @@ const assigneeDisplayData = computed(() => { .task-row.parent-task:hover { background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover)); - transform: scale(1.02); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15); z-index: 10; } .task-row.parent-task.task-row-hovered { background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover)) !important; - transform: scale(1.02) !important; - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2) !important; + box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15) !important; z-index: 10 !important; } @@ -588,13 +659,9 @@ const assigneeDisplayData = computed(() => { .milestone-group-row:hover { background: linear-gradient(90deg, var(--gantt-bg-hover-parent) 0%, var(--gantt-bg-hover) 100%); - transform: scale(1.02); - box-shadow: - 0 6px 16px rgba(245, 108, 108, 0.3), - 0 2px 8px rgba(0, 0, 0, 0.1); + box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15); z-index: 10; border-left-color: var(--gantt-danger, #f56c6c); - border-left-width: 4px; /* 悬停时边框稍微加粗 */ } /* 任务类型左边框颜色 */ @@ -610,30 +677,43 @@ const assigneeDisplayData = computed(() => { border-left: 3px solid var(--gantt-danger, #f56c6c); } -/* 任务类型悬停时保持并增强左边框 */ +/* v1.9.0 资源类型左边框颜色 */ +.task-type-resource { + border-left: 3px solid var(--gantt-success, #67c23a); +} + +/* 任务类型悬停时保持左边框,无需加粗 */ .task-type-story:hover { - border-left: 5px solid var(--gantt-primary, #409eff); + border-left: 3px solid var(--gantt-primary, #409eff); } .task-type-task:hover { - border-left: 5px solid var(--gantt-warning, #e6a23c); + border-left: 3px solid var(--gantt-warning, #e6a23c); } .task-type-milestone:hover { - border-left: 5px solid var(--gantt-danger, #f56c6c); + border-left: 3px solid var(--gantt-danger, #f56c6c); +} + +.task-type-resource:hover { + border-left: 3px solid var(--gantt-success, #67c23a); } /* 悬停状态下的左边框保持 */ .task-row-hovered.task-type-story { - border-left: 5px solid var(--gantt-primary, #409eff) !important; + border-left: 3px solid var(--gantt-primary, #409eff) !important; } .task-row-hovered.task-type-task { - border-left: 5px solid var(--gantt-warning, #e6a23c) !important; + border-left: 3px solid var(--gantt-warning, #e6a23c) !important; } .task-row-hovered.task-type-milestone { - border-left: 5px solid var(--gantt-danger, #f56c6c) !important; + border-left: 3px solid var(--gantt-danger, #f56c6c) !important; +} + +.task-row-hovered.task-type-resource { + border-left: 3px solid var(--gantt-success, #67c23a) !important; } :global(.gantt-root[data-theme='dark']) .milestone-group-row { @@ -653,6 +733,10 @@ const assigneeDisplayData = computed(() => { border-left-color: var(--gantt-danger, #f67c7c); } +:global(.gantt-root[data-theme='dark']) .task-type-resource { + border-left-color: var(--gantt-success, #85ce61); +} + .collapse-btn:hover { background-color: var(--gantt-primary-light); } @@ -787,35 +871,25 @@ const assigneeDisplayData = computed(() => { /* 暗黑模式的悬停效果 */ :global(.gantt-root[data-theme='dark']) .task-row:hover { - box-shadow: - 0 4px 12px rgba(255, 255, 255, 0.1), - 0 2px 8px rgba(0, 0, 0, 0.3); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15); } :global(.gantt-root[data-theme='dark']) .task-row.task-row-hovered { background-color: var(--gantt-bg-hover) !important; - box-shadow: - 0 4px 12px rgba(255, 255, 255, 0.1), - 0 2px 8px rgba(0, 0, 0, 0.3) !important; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15) !important; } :global(.gantt-root[data-theme='dark']) .task-row.parent-task:hover { - box-shadow: - 0 6px 16px rgba(255, 255, 255, 0.15), - 0 2px 8px rgba(0, 0, 0, 0.4); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15); } :global(.gantt-root[data-theme='dark']) .task-row.parent-task.task-row-hovered { background: var(--gantt-bg-hover-parent) !important; - box-shadow: - 0 6px 16px rgba(255, 255, 255, 0.15), - 0 2px 8px rgba(0, 0, 0, 0.4) !important; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15) !important; } :global(.gantt-root[data-theme='dark']) .milestone-group-row:hover { - box-shadow: - 0 6px 16px rgba(246, 124, 124, 0.4), - 0 2px 8px rgba(255, 255, 255, 0.1); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15); } /* TaskRow拖拽样式 */ @@ -835,6 +909,52 @@ const assigneeDisplayData = computed(() => { background-color: rgba(64, 158, 255, 0.05) !important; } +/* v1.9.0 资源视图行样式 */ +.resource-row-name { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + color: var(--gantt-text-primary); +} + +/* v1.9.0 资源超载警示图标 */ +.resource-warning-icon { + width: 18px; + height: 18px; + flex-shrink: 0; + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.resource-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; +} + +.resource-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.resource-name-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + :global(.gantt-root[data-theme='dark']) .task-row-drop-target.drop-after { background-color: rgba(125, 180, 240, 0.1) !important; } diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index c305fa9..6a4a946 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -8,16 +8,16 @@ import LinkDragGuide from './LinkDragGuide.vue' import GanttConflicts from './Timeline/GanttConflicts.vue' /* eslint-disable @typescript-eslint/no-explicit-any */ import { useI18n } from '../composables/useI18n' +import { useViewMode } from '../composables/useViewMode' // v1.9.9 视图模式状态管理 import type { TaskBarConfig } from '../models/configs/TaskBarConfig' import { getPredecessorIds } from '../utils/predecessorUtils' import { perfMonitor } from '../utils/perfMonitor' import { perfMonitor2 } from '../utils/perfMonitor2' // v1.9.6 性能诊断工具 import type { Task } from '../models/classes/Task' -import type { Resource } from '../models/types/Resource' +import type { Resource } from '../models/classes/Resource' import type { Milestone } from '../models/classes/Milestone' import type { TimelineConfig } from '../models/configs/TimelineConfig' import { TimelineScale } from '../models/types/TimelineScale' -import { assignTaskRows } from '../utils/taskLayoutUtils' // v1.9.0 换行布局算法 import { positionCache } from '../utils/positionCache' // v1.9.6 Phase1 位置计算缓存 // 定义Props接口 @@ -103,9 +103,8 @@ const t = (key: string): string => { return getTranslation(key) } -// v1.9.0 从 GanttChart 注入视图模式和数据源 -const viewMode = inject>('gantt-view-mode', ref('task')) -const dataSource = inject>('gantt-data-source', computed(() => [])) +// v1.9.9 使用useViewMode统一管理视图模式状态 +const { viewMode, dataSource } = useViewMode() // v1.9.0 从 GanttChart 注入资源冲突信息(由 GanttChart 计算并响应 updateTaskTrigger) const resourceConflicts = inject>>>('resourceConflicts', computed(() => new Map())) @@ -171,143 +170,16 @@ const isSplitBarDragging = inject>('isSplitBarDragging', ref(false) // v1.9.5 注入showConflicts配置 const showConflicts = inject>('gantt-show-conflicts', computed(() => true)) -// v1.9.6 Sprint1(P0) - 布局缓存(用于resourceTaskLayouts按需计算) -const layoutCache = new Map, - rowHeights: number[], - totalHeight: number -}>() +// 纵向虚拟滚动相关状态(需要在useResourceLayout之前定义) +const ROW_HEIGHT = 51 // 每行高度51px (50px + 1px border) +const VERTICAL_BUFFER = 5 // 纵向缓冲区行数 +const timelineBodyScrollTop = ref(0) // 纵向滚动位置 +const timelineBodyHeight = ref(0) // 容器高度状态管理 -// v1.9.6 Phase2 - 计算资源视图的任务行布局(按需计算+缓存优化) -// 策略:只计算可见资源的布局,通过缓存避免重复计算 -let resourceTaskLayoutsCallCount = 0 -const resourceTaskLayouts = computed(() => { - resourceTaskLayoutsCallCount++ - - const layoutMap = new Map, - rowHeights: number[], - totalHeight: number - }>() - - if (viewMode.value !== 'resource') { - return layoutMap - } - - const resources = dataSource.value as Resource[] - - // ⚠️ 重要:必须计算所有资源的布局,不能只计算可见资源 - // 因为 visibleTaskRange 的计算依赖于 resourceRowPositions,而 resourceRowPositions 依赖于这里的布局数据 - // 如果只计算可见资源,会导致循环依赖和布局错误 - - // 性能监控 - let cacheHits = 0 - let cacheMisses = 0 - - // 为所有资源计算布局(使用缓存优化性能) - resources.forEach(resource => { - // 先检查缓存命中情况 - const cacheKey = `${resource.id}-${((resource as any).tasks || []).length}` - const isCacheHit = layoutCache.has(cacheKey) - - // 获取布局(如果缓存未命中会自动计算并缓存) - const layout = getResourceLayout(resource) - layoutMap.set(resource.id, layout) - - // 统计缓存命中率 - if (isCacheHit) { - cacheHits++ - } else { - cacheMisses++ - } - }) - - return layoutMap -}) - -// v1.9.6 Sprint1(P0) - 缓存清理策略(保留最近100个条目,防止内存泄漏) -watch(dataSource, () => { - if (layoutCache.size > 100) { - const keysToDelete = Array.from(layoutCache.keys()).slice(0, layoutCache.size - 100) - keysToDelete.forEach(key => layoutCache.delete(key)) - } -}) - -// v1.9.6 Sprint1(P0) - 获取单个资源的布局(按需计算+缓存) -const getResourceLayout = (resource: Resource) => { - const resourceTasks = (resource as any).tasks || [] - - // 🎯 修复:缓存key需要包含任务的时间信息,因为时间交汇会影响换行布局 - // 生成任务时间范围的简单哈希(拼接所有任务的id-start-end) - const timeHash = resourceTasks - .map((t: Task) => `${t.id}-${t.startDate || ''}-${t.endDate || ''}`) - .join('|') - const cacheKey = `${resource.id}-${resourceTasks.length}-${timeHash}` - - // 检查缓存 - if (layoutCache.has(cacheKey)) { - return layoutCache.get(cacheKey)! - } - - // 未命中缓存,重新计算 - let result - if (resourceTasks.length > 0) { - result = assignTaskRows(resourceTasks, 51) - } else { - result = { - taskRowMap: new Map(), - rowHeights: [51], - totalHeight: 51, - } - } - - layoutCache.set(cacheKey, result) - return result -} - -// v1.9.6 Phase2 - 计算资源行的累积位置(懒加载优化:只计算到需要的位置) -let resourceRowPositionsCallCount = 0 -const resourceRowPositions = computed(() => { - resourceRowPositionsCallCount++ - const positions = new Map() - - if (viewMode.value !== 'resource') { - return positions - } - - const resources = dataSource.value as Resource[] - const scrollTop = timelineBodyScrollTop.value - const containerHeight = timelineBodyHeight.value || 600 - const scrollBottom = scrollTop + containerHeight + ROW_HEIGHT * VERTICAL_BUFFER * 2 - - let cumulativeTop = 0 - let processedCount = 0 - - // 🎯 Phase2优化:懒加载计算,只计算到scrollBottom以下一定范围 - // 而不是一次性计算所有100个资源 - for (const resource of resources) { - positions.set(resource.id, cumulativeTop) - - // 使用辅助函数获取布局(自动缓存) - const layout = getResourceLayout(resource) - const resourceHeight = layout.totalHeight || 51 - cumulativeTop += resourceHeight - processedCount++ - - // 优化:如果已经计算到scrollBottom之下足够远,停止计算 - // 保留一定余量,避免滚动时需要重新计算 - if (cumulativeTop > scrollBottom + ROW_HEIGHT * 20) { - // 仍需继续计算剩余资源的近似位置(假设都是51px高度) - for (let i = processedCount; i < resources.length; i++) { - positions.set(resources[i].id, cumulativeTop) // 设置当前资源的位置 - cumulativeTop += 51 // 累加位置,为下一个资源准备 - } - break - } - } - - return positions -}) +// v1.9.9 优化:注入GanttChart提供的资源布局,避免重复计算 +// GanttChart已经计算并provide了resourceTaskLayouts和resourceRowPositions +const resourceTaskLayouts = inject>>('resourceTaskLayouts', computed(() => new Map())) +const resourceRowPositions = inject>>('resourceRowPositions', computed(() => new Map())) // 获取以今天为中心的时间线范围(缓存结果,避免每次计算创建新对象) const cachedTodayCenteredRange = (() => { @@ -356,6 +228,7 @@ watch([timelineStartDate, timelineEndDate], ([newStart, newEnd]) => { } } }) + // 优化:使用常量避免每次创建新空数组 const EMPTY_TASKS_ARRAY: Task[] = [] @@ -1482,12 +1355,6 @@ let hideBubblesTimeout: number | null = null // 半圆显示恢复定时器 const HOUR_WIDTH = 40 // 每小时40px const VIRTUAL_BUFFER = 10 // 减少缓冲区以提升滑动性能 -// 纵向虚拟滚动相关状态 -const ROW_HEIGHT = 51 // 每行高度51px (50px + 1px border) -const VERTICAL_BUFFER = 5 // 纵向缓冲区行数 -const timelineBodyScrollTop = ref(0) // 纵向滚动位置 -const timelineBodyHeight = ref(0) // 容器高度状态管理 - // v1.9.5 P2-3优化 - 智能缓存数据结构 interface TimelineCacheEntry { data: unknown @@ -1720,8 +1587,8 @@ const visibleTaskRange = computed(() => { for (let i = 0; i < resources.length; i++) { const resourceId = resources[i].id const rowTop = resourceRowPositions.value?.get(resourceId) || 0 - // 🎯 使用辅助函数获取布局,避免循环依赖 - const layout = getResourceLayout(resources[i]) + // v1.9.9 从 resourceTaskLayouts 直接获取布局 + const layout = resourceTaskLayouts.value.get(resourceId) const rowHeight = layout?.totalHeight || ROW_HEIGHT const rowBottom = rowTop + rowHeight @@ -1968,10 +1835,10 @@ const rebuildResourceTaskQueues = () => { resourceId, rendered: isRendered, timestamp: isRendered ? - (existingCache ? - existingCache.timestamp - : currentTimestamp) - : currentTimestamp, + (existingCache ? + existingCache.timestamp + : currentTimestamp) + : currentTimestamp, }) }) @@ -2412,7 +2279,7 @@ const getOtherMilestonesInfo = (currentId: number) => { // v1.9.5 P2-4优化 - 监听Split Bar拖拽结束,执行清理工作 watch(isSplitBarDragging, (dragging) => { if (!dragging) { - // 拖拽结束后,手动触发一次容器宽度更新 + // v1.9.9 拖拽结束后,手动触发一次容器尺寸更新(因为拖拽期间ResizeObserver被暂停) if (timelineContainerElement.value) { const newWidth = timelineContainerElement.value.clientWidth if (Math.abs(newWidth - timelineContainerWidth.value) > 1) { @@ -2420,6 +2287,14 @@ watch(isSplitBarDragging, (dragging) => { } } + // 同时更新body高度(虽然拖拽SplitterBar通常不会改变高度,但为了完整性) + if (timelineBodyElement.value) { + const newHeight = timelineBodyElement.value.clientHeight + if (Math.abs(newHeight - timelineBodyHeight.value) > 1) { + timelineBodyHeight.value = newHeight + } + } + // Splitter拖拽结束后,强制重新计算半圆显示状态 // 因为Timeline容器宽度可能发生了变化 hideBubbles.value = true @@ -2492,8 +2367,8 @@ const contentHeight = computed(() => { let totalHeight = 0 resources.forEach(resource => { - // 🎯 使用辅助函数获取布局,确保所有资源高度都被计算 - const layout = getResourceLayout(resource) + // v1.9.9 从 resourceTaskLayouts 直接获取布局 + const layout = resourceTaskLayouts.value.get(resource.id) totalHeight += layout?.totalHeight || 51 }) @@ -3658,42 +3533,19 @@ function handleBarMounted(payload: { const handleTaskBarDragEnd = (updatedTask: Task) => { // 如果是资源视图,需要更新dataSource中的资源数据 if (viewMode.value === 'resource' && dataSource.value) { - let targetResourceId: string | number | null = null - let targetResource: any = null - for (const resource of dataSource.value as any[]) { if (resource.tasks) { const taskIndex = resource.tasks.findIndex((t: Task) => t.id === updatedTask.id) if (taskIndex !== -1) { // 更新资源中的任务数据 resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask } - targetResourceId = resource.id - targetResource = resource break } } } - // 🎯 关键修复:清除该资源的布局缓存,触发自动换行重新计算 - if (targetResourceId !== null && targetResource) { - // 清除所有该资源的缓存(通配符删除,因为时间可能变化了) - const keysToDelete = Array.from(layoutCache.keys()).filter(key => key.startsWith(`${targetResourceId}-`)) - keysToDelete.forEach(key => layoutCache.delete(key)) - - // 获取旧布局信息 - const oldLayout = resourceTaskLayouts.value.get(targetResourceId) - const oldRowCount = oldLayout?.rowHeights.length || 1 - - // 重新计算布局 - const newLayout = getResourceLayout(targetResource) - const newRowCount = newLayout.rowHeights.length - - // 只有当行数发生变化时,才需要触发全量重绘 - if (newRowCount !== oldRowCount) { - // 行数变化,需要触发重绘 - taskBarRenderKey.value++ - } - } + // 🎯 关键修复:数据已经更新,GanttChart的watch会自动检测并刷新布局 + // v1.9.9 不需要手动调用invalidateLayout,props.resources的deep watch会自动触发 } // 记录变化的TaskBar ID(用于增量冲突更新) lastChangedTaskId.value = updatedTask.id @@ -3703,43 +3555,19 @@ const handleTaskBarDragEnd = (updatedTask: Task) => { const handleTaskBarResizeEnd = (updatedTask: Task) => { // 如果是资源视图,需要更新dataSource中的资源数据 if (viewMode.value === 'resource' && dataSource.value) { - let targetResourceId: string | number | null = null - let targetResource: any = null - for (const resource of dataSource.value as any[]) { if (resource.tasks) { const taskIndex = resource.tasks.findIndex((t: Task) => t.id === updatedTask.id) if (taskIndex !== -1) { // 更新资源中的任务数据 resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask } - targetResourceId = resource.id - targetResource = resource break } } } - // 🎯 关键修复:清除该资源的布局缓存,触发自动换行重新计算 - if (targetResourceId !== null && targetResource) { - // 清除所有该资源的缓存(通配符删除,因为时间可能变化了) - const keysToDelete = Array.from(layoutCache.keys()).filter(key => key.startsWith(`${targetResourceId}-`)) - keysToDelete.forEach(key => layoutCache.delete(key)) - - // 获取旧布局信息 - const oldLayout = resourceTaskLayouts.value.get(targetResourceId) - const oldRowCount = oldLayout?.rowHeights.length || 1 - - // 重新计算布局 - const newLayout = getResourceLayout(targetResource) - const newRowCount = newLayout.rowHeights.length - - // 🔧 修复:不再触发 taskBarRenderKey 更新,避免整个视图重建导致闪烁 - // 资源布局的更新会通过 resourceTaskLayouts 的响应式自动触发重新渲染 - // 注释掉以避免不必要的全量重绘: - // if (newRowCount !== oldRowCount) { - // taskBarRenderKey.value++ - // } - } + // 🎯 关键修复:数据已经更新,GanttChart的watch会自动检测并刷新布局 + // v1.9.9 不需要手动调用invalidateLayout,props.resources的deep watch会自动触发 } // 记录变化的TaskBar ID(用于增量冲突更新) lastChangedTaskId.value = updatedTask.id @@ -3839,6 +3667,11 @@ onMounted(() => { timelineBodyHeight.value = timelineBody.clientHeight resizeObserver = new ResizeObserver(entries => { + // v1.9.9 优化:拖拽SplitterBar期间不处理高度变化,避免影响拖拽性能 + if (isSplitBarDragging.value) { + return + } + for (const entry of entries) { timelineBodyHeight.value = entry.contentRect.height } @@ -3854,6 +3687,11 @@ onMounted(() => { // 为容器宽度变化创建独立的ResizeObserver const containerResizeObserver = new ResizeObserver(entries => { + // v1.9.9 优化:拖拽SplitterBar期间不处理宽度变化,避免影响拖拽性能 + if (isSplitBarDragging.value) { + return + } + for (const entry of entries) { const newWidth = entry.contentRect.width // 当容器宽度发生变化时,立即更新宽度并重新计算半圆显示 @@ -3992,6 +3830,7 @@ const handleTaskBarHighlighted = () => { // 🔧 修复:检查是否有 TaskBar 正在被拖拽或 resize // 如果有,不应该触发 Timeline 拖拽,避免与 TaskBar 交互冲突 if (isDraggingTaskBar.value) { + // TaskBar 正在被拖拽,取消 Timeline 拖拽启动 document.removeEventListener('mousemove', handleNextMouseMove) return } @@ -4063,8 +3902,8 @@ const handleResourceTaskBarDrop = (event: Event) => { const resource = resources[i] const resourceId = String(resource.id) const rowTop = resourceRowPositions.value.get(resourceId) || 0 - // v1.9.6 Phase2: 使用getResourceLayout确保获取到布局(自动缓存) - const layout = resourceTaskLayouts.value.get(resourceId) || getResourceLayout(resource) + // v1.9.9 从resoureTaskLayouts直接获取布局 + const layout = resourceTaskLayouts.value.get(resourceId) const rowHeight = layout?.totalHeight || 51 const rowCenter = rowTop + rowHeight / 2 const distance = Math.abs(relativeY - rowCenter) @@ -4395,8 +4234,8 @@ const handleDragBoundaryCheck = (event: CustomEvent) => { const resource = resources[i] const resourceId = String(resource.id) const rowTop = resourceRowPositions.value.get(resourceId) || 0 - // v1.9.6 Phase2: 使用getResourceLayout确保获取到布局(自动缓存) - const layout = resourceTaskLayouts.value.get(resourceId) || getResourceLayout(resource) + // v1.9.9 从resoureTaskLayouts直接获取布局 + const layout = resourceTaskLayouts.value.get(resourceId) const rowHeight = layout?.totalHeight || 51 const rowCenter = rowTop + rowHeight / 2 const distance = Math.abs(relativeY - rowCenter) @@ -4669,6 +4508,13 @@ const debouncedUpdateTimelineRange = (delay = 50) => { // 监听tasks数据变化和容器宽度变化,合并处理以减少重复计算 const updateTimelineRange = () => { + // 资源视图下不自动调整时间范围,保持Timeline背景层(header + 竖列)稳定 + // 避免滚动时重新生成timelineData导致header闪烁 + // 虚拟滚动通过visibleTimeRange过滤TaskBar即可 + if (viewMode.value === 'resource') { + return + } + let newRange: { startDate: Date; endDate: Date } | null = null if (currentTimeScale.value === TimelineScale.HOUR) { @@ -5739,6 +5585,7 @@ const handleAddSuccessor = (task: Task) => { + { :row-heights="resourceTaskLayouts.get(resource.id)?.rowHeights" :scroll-left="timelineScrollLeft" :container-width="timelineContainerWidth" + :render-limit="resourceTaskRenderLimits.get(resource.id)" /> diff --git a/src/components/Timeline/GanttConflicts.vue b/src/components/Timeline/GanttConflicts.vue index c92c1b8..9ae224f 100644 --- a/src/components/Timeline/GanttConflicts.vue +++ b/src/components/Timeline/GanttConflicts.vue @@ -31,6 +31,8 @@ interface Props { scrollLeft?: number /** 可视区域宽度 */ containerWidth?: number + /** v1.9.9 当前渲染的任务数量限制(用于渐进式渲染) */ + renderLimit?: number } const props = defineProps() @@ -90,11 +92,17 @@ const canvasStyle = computed(() => ({ watch(() => props.tasks, () => { if (isDraggingTaskBar.value) { needsRecalculation.value = true - } else { - recalculateConflicts() } }, { deep: true }) +// v1.9.9 监听renderLimit变化,TaskBar渐进式渲染时重新计算冲突 +watch(() => props.renderLimit, () => { + // 延迟一帧,确保DOM更新完成 + nextTick(() => { + recalculateConflicts() + }) +}) + // 监听拖拽状态变化 watch(isDraggingTaskBar, (dragging) => { if (!dragging && needsRecalculation.value) { @@ -155,7 +163,6 @@ watch([canvasWidth, canvasHeight], () => { // 监听timelineData和currentTimeScale变化 watch([() => props.timelineData, () => props.currentTimeScale], () => { - if (import.meta.env.DEV) {} // v1.9.4 BUG修复 - 视图切换时清空坐标缓存 coordsCache.clear() // v1.9.7 Bug修复 - 视图切换时清空纹理缓存并使用nextTick确保立即刷新 @@ -166,6 +173,7 @@ watch([() => props.timelineData, () => props.currentTimeScale], () => { }, { deep: true }) // v1.9.6 监听scrollLeft变化,滚动停止后重新计算可视区域的冲突 +// v1.9.9 恢复防抖机制:避免滚动时频繁重绘Canvas导致性能问题 let scrollTimer: ReturnType | null = null watch(() => props.scrollLeft, () => { // 清除之前的定时器 @@ -173,15 +181,14 @@ watch(() => props.scrollLeft, () => { clearTimeout(scrollTimer) } - // 滚动停止后100ms重新计算冲突区域(包含视口裁剪) + // 滚动时仅清空Canvas,避免闪烁 + clearCanvas() + + // 50ms防抖:滚动停止后重新计算和绘制冲突区域 scrollTimer = setTimeout(() => { - nextTick(() => { - // 先清除Canvas上的旧内容 - clearCanvas() - // 重新计算conflictZones(会根据新的scrollLeft裁剪可见区域) - recalculateConflicts() - }) - }, 100) + recalculateConflicts() + scrollTimer = null + }, 50) }) // 增量重新计算冲突(仅计算与变化TaskBar相关的冲突) @@ -298,8 +305,14 @@ function recalculateConflictsIncremental(changedTaskId: string | number) { // 重新计算冲突 function recalculateConflicts() { + // v1.9.9 优化:只计算已渲染的TaskBar的冲突 + // 如果设置了renderLimit,则只取前N个任务进行冲突检测 + const tasksToCheck = props.renderLimit !== undefined && props.renderLimit > 0 + ? props.tasks.slice(0, props.renderLimit) + : props.tasks + // 调用冲突检测算法 - const conflicts = detectConflicts(props.tasks, props.resourceId) + const conflicts = detectConflicts(tasksToCheck, props.resourceId) // v1.9.4 P1优化 - 使用坐标缓存 conflictZones.value = conflicts.map((zone) => { diff --git a/src/components/Timeline/TaskBarTab.vue b/src/components/Timeline/TaskBarTab.vue index e13077e..adf741c 100644 --- a/src/components/Timeline/TaskBarTab.vue +++ b/src/components/Timeline/TaskBarTab.vue @@ -205,6 +205,8 @@ const conflictInfoList = computed(() => { const conflictEnd = new Date(conflictTask.endDate).getTime() // 计算冲突任务的资源占比 + // v1.9.10 注释:如果冲突任务没有 resources 字段,默认使用 100% + // 这是因为在资源视图中,任务隶属于该资源但未明确指定占比时,视为全职投入 let conflictPercent = 100 if (conflictTask.resources && Array.isArray(conflictTask.resources)) { const allocation = conflictTask.resources.find( @@ -214,6 +216,7 @@ const conflictInfoList = computed(() => { conflictPercent = Math.max(20, Math.min(100, allocation.capacity)) } } + // else: 保持默认 100%(资源视图中任务隶属于当前资源) // 计算当前任务与该冲突任务的重叠时间段 const overlapStart = Math.max(currentStart, conflictStart) @@ -282,6 +285,8 @@ const totalOverloadPercent = computed(() => { // 检查任务是否在该重叠区间内(使用 +1 天后的 endDate) if (tStart < overlapEndPlus && tEndPlus > overlapStart) { + // v1.9.10 注释:如果任务没有 resources 字段,默认使用 100% + // 这确保了资源视图中未明确指定占比的任务被正确计入冲突检测 let taskPercent = 100 if (task.resources && Array.isArray(task.resources)) { const allocation = task.resources.find( @@ -291,6 +296,7 @@ const totalOverloadPercent = computed(() => { taskPercent = allocation.capacity } } + // else: 保持默认 100%(资源视图中任务隶属于当前资源) intervalTotal += taskPercent } }) diff --git a/src/composables/useResourceLayout.ts b/src/composables/useResourceLayout.ts new file mode 100644 index 0000000..c705947 --- /dev/null +++ b/src/composables/useResourceLayout.ts @@ -0,0 +1,211 @@ +/** + * useResourceLayout - 资源视图布局计算 + * v1.9.9 从Timeline.vue抽取,符合单一职责原则 + * + * 职责: + * - 计算资源行内任务的子行布局(taskRowMap, rowHeights, totalHeight) + * - 提供布局缓存机制,避免重复计算 + * - 管理资源行的累积位置计算 + */ + +import { computed, ref, watch, type Ref } from 'vue' +import { assignTaskRows } from '../utils/taskLayoutUtils' +import type { Resource } from '../models/classes/Resource' +import type { Task } from '../models/classes/Task' + +// 布局结果接口 +export interface ResourceLayout { + taskRowMap: Map + rowHeights: number[] + totalHeight: number +} + +// 布局缓存(全局单例,避免重复创建) +const layoutCache = new Map() + +// 默认行高常量 +const DEFAULT_ROW_HEIGHT = 51 +const VERTICAL_BUFFER = 2 + +/** + * 核心composable:管理资源视图布局 + */ +export function useResourceLayout( + viewMode: Ref<'task' | 'resource'>, + dataSource: Ref, + timelineBodyScrollTop: Ref, + timelineBodyHeight: Ref, +) { + // 性能监控计数器 + const layoutsCallCount = ref(0) + const positionsCallCount = ref(0) + + // v1.9.9 layoutTrigger用于手动触发布局重新计算(当资源tasks变化时) + const layoutTrigger = ref(0) + + /** + * 计算所有资源的任务布局 + * 注意:必须计算所有资源,不能只计算可见资源,因为resourceRowPositions依赖完整数据 + */ + const resourceTaskLayouts = computed(() => { + layoutsCallCount.value++ + // 读取trigger以建立依赖 + void layoutTrigger.value + + const layoutMap = new Map() + + if (viewMode.value !== 'resource') { + return layoutMap + } + + const resources = dataSource.value as Resource[] + + // 为所有资源计算布局(使用缓存优化) + resources.forEach(resource => { + // v1.9.9 通过访问tasks.length建立对tasks数组变化的依赖 + // 这样当tasks数组增删元素时,computed会自动重新计算 + const tasks = (resource as Resource & { tasks?: Task[] }).tasks || [] + void tasks.length // 建立依赖但不使用值 + + // 获取布局(内部会使用缓存) + const layout = getResourceLayout(resource) + layoutMap.set(resource.id, layout) + }) + + return layoutMap + }) + + /** + * 计算资源行的累积位置(懒加载优化:只计算到需要的位置) + */ + const resourceRowPositions = computed(() => { + positionsCallCount.value++ + const positions = new Map() + + if (viewMode.value !== 'resource') { + return positions + } + + const resources = dataSource.value as Resource[] + const scrollTop = timelineBodyScrollTop.value + const containerHeight = timelineBodyHeight.value || 600 + const scrollBottom = scrollTop + containerHeight + DEFAULT_ROW_HEIGHT * VERTICAL_BUFFER * 2 + + let cumulativeTop = 0 + let processedCount = 0 + + // 懒加载计算:只计算到scrollBottom以下一定范围 + for (const resource of resources) { + positions.set(resource.id, cumulativeTop) + + const layout = getResourceLayout(resource) + const resourceHeight = layout.totalHeight || DEFAULT_ROW_HEIGHT + cumulativeTop += resourceHeight + processedCount++ + + // 优化:已经计算到scrollBottom之下足够远,停止详细计算 + if (cumulativeTop > scrollBottom + DEFAULT_ROW_HEIGHT * 20) { + // 剩余资源使用近似位置(假设都是51px高度) + for (let i = processedCount; i < resources.length; i++) { + positions.set(resources[i].id, cumulativeTop) + cumulativeTop += DEFAULT_ROW_HEIGHT + } + break + } + } + + return positions + }) + + /** + * 获取单个资源的布局(按需计算+缓存) + */ + function getResourceLayout(resource: Resource): ResourceLayout { + const resourceTasks = (resource as Resource & { tasks?: Task[] }).tasks || [] + + // 缓存key包含任务时间信息,因为时间交汇会影响换行布局 + const timeHash = resourceTasks + .map((t: Task) => `${t.id}-${t.startDate || ''}-${t.endDate || ''}`) + .join('|') + const cacheKey = `${resource.id}-${resourceTasks.length}-${timeHash}` + + // 检查缓存 + if (layoutCache.has(cacheKey)) { + return layoutCache.get(cacheKey)! + } + + // 未命中缓存,重新计算 + let result: ResourceLayout + if (resourceTasks.length > 0) { + result = assignTaskRows(resourceTasks, DEFAULT_ROW_HEIGHT) + } else { + result = { + taskRowMap: new Map(), + rowHeights: [DEFAULT_ROW_HEIGHT], + totalHeight: DEFAULT_ROW_HEIGHT, + } + } + + layoutCache.set(cacheKey, result) + return result + } + + /** + * 缓存清理策略(保留最近100个条目,防止内存泄漏) + */ + watch(dataSource, () => { + if (layoutCache.size > 100) { + const keysToDelete = Array.from(layoutCache.keys()).slice(0, layoutCache.size - 100) + keysToDelete.forEach(key => layoutCache.delete(key)) + } + }) + + /** + * 清空布局缓存(用于数据源切换等场景) + */ + function clearLayoutCache() { + layoutCache.clear() + } + + /** + * 清除指定资源的布局缓存 + * @param resourceId 资源ID + */ + function clearResourceCache(resourceId: string | number) { + const keysToDelete = Array.from(layoutCache.keys()).filter(key => + key.startsWith(`${resourceId}-`), + ) + keysToDelete.forEach(key => layoutCache.delete(key)) + } + + /** + * v1.9.9 强制使布局失效并重新计算 + * 用于资源拖拽等场景,当资源的tasks数组被外部修改后调用 + * @param resourceIds 可选,指定需要刷新的资源ID数组,不传则刷新所有 + */ + function invalidateLayout(resourceIds?: (string | number)[]) { + if (resourceIds && resourceIds.length > 0) { + // 清除指定资源的缓存 + resourceIds.forEach(id => clearResourceCache(id)) + } else { + // 清除所有缓存 + layoutCache.clear() + } + // 触发computed重新计算 + layoutTrigger.value++ + } + + return { + // 计算结果 + resourceTaskLayouts, + resourceRowPositions, + // 辅助函数 + getResourceLayout, + clearLayoutCache, + clearResourceCache, + invalidateLayout, + // 性能监控 + layoutsCallCount, + positionsCallCount, + } +} diff --git a/src/composables/useViewMode.ts b/src/composables/useViewMode.ts new file mode 100644 index 0000000..d4917c9 --- /dev/null +++ b/src/composables/useViewMode.ts @@ -0,0 +1,76 @@ +/** + * useViewMode - 视图模式状态管理 + * v1.9.9 统一管理视图切换状态,减少组件间inject复杂度 + * + * 职责: + * - 封装视图模式相关的inject逻辑 + * - 提供统一的视图状态访问接口 + * - 简化组件中的状态获取代码 + */ + +import { inject, computed, ref, type Ref, type ComputedRef } from 'vue' +import type { Task } from '../models/classes/Task' +import type { Resource } from '../models/classes/Resource' +import type { TaskListConfig } from '../models/configs/TaskListConfig' +import type { ResourceListConfig } from '../models/configs/ResourceListConfig' + +/** + * 在子组件中使用:统一注入视图模式相关状态 + * + * 用法: + * ```ts + * const { viewMode, dataSource, isResourceView, isTaskView } = useViewMode() + * ``` + */ +export function useViewMode() { + // 注入视图模式 + const viewMode = inject>('gantt-view-mode', ref('task')) + + // 注入数据源 + const dataSource = inject>('gantt-data-source', computed(() => [])) + + // 注入列表配置 + const listConfig = inject>( + 'gantt-list-config', + computed(() => null), + ) + + // 派生状态:是否资源视图 + const isResourceView = computed(() => viewMode.value === 'resource') + + // 派生状态:是否任务视图 + const isTaskView = computed(() => viewMode.value === 'task') + + return { + // 原始状态 + viewMode, + dataSource, + listConfig, + // 派生状态 + isResourceView, + isTaskView, + } +} + +/** + * 在GanttChart等父组件中使用:提供视图模式状态 + * + * 用法: + * ```ts + * const { provideViewMode } = useViewMode() + * provideViewMode(currentViewMode, currentDataSource, currentListConfig) + * ``` + */ +export function provideViewMode( + viewMode: Ref<'task' | 'resource'>, + dataSource: ComputedRef, + listConfig: ComputedRef, +) { + // 注意:实际的provide调用仍在GanttChart中进行 + // 这个函数主要用于类型安全和文档目的 + return { + viewMode, + dataSource, + listConfig, + } +} diff --git a/src/index.ts b/src/index.ts index 3cd4ed9..74057c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ export { default as MilestonePoint } from './components/MilestonePoint.vue' export { default as MilestoneDialog } from './components/MilestoneDialog.vue' export { default as TaskRow } from './components/TaskList/taskRow/TaskRow.vue' export type { Task } from './models/classes/Task.ts' // 导出Task类型 -export type { Resource } from './models/types/Resource.ts' // v1.9.0 导出Resource类型 +export type { Resource } from './models/classes/Resource.ts' // v1.9.0 导出Resource类型 export { createResource } from './utils/resourceUtils.ts' // v2.0.0 导出资源创建工厂函数 export { useMessage } from './composables/useMessage.ts' // 导出useMessage组合式函数 diff --git a/src/models/types/Resource.ts b/src/models/classes/Resource.ts similarity index 100% rename from src/models/types/Resource.ts rename to src/models/classes/Resource.ts diff --git a/src/models/classes/Task.ts b/src/models/classes/Task.ts index 4017e39..6bbab78 100644 --- a/src/models/classes/Task.ts +++ b/src/models/classes/Task.ts @@ -1,4 +1,4 @@ -import type { Resource } from '../types/Resource' +import type { Resource } from './Resource' // Task 类型定义 export interface Task { diff --git a/src/models/configs/ResourceListConfig.ts b/src/models/configs/ResourceListConfig.ts index 624d3c2..3a2ca36 100644 --- a/src/models/configs/ResourceListConfig.ts +++ b/src/models/configs/ResourceListConfig.ts @@ -1,4 +1,4 @@ -import type { Resource } from '../types/Resource' +import type { Resource } from '../classes/Resource' /** * 资源列表列类型枚举 diff --git a/src/utils/conflictUtils.ts b/src/utils/conflictUtils.ts index 146bf4e..37a6173 100644 --- a/src/utils/conflictUtils.ts +++ b/src/utils/conflictUtils.ts @@ -56,7 +56,8 @@ export function detectConflicts( tasks: Task[], resourceId: string | number, ): ConflictZone[] { - // 过滤出包含指定资源的任务(没有resources字段时视为100%分配给该资源) + // v1.9.10 过滤出包含指定资源的任务 + // 注意:如果任务没有resources字段或为空,视为100%分配给该资源(资源视图中任务必然属于某个资源) const resourceTasks = tasks.filter((task) => { // 如果没有resources字段或为空,视为100%分配 if (!task.resources || task.resources.length === 0) return true @@ -125,7 +126,8 @@ function detectConflictsBruteForce( // 计算总投入比例 let totalPercent = 0 const taskDetails = overlappingTasks.map((task) => { - // 如果没有resources字段,默认100%;否则查找对应资源的percent + // v1.9.10 如果没有resources字段,默认100%;否则查找对应资源的capacity + // 这确保了资源视图中未明确指定占比的任务被正确计入冲突检测 const resource = task.resources?.find((r) => String(r.id) === String(resourceId)) const capacity = !task.resources || task.resources.length === 0 ? 100 : (resource?.capacity || 0) diff --git a/src/utils/perfMonitor.ts b/src/utils/perfMonitor.ts index 8f34d31..73b784f 100644 --- a/src/utils/perfMonitor.ts +++ b/src/utils/perfMonitor.ts @@ -81,13 +81,7 @@ export const perfMonitor = { * 测量函数执行时间 */ measure(fn: () => T): T { - const start = performance.now() const result = fn() - const end = performance.now() - const duration = end - start - - if (duration > 5) { } - return result }, diff --git a/src/utils/resourceUtils.ts b/src/utils/resourceUtils.ts index 10b7e1a..605fb6e 100644 --- a/src/utils/resourceUtils.ts +++ b/src/utils/resourceUtils.ts @@ -1,5 +1,5 @@ import type { Task } from '../models/classes/Task' -import type { Resource } from '../models/types/Resource' +import type { Resource } from '../models/classes/Resource' import { detectConflicts } from './conflictUtils' /**
:locale="{{ propsLocale }}"
:theme="{{ propsTheme }}"
:time-scale="{{ propsTimeScale }}"
:fullscreen="{{ propsFullscreen }}"
:expand-all="{{ propsExpandAll }}"