v1.9.0-rc.8 - SIT finished

This commit is contained in:
LINING-PC\lining
2026-02-07 18:44:23 +08:00
parent cf4d410e95
commit 430015e3fe
28 changed files with 1676 additions and 994 deletions

View File

@@ -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

View File

@@ -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);

View File

@@ -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;
}
</style>

View File

@@ -506,5 +506,43 @@
"🎉 Optimized: Refactored components, Perfectly supports Nuxt3, TailwindCSS and other frameworks",
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to andrijor@gmail.com for their professional feedback</span>"
]
},
{
"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",
"<span style=\"font-weight: bold; color: #f00;\">特别感谢a_sunshine@gitee的使用与反馈</span>",
"🎉 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",
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to a_sunshine@gitee for their use and feedback</span>"
]
}
]

View File

@@ -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<TaskListColumnConfig[]>([
@@ -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)
<template>
<div>
<!-- 工具设置面板 -->
<div class="tool-settings-panel">
<h3>🔧 External Control Demo</h3>
<!-- 当前状态显示 -->
<div class="status-section">
<div class="status-item">
<span class="status-label">Fullscreen:</span>
<span :class="['status-value', { active: fullscreenStatus }]">
{{ fullscreenStatus ? '✓ Yes' : '✗ No' }}
</span>
</div>
<div class="status-item">
<span class="status-label">Expand All:</span>
<span :class="['status-value', { active: expandStatus }]">
{{ expandStatus ? '✓ Yes' : '✗ No' }}
</span>
</div>
<div class="status-item">
<span class="status-label">Locale:</span>
<span class="status-value active">{{ currentLocaleStatus }}</span>
</div>
<div class="status-item">
<span class="status-label">Time Scale:</span>
<span class="status-value active">{{ currentScaleStatus }}</span>
</div>
<div class="status-item">
<span class="status-label">Theme:</span>
<span class="status-value active">{{ currentThemeStatus }}</span>
</div>
<div class="status-item">
<span class="status-label">Control Mode:</span>
<span class="status-value active" :style="{ color: controlMode === 'props' ? '#67c23a' : '#409eff' }">
{{ controlMode === 'props' ? '📝 Props' : '⚡ Expose' }}
</span>
</div>
</div>
<!-- 控制模式切换 -->
<div class="control-mode-section">
<h4>🎛 Control Mode</h4>
<div class="button-group">
<button
class="mode-button"
:class="{ active: controlMode === 'expose' }"
@click="controlMode = 'expose'"
>
Expose Methods
</button>
<button
class="mode-button"
:class="{ active: controlMode === 'props' }"
@click="controlMode = 'props'"
>
📝 Props Control
</button>
</div>
</div>
<!-- Expose 方法控制 -->
<div v-show="controlMode === 'expose'" class="control-section">
<h4> Expose Methods Control</h4>
<div class="controls-flow">
<div class="control-group">
<label>Fullscreen:</label>
<button class="control-btn" @click="handleToggleFullscreen">Toggle Fullscreen</button>
</div>
<div class="control-group">
<label>Expand All:</label>
<button class="control-btn" @click="handleToggleExpandAll">Toggle Expand All</button>
</div>
<div class="control-group">
<label>Locale:</label>
<div class="button-group">
<button class="control-btn" @click="handleSetLocale('zh-CN')">中文</button>
<button class="control-btn" @click="handleSetLocale('en-US')">English</button>
</div>
</div>
<div class="control-group">
<label>Time Scale:</label>
<div class="button-group">
<button class="control-btn" @click="handleSetTimeScale('day')">Day</button>
<button class="control-btn" @click="handleSetTimeScale('week')">Week</button>
<button class="control-btn" @click="handleSetTimeScale('month')">Month</button>
</div>
</div>
<div class="control-group">
<label>Theme:</label>
<div class="button-group">
<button class="control-btn" @click="handleSetTheme('light')"> Light</button>
<button class="control-btn" @click="handleSetTheme('dark')">🌙 Dark</button>
</div>
</div>
</div>
</div>
<!-- Props 控制 -->
<div v-show="controlMode === 'props'" class="control-section">
<h4>📝 Props Control</h4>
<div class="controls-flow">
<div class="control-group">
<label>Locale Prop:</label>
<div class="button-group">
<button
class="control-btn"
:class="{ primary: propsLocale === 'zh-CN' }"
@click="propsLocale = 'zh-CN'"
>
中文
</button>
<button
class="control-btn"
:class="{ primary: propsLocale === 'en-US' }"
@click="propsLocale = 'en-US'"
>
English
</button>
</div>
<p class="prop-info">:locale="{{ propsLocale }}"</p>
</div>
<div class="control-group">
<label>Theme Prop:</label>
<div class="button-group">
<button
class="control-btn"
:class="{ primary: propsTheme === 'light' }"
@click="propsTheme = 'light'"
>
Light
</button>
<button
class="control-btn"
:class="{ primary: propsTheme === 'dark' }"
@click="propsTheme = 'dark'"
>
🌙 Dark
</button>
</div>
<p class="prop-info">:theme="{{ propsTheme }}"</p>
</div>
<div class="control-group">
<label>Time Scale Prop:</label>
<div class="button-group">
<button
class="control-btn"
:class="{ primary: propsTimeScale === 'day' }"
@click="propsTimeScale = 'day'"
>
Day
</button>
<button
class="control-btn"
:class="{ primary: propsTimeScale === 'week' }"
@click="propsTimeScale = 'week'"
>
Week
</button>
<button
class="control-btn"
:class="{ primary: propsTimeScale === 'month' }"
@click="propsTimeScale = 'month'"
>
Month
</button>
</div>
<p class="prop-info">:time-scale="{{ propsTimeScale }}"</p>
</div>
<div class="control-group">
<label>Fullscreen Prop:</label>
<div class="button-group">
<button
class="control-btn"
:class="{ primary: propsFullscreen }"
@click="propsFullscreen = true"
>
True
</button>
<button
class="control-btn"
:class="{ primary: !propsFullscreen }"
@click="propsFullscreen = false"
>
False
</button>
</div>
<p class="prop-info">:fullscreen="{{ propsFullscreen }}"</p>
</div>
<div class="control-group">
<label>Expand All Prop:</label>
<div class="button-group">
<button
class="control-btn"
:class="{ primary: propsExpandAll }"
@click="propsExpandAll = true"
>
True
</button>
<button
class="control-btn"
:class="{ primary: !propsExpandAll }"
@click="propsExpandAll = false"
>
False
</button>
</div>
<p class="prop-info">:expand-all="{{ propsExpandAll }}"</p>
</div>
</div>
</div>
</div>
<!-- Gantt Chart -->
<div style="height: 600px; margin-top: 20px;">
<GanttChart
ref="ganttRef"
:tasks="tasks"
:resources="resources"
:milestones="milestones"
:locale="controlMode === 'props' ? propsLocale : undefined"
:theme="controlMode === 'props' ? propsTheme : undefined"
:time-scale="controlMode === 'props' ? propsTimeScale : undefined"
:fullscreen="controlMode === 'props' ? propsFullscreen : undefined"
:expand-all="controlMode === 'props' ? propsExpandAll : undefined"
@@ -559,6 +568,10 @@ const handleCustomMenuAction = (action: string, task: Task, onClose: () => void)
@task-click="onTaskClick"
@milestone-double-click="onMilestoneDblclick"
@task-added="onTaskAdded"
@some-event="(e) => {
console.log('resourceConflicts:', /* 通过 ref 获取 */)
console.log('conflictTasks:', /* 检查传递的数据 */)
}"
>
<TaskListColumn prop="name" label="任务名称" width="300">
<template #header>
@@ -655,8 +668,8 @@ const handleCustomMenuAction = (action: string, task: Task, onClose: () => void)
<!-- 自定义Dialog组件基于element plus -->
<el-dialog
v-model="showAddMilestoneDialog"
title="自定义添加里程碑组件 - Element Plus"
v-model="showAddMilestoneDialog"
width="400px"
@close="newTask = { name: '', startDate: '', endDate: '' }"
>

View File

@@ -1,287 +1,3 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { GanttChart, Task } from 'jordium-gantt-vue3'
import 'jordium-gantt-vue3/dist/assets/jordium-gantt-vue3.css'
// GanttChart ref
const ganttRef = ref(null)
// 控制模式:'expose' 使用expose方法'props' 使用Props
const controlMode = ref<'expose' | 'props'>('expose')
// 状态变量
const fullscreenStatus = ref(false)
const expandStatus = ref(false)
const currentLocaleStatus = ref<'zh-CN' | 'en-US'>('zh-CN')
const currentScaleStatus = ref<'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'>('week')
const currentThemeStatus = ref<'light' | 'dark'>('light')
// Props控制变量
const propsLocale = ref<'zh-CN' | 'en-US'>('zh-CN')
const propsTheme = ref<'light' | 'dark'>('light')
const propsTimeScale = ref<'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'>('week')
const propsFullscreen = ref(false)
const propsExpandAll = ref(false)
// 监听 Props 变化,同步 status
watch(propsLocale, (newLocale) => {
currentLocaleStatus.value = newLocale
})
watch(propsTheme, (newTheme) => {
currentThemeStatus.value = newTheme
})
watch(propsTimeScale, (newScale) => {
currentScaleStatus.value = newScale
})
watch(propsFullscreen, (newFullscreen) => {
fullscreenStatus.value = newFullscreen
})
watch(propsExpandAll, (newExpandAll) => {
expandStatus.value = newExpandAll
})
// 更新状态函数
const updateStatus = () => {
if (ganttRef.value) {
fullscreenStatus.value = ganttRef.value.isFullscreen()
expandStatus.value = ganttRef.value.isExpandAll()
currentLocaleStatus.value = ganttRef.value.currentLocale()
currentScaleStatus.value = ganttRef.value.currentScale()
currentThemeStatus.value = ganttRef.value.currentTheme()
}
}
// Expose 方法处理器
const handleToggleFullscreen = () => {
ganttRef.value?.toggleFullscreen()
updateStatus()
propsFullscreen.value = ganttRef.value?.isFullscreen() ?? false
}
const handleToggleExpandAll = () => {
ganttRef.value?.toggleExpandAll()
updateStatus()
propsExpandAll.value = ganttRef.value?.isExpandAll() ?? false
}
const handleSetLocale = (locale: 'zh-CN' | 'en-US') => {
ganttRef.value?.setLocale(locale)
currentLocaleStatus.value = locale
propsLocale.value = locale
}
const handleSetTimeScale = (scale: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year') => {
ganttRef.value?.setTimeScale(scale)
updateStatus()
propsTimeScale.value = scale
}
const handleSetTheme = (mode: 'light' | 'dark') => {
ganttRef.value?.setTheme(mode)
updateStatus()
propsTheme.value = mode
}
// 定义类型接口
interface TaskListColumnConfig {
key: string;
label: string;
visible: boolean;
width?: number;
}
interface ToolbarConfig {
showAddTask?: boolean;
showAddMilestone?: boolean;
showTodayLocate?: boolean;
showExportCsv?: boolean;
showExportPdf?: boolean;
showLanguage?: boolean;
showTheme?: boolean;
showFullscreen?: boolean;
showTimeScale?: boolean;
timeScaleDimensions?: string[];
defaultTimeScale?: string;
showExpandCollapse?: boolean;
}
const tasks = ref([
{
id: 1,
name: '项目启动',
startDate: '2025-10-30',
endDate: '2025-11-5',
progress: 100,
department: '管理部',
departmentCode: 'D001',
type: 'task',
assignee: '',
assigneeName: '',
},
])
const milestones = ref([
{
id: 101,
name: '项目立项',
startDate: '2025-10-29',
type: 'milestone',
icon: 'diamond',
},
])
const customMessages = {
'zh-CN': {
department: '部门',
departmentCode: '部门编号',
},
'en-US': {
department: 'Department',
departmentCode: 'Department Code',
},
}
// const tasks = ref([])
// const milestones = ref([])
const showAddTaskDrawer = ref(false)
const showAddMilestoneDialog = ref(false)
// 定义可动态配置的列
const availableColumns = ref<TaskListColumnConfig[]>([
{ key: 'startDate', label: '开始日期', visible: true },
{ key: 'endDate', label: '结束日期', visible: true },
{ key: 'progress', label: '进度', visible: true },
{ key: 'department', label: '部门', visible: true, width: 120 },
{ key: 'departmentCode', label: '部门编号', visible: true },
{ key: 'assigneeName', label: '负责人', visible: true },
])
// 自定义负责人列表
const assigneeOptions = ref([
{ value: 'zhangsan', label: '张三' },
{ value: 'lisi', label: '李四' },
{ value: 'wangwu', label: '王五' },
])
// TaskList宽度配置示例
const taskListConfig = {
defaultWidth: '50%', // 默认展开宽度50%
minWidth: '300px', // 最小宽度300px默认280px
maxWidth: '1200px', // 最大宽度1200px默认1160px
columns: availableColumns.value,
}
// toolbar配置示例
const toolbarConfig: ToolbarConfig = {
showAddTask: true, // 显示添加任务按钮
showAddMilestone: true, // 显示添加里程碑按钮
showTodayLocate: true, // 显示定位到今天按钮
showExportCsv: true, // 显示导出CSV按钮
showExportPdf: true, // 显示导出PDF按钮
showLanguage: true, // 显示语言切换按钮
showTheme: true, // 显示主题切换按钮
showFullscreen: true, // 显示全屏按钮
showTimeScale: true, // 显示时间刻度按钮组
timeScaleDimensions: [ // 显示所有时间刻度维度
'hour', 'day', 'week', 'month', 'quarter', 'year',
],
defaultTimeScale: 'week', // 默认选中周视图
showExpandCollapse: false, // 显示展开/折叠按钮
}
const newTask = ref({
name: '',
startDate: '',
endDate: '',
})
const addTask = () => {
tasks.value.push({
id: tasks.value.length + 1,
name: newTask.value.name,
startDate: newTask.value.startDate,
endDate: newTask.value.endDate,
progress: 0,
department: '未分配',
departmentCode: 'D000',
assignee: '',
assigneeName: '',
type: 'task',
})
newTask.value = { name: '', startDate: '', endDate: '' }
showAddTaskDrawer.value = false
}
const addMilestone = () => {
milestones.value.push({
id: milestones.value.length + 101,
name: newTask.value.name,
startDate: newTask.value.startDate,
type: 'milestone',
icon: 'diamond',
})
//console.log('milestones: ', milestones.value)
newTask.value = { name: '', startDate: '', endDate: '' }
showAddMilestoneDialog.value = false
}
const onTaskDblclick = (task: any) => {
alert(`双击任务: ${task.name}`)
}
const onTaskClick = (task: any) => {
alert(`单击任务: ${task.name}`)
}
const onMilestoneDblclick = (milestone: any) => {
alert(`双击里程碑: ${milestone.name}`)
}
// 任务行拖拽完成事件
const handleTaskRowMoved = (payload: {
draggedTask: Task
targetTask: Task
position: 'after' | 'child'
}) => {
const { draggedTask, targetTask, position } = payload
alert(`任务 [${draggedTask.name}] 被拖拽到任务 [${targetTask.name}] ${position === 'after' ? '之后' : '下方作为子任务'}`)
// 组件已自动更新任务的层级关系和位置
// position === 'after': 任务被放置在目标任务之后(同级)
// position === 'child': 任务被放置为目标任务的子任务(第一个子任务位置)
// 这里可以:
// 1. 显示确认对话框,让用户确认是否移动
// 2. 调用后端 API 保存新的任务层级关系
// 3. 更新相关的依赖关系
// 示例:调用后端 API
// await api.updateTaskHierarchy({
// taskId: draggedTask.id,
// targetTaskId: targetTask.id,
// position: position
// })
}
// 任务添加后回调
const onTaskAdded = (res) => {
const addedTask = tasks.value.find(t => t.id === res.task.id)
if (addedTask) {
// 使用addedTask.assignee去查找assigneeOptions的label进行赋值
const assigneeOption = assigneeOptions.value.find(option => option.value === addedTask.assignee)
if (assigneeOption) {
addedTask.assigneeName = assigneeOption.label
}
} else {
// 使用addedTask.assignee去查找assigneeOptions的label进行赋值
const assigneeOption = assigneeOptions.value.find(option => option.value === res.task.assignee)
if (assigneeOption) {
res.task.assigneeName = assigneeOption.label
}
tasks.value.push(res.task)
}
}
</script>
<template>
<div>
<!-- 工具设置面板 -->
@@ -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) => {
<!-- 自定义Dialog组件基于element plus -->
<el-dialog
v-model="showAddMilestoneDialog"
title="自定义添加里程碑组件 - Element Plus"
v-model="showAddMilestoneDialog"
width="400px"
@close="newTask = { name: '', startDate: '', endDate: '' }"
>
@@ -599,6 +316,537 @@ const onTaskAdded = (res) => {
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { GanttChart, Task } from 'jordium-gantt-vue3'
import 'jordium-gantt-vue3/dist/assets/jordium-gantt-vue3.css'
// GanttChart ref
const ganttRef = ref(null)
// 控制模式:'expose' 使用expose方法'props' 使用Props
const controlMode = ref<'expose' | 'props'>('expose')
// 状态变量
const fullscreenStatus = ref(false)
const expandStatus = ref(false)
const currentLocaleStatus = ref<'zh-CN' | 'en-US'>('zh-CN')
const currentScaleStatus = ref<'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'>('week')
const currentThemeStatus = ref<'light' | 'dark'>('light')
// Props控制变量
const propsLocale = ref<'zh-CN' | 'en-US'>('zh-CN')
const propsTheme = ref<'light' | 'dark'>('light')
const propsTimeScale = ref<'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'>('week')
const propsFullscreen = ref(false)
const propsExpandAll = ref(false)
// 监听 Props 变化,同步 status
watch(propsLocale, (newLocale) => {
currentLocaleStatus.value = newLocale
})
watch(propsTheme, (newTheme) => {
currentThemeStatus.value = newTheme
})
watch(propsTimeScale, (newScale) => {
currentScaleStatus.value = newScale
})
watch(propsFullscreen, (newFullscreen) => {
fullscreenStatus.value = newFullscreen
})
watch(propsExpandAll, (newExpandAll) => {
expandStatus.value = newExpandAll
})
// 更新状态函数
const updateStatus = () => {
if (ganttRef.value) {
fullscreenStatus.value = ganttRef.value.isFullscreen()
expandStatus.value = ganttRef.value.isExpandAll()
currentLocaleStatus.value = ganttRef.value.currentLocale()
currentScaleStatus.value = ganttRef.value.currentScale()
currentThemeStatus.value = ganttRef.value.currentTheme()
}
}
// Expose 方法处理器
const handleToggleFullscreen = () => {
ganttRef.value?.toggleFullscreen()
updateStatus()
propsFullscreen.value = ganttRef.value?.isFullscreen() ?? false
}
const handleToggleExpandAll = () => {
ganttRef.value?.toggleExpandAll()
updateStatus()
propsExpandAll.value = ganttRef.value?.isExpandAll() ?? false
}
const handleSetLocale = (locale: 'zh-CN' | 'en-US') => {
ganttRef.value?.setLocale(locale)
currentLocaleStatus.value = locale
propsLocale.value = locale
}
const handleSetTimeScale = (scale: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year') => {
ganttRef.value?.setTimeScale(scale)
updateStatus()
propsTimeScale.value = scale
}
const handleSetTheme = (mode: 'light' | 'dark') => {
ganttRef.value?.setTheme(mode)
updateStatus()
propsTheme.value = mode
}
// 定义类型接口
interface TaskListColumnConfig {
key: string;
label: string;
visible: boolean;
width?: number;
}
interface ToolbarConfig {
showAddTask?: boolean;
showAddMilestone?: boolean;
showTodayLocate?: boolean;
showExportCsv?: boolean;
showExportPdf?: boolean;
showLanguage?: boolean;
showTheme?: boolean;
showFullscreen?: boolean;
showTimeScale?: boolean;
timeScaleDimensions?: string[];
defaultTimeScale?: string;
showExpandCollapse?: boolean;
}
const tasks = ref([
{
id: 1,
name: '项目启动',
startDate: '2025-10-30',
endDate: '2025-11-5',
progress: 100,
department: '管理部',
departmentCode: 'D001',
type: 'task',
assignee: '',
assigneeName: ''
}
])
const milestones = ref([
{
id: 101,
name: '项目立项',
startDate: '2025-10-29',
type: 'milestone',
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 = {
'zh-CN': {
department: '部门',
departmentCode: '部门编号',
resourceView: {
desc: '资源视图',
capacity: '利用率',
overloaded: '超负荷',
duration: '时间周期',
overloadWarning: '资源超负荷警告',
conflictDuration: '冲突时段',
conflictWith: '与',
conflictSuffix: '冲突',
},
},
'en-US': {
department: 'Department',
departmentCode: 'Department Code',
resourceView: {
desc: 'Resource View',
capacity: 'capacity',
overloaded: 'overLoaded',
duration: 'duration',
overloadWarning: 'Overload Warning',
conflictDuration: 'conflict duration',
conflictWith: 'conflict with',
conflictSuffix: '',
},
}
}
// const tasks = ref([])
// const milestones = ref([])
const showAddTaskDrawer = ref(false);
const showAddMilestoneDialog = ref(false);
// 定义可动态配置的列
const availableColumns = ref<TaskListColumnConfig[]>([
{ key: 'startDate', label: '开始日期', visible: true },
{ key: 'endDate', label: '结束日期', visible: true },
{ key: 'progress', label: '进度', visible: true },
{ key: 'department', label: '部门', visible: true, width: 120 },
{ key: 'departmentCode', label: '部门编号', visible: true },
{ key: 'assigneeName', label: '负责人', visible: true },
])
// 自定义负责人列表
const assigneeOptions = ref([
{ value: 'zhangsan', label: '张三' },
{ value: 'lisi', label: '李四' },
{ value: 'wangwu', label: '王五' },
])
// TaskList宽度配置示例
const taskListConfig = {
defaultWidth: '50%', // 默认展开宽度50%
minWidth: '300px', // 最小宽度300px默认280px
maxWidth: '1200px', // 最大宽度1200px默认1160px
columns: availableColumns.value
}
// toolbar配置示例
const toolbarConfig: ToolbarConfig = {
showAddTask: true, // 显示添加任务按钮
showAddMilestone: true, // 显示添加里程碑按钮
showTodayLocate: true, // 显示定位到今天按钮
showExportCsv: true, // 显示导出CSV按钮
showExportPdf: true, // 显示导出PDF按钮
showLanguage: true, // 显示语言切换按钮
showTheme: true, // 显示主题切换按钮
showFullscreen: true, // 显示全屏按钮
showTimeScale: true, // 显示时间刻度按钮组
timeScaleDimensions: [ // 显示所有时间刻度维度
'hour', 'day', 'week', 'month', 'quarter', 'year'
],
defaultTimeScale: 'week', // 默认选中周视图
showExpandCollapse: false // 显示展开/折叠按钮
}
const newTask = ref({
name: '',
startDate: '',
endDate: ''
});
const addTask = () => {
tasks.value.push({
id: tasks.value.length + 1,
name: newTask.value.name,
startDate: newTask.value.startDate,
endDate: newTask.value.endDate,
progress: 0,
department: '未分配',
departmentCode: 'D000',
assignee: '',
assigneeName: '',
type: 'task',
});
newTask.value = { name: '', startDate: '', endDate: '' };
showAddTaskDrawer.value = false;
};
const addMilestone = () => {
milestones.value.push({
id: milestones.value.length + 101,
name: newTask.value.name,
startDate: newTask.value.startDate,
type: 'milestone',
icon: 'diamond'
});
console.log('milestones: ', milestones.value)
newTask.value = { name: '', startDate: '', endDate: '' };
showAddMilestoneDialog.value = false;
}
const onTaskDblclick = (task: any) => {
alert(`双击任务: ${task.name}`)
}
const onTaskClick = (task: any) => {
alert(`单击任务: ${task.name}`)
}
const onMilestoneDblclick = (milestone: any) => {
alert(`双击里程碑: ${milestone.name}`)
}
// 任务行拖拽完成事件
const handleTaskRowMoved = (payload: {
draggedTask: Task
targetTask: Task
position: 'after' | 'child'
}) => {
const { draggedTask, targetTask, position } = payload
alert(`任务 [${draggedTask.name}] 被拖拽到任务 [${targetTask.name}] ${position === 'after' ? '之后' : '下方作为子任务'}`)
// 组件已自动更新任务的层级关系和位置
// position === 'after': 任务被放置在目标任务之后(同级)
// position === 'child': 任务被放置为目标任务的子任务(第一个子任务位置)
// 这里可以:
// 1. 显示确认对话框,让用户确认是否移动
// 2. 调用后端 API 保存新的任务层级关系
// 3. 更新相关的依赖关系
// 示例:调用后端 API
// await api.updateTaskHierarchy({
// taskId: draggedTask.id,
// targetTaskId: targetTask.id,
// position: position
// })
}
// 任务添加后回调
const onTaskAdded = (res) => {
const addedTask = tasks.value.find(t => t.id === res.task.id);
if (addedTask) {
// 使用addedTask.assignee去查找assigneeOptions的label进行赋值
const assigneeOption = assigneeOptions.value.find(option => option.value === addedTask.assignee);
if (assigneeOption) {
addedTask.assigneeName = assigneeOption.label;
}
} else {
// 使用addedTask.assignee去查找assigneeOptions的label进行赋值
const assigneeOption = assigneeOptions.value.find(option => option.value === res.task.assignee);
if (assigneeOption) {
res.task.assigneeName = assigneeOption.label;
}
tasks.value.push(res.task);
}
};
</script>
<style scoped>
/* 工具设置面板 */
.tool-settings-panel {

31
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jordium-gantt-vue3",
"version": "1.7.0",
"version": "1.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jordium-gantt-vue3",
"version": "1.7.0",
"version": "1.9.0",
"license": "MIT",
"dependencies": {
"date-fns": "^4.1.0",
@@ -1118,8 +1118,9 @@
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/whatwg-mimetype": {
@@ -2136,9 +2137,10 @@
}
},
"node_modules/dompurify": {
"version": "3.2.6",
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -3131,9 +3133,9 @@
"dev": true
},
"node_modules/jspdf": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.0.0.tgz",
"integrity": "sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz",
"integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
@@ -3143,7 +3145,7 @@
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.2.4",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
@@ -3185,10 +3187,11 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",

View File

@@ -1,6 +1,6 @@
{
"name": "jordium-gantt-vue3",
"version": "1.8.1",
"version": "1.9.0",
"type": "module",
"main": "npm-package/dist/jordium-gantt-vue3.cjs.js",
"module": "npm-package/dist/jordium-gantt-vue3.es.js",

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, onUnmounted, onMounted, computed, watch, nextTick, useSlots, provide } from 'vue'
import type { StyleValue } from 'vue'
import TaskList from './TaskList/TaskList.vue'
@@ -14,7 +14,7 @@ import jsPDF from 'jspdf'
import html2canvas from 'html2canvas'
import type { Task } from '../models/classes/Task'
import type { Milestone } from '../models/classes/Milestone'
import type { Resource } from '../models/types/Resource'
import type { Resource } from '../models/classes/Resource'
import { useTaskListContextMenu } from './TaskList/composables/taskList/useTaskListContextMenu'
import { useTaskBarContextMenu } from './Timeline/composables/useTaskBarContextMenu'
import type { ToolbarConfig } from '../models/configs/ToolbarConfig'
@@ -31,9 +31,6 @@ import { TimelineScale, SCALE_CONFIGS } from '../models/types/TimelineScale'
import { detectConflicts } from '../utils/conflictUtils'
import { useMessage } from '../composables/useMessage'
// 根元素引用
const ganttRootRef = ref<HTMLElement>()
const props = withDefaults(defineProps<Props>(), {
tasks: () => [],
milestones: () => [],
@@ -114,6 +111,9 @@ const emit = defineEmits([
'resource-drag-end', // v1.9.0 资源视图垂直拖拽结束事件
])
// 根元素引用
const ganttRootRef = ref<HTMLElement>()
const { showMessage } = useMessage()
const slots = useSlots()
@@ -736,6 +736,17 @@ watch(
{ deep: true },
)
// v1.9.9 监听props.resources变化自动触发Timeline更新
// 这对于资源视图下的TaskBar跨资源拖拽很重要
watch(
() => props.resources,
() => {
// props变化时清空增量追踪执行全量更新
triggerFullUpdate()
},
{ deep: true },
)
// 时间刻度状态
const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
@@ -766,6 +777,8 @@ const showCloseButton = computed(() => {
return taskId !== null && taskId !== undefined
})
// v1.9.7 bugfix: 修复拖拽TaskBar后不必要地触发updateTimeScale的问题
// 只在Timeline首次挂载时初始化timeScale避免在updateTaskTrigger变化时重复调用
watch(
() => timelineRef.value,
newTimeline => {
@@ -773,6 +786,7 @@ watch(
newTimeline.updateTimeScale(currentTimeScale.value)
}
},
{ once: true }, // 只执行一次,避免不必要的重复调用
)
const dragging = ref(false)
@@ -840,6 +854,10 @@ function onMouseDown(e: MouseEvent) {
document.addEventListener('wheel', blockAllEvents, { capture: true, passive: false })
document.addEventListener('contextmenu', blockAllEvents, { capture: true })
// v1.9.9 性能优化使用requestAnimationFrame节流避免高频更新导致卡顿
let rafId: number | null = null
let pendingWidth: number | null = null
function onMouseMove(ev: MouseEvent) {
if (!dragging.value) return
@@ -853,12 +871,35 @@ function onMouseDown(e: MouseEvent) {
// 直接使用面板宽度限制检查,无需复杂的坐标计算
const finalWidth = checkWidthLimits(proposedWidth)
leftPanelWidth.value = finalWidth
// 保存待更新的宽度使用requestAnimationFrame批量更新
pendingWidth = finalWidth
if (rafId === null) {
rafId = requestAnimationFrame(() => {
if (pendingWidth !== null) {
leftPanelWidth.value = pendingWidth
pendingWidth = null
}
rafId = null
})
}
}
function onMouseUp() {
dragging.value = false
// 取消待处理的requestAnimationFrame
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
// 如果有待更新的宽度,立即应用
if (pendingWidth !== null) {
leftPanelWidth.value = pendingWidth
pendingWidth = null
}
}
// 移除全局事件拦截器
document.removeEventListener('mousedown', blockAllEvents, { capture: true })
document.removeEventListener('click', blockAllEvents, { capture: true })

View File

@@ -1423,36 +1423,36 @@ onUnmounted(() => {
}
/* 暗黑模式下的视图模式切换样式 */
:global(html[data-theme='dark']) .gantt-view-mode-control {
:global(.gantt-root[data-theme='dark']) .gantt-view-mode-control {
background: var(--gantt-bg-secondary, #4b4b4b);
border-color: var(--gantt-border-color, #808080);
}
:global(html[data-theme='dark']) .gantt-view-mode-control:hover {
:global(.gantt-root[data-theme='dark']) .gantt-view-mode-control:hover {
border-color: var(--gantt-primary, #3399ff);
}
:global(html[data-theme='dark']) .view-mode-thumb {
:global(.gantt-root[data-theme='dark']) .view-mode-thumb {
background: var(--gantt-primary, #3399ff);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.3),
0 1px 6px -1px rgba(0, 0, 0, 0.3);
}
:global(html[data-theme='dark']) .view-mode-item {
:global(.gantt-root[data-theme='dark']) .view-mode-item {
color: #ffffff !important;
}
:global(html[data-theme='dark']) .view-mode-item:hover:not(.active) {
:global(.gantt-root[data-theme='dark']) .view-mode-item:hover:not(.active) {
color: var(--gantt-primary, #3399ff);
background: rgba(51, 153, 255, 0.12);
}
:global(html[data-theme='dark']) .view-mode-item:active:not(.active) {
:global(.gantt-root[data-theme='dark']) .view-mode-item:active:not(.active) {
background: rgba(51, 153, 255, 0.2);
}
:global(html[data-theme='dark']) .view-mode-item.active {
:global(.gantt-root[data-theme='dark']) .view-mode-item.active {
color: #ffffff;
}

View File

@@ -72,7 +72,9 @@ const resourcePercent = computed(() => {
}
}
// 默认100%(向后兼容)
// v1.9.10 注释:默认100%(向后兼容)
// 在资源视图中,如果任务没有 resources 字段或未找到匹配的资源分配,
// 说明任务隶属于该资源但未明确指定占比视为全职投入100%
return 100
})
@@ -95,9 +97,9 @@ const currentResourceName = computed(() => {
})
// v1.9.0 是否显示占比文字(占比<100%时显示)
const shouldShowPercentText = computed(() => {
return viewMode.value === 'resource' && resourcePercent.value < 100
})
// const shouldShowPercentText = computed(() => {
// return viewMode.value === 'resource' && resourcePercent.value < 100
// })
interface Props {
task: Task
@@ -356,6 +358,8 @@ const resizeStartLeft = ref(0)
// v1.9.2 注入Timeline的拖拽状态用于冲突检测优化
const timelineIsDraggingTaskBar = inject<Ref<boolean>>('isDraggingTaskBar', ref(false))
// v1.9.7 注入Timeline容器的拖拽状态用于禁止TaskBar在Timeline拖拽时被拖拽
const isTimelineDragging = inject<Ref<boolean>>('isDraggingTimeline', ref(false))
// v1.9.0 拖拽预览效果(资源视图垂直拖拽)
const dragPreviewVisible = ref(false)
@@ -1095,6 +1099,12 @@ const needsOverflowEffect = computed(() => isWeekView.value && isShortTaskBar.va
// 鼠标事件处理 - 使用相对位置拖拽方案(带防误触机制)
const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-right') => {
// v1.9.7 如果Timeline正在被拖拽禁止TaskBar的拖拽和拉伸让事件传播到Timeline
if (isTimelineDragging.value) {
// 不阻止事件传播让Timeline可以继续滚动
return
}
// 如果处于高亮状态不阻止事件传播让Timeline可以滚动
if (props.isHighlighted || props.isPrimaryHighlight) {
// 不调用 e.preventDefault() 和 e.stopPropagation()
@@ -1267,7 +1277,7 @@ const dragTooltipContent = ref({ startDate: '', endDate: '' })
const handleMouseMove = (e: MouseEvent) => {
// 记录最新的鼠标Y位置用于资源视图垂直拖拽
if (viewMode.value === 'resource') {
;(window as any).lastDragMouseY = e.clientY
(window as any).lastDragMouseY = e.clientY
// v1.9.0 检测是否跨行拖拽(基于资源行的实际高度)
const timelineBody = document.querySelector('.timeline-body')
@@ -1927,6 +1937,9 @@ const handleMouseUp = () => {
dragType.value = null
tempTaskPixelLeft.value = null // v1.9.0 清除资源视图的像素位置缓存
// v1.9.7 不需要显式设置timelineIsDraggingTaskBar
// 上面的状态重置会自动触发watchwatch会同步timelineIsDraggingTaskBar的值
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
@@ -2732,6 +2745,7 @@ const handleTaskBarMouseLeave = () => {
}
// 监听拖拽/拉伸状态,如果开始拖拽/拉伸立即隐藏tooltip
// v1.9.7 使用 flush: 'sync' 确保状态变化时立即同步执行避免在资源视图拖拽时因组件更新导致watch延迟执行
watch([isDragging, isResizingLeft, isResizingRight], ([dragging, resizingL, resizingR]) => {
if (dragging || resizingL || resizingR) {
showHoverTooltip.value = false
@@ -2746,6 +2760,8 @@ watch([isDragging, isResizingLeft, isResizingRight], ([dragging, resizingL, resi
if (timelineIsDraggingTaskBar.value !== isDraggingOrResizing) {
timelineIsDraggingTaskBar.value = isDraggingOrResizing
}
}, {
flush: 'sync', // 同步执行,确保状态重置时立即触发
})
// v1.9.2 监听 Tab 悬停状态,当 Tab 悬停时立即隐藏 TaskBar 的 tooltip
@@ -3505,9 +3521,9 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
<div v-else class="task-name">
{{ task.name }}
<!-- v1.9.0 资源视图:显示占比文字 -->
<span v-if="shouldShowPercentText" class="resource-capacity-text">
<!--<span v-if="shouldShowPercentText" class="resource-capacity-text">
{{ resourcePercent }}%
</span>
</span>-->
</div>
</div>

View File

@@ -1819,24 +1819,24 @@ const taskStatus = computed(() => {
}
/* 暗黑模式 */
:global(html[data-theme='dark']) .resource-header {
:global(.gantt-root[data-theme='dark']) .resource-header {
background: var(--gantt-bg-toolbar, rgba(255, 255, 255, 0.03)) !important;
border-color: var(--gantt-border-light, rgba(255, 255, 255, 0.1)) !important;
}
:global(html[data-theme='dark']) .resource-header-label {
:global(.gantt-root[data-theme='dark']) .resource-header-label {
color: var(--gantt-text-secondary, #a8a8a8) !important;
}
:global(html[data-theme='dark']) .resource-item {
:global(.gantt-root[data-theme='dark']) .resource-item {
background: var(--gantt-bg-secondary, rgba(255, 255, 255, 0.05)) !important;
}
:global(html[data-theme='dark']) .btn-remove-resource:hover {
:global(.gantt-root[data-theme='dark']) .btn-remove-resource:hover {
background: var(--gantt-danger-dark, rgba(245, 108, 108, 0.2)) !important;
}
:global(html[data-theme='dark']) .btn-add-resource:hover {
:global(.gantt-root[data-theme='dark']) .btn-add-resource:hover {
background: var(--gantt-primary-dark, rgba(64, 158, 255, 0.2)) !important;
}
</style>

View File

@@ -1,13 +1,12 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, useSlots, computed, inject } from 'vue'
import type { StyleValue, Slots, Ref, ComputedRef } from 'vue'
import type { StyleValue, Slots } from 'vue'
import TaskRow from './taskRow/TaskRow.vue'
import { useI18n } from '../../composables/useI18n'
import { useViewMode } from '../../composables/useViewMode'
import type { Task } from '../../models/classes/Task'
import type { Resource } from '../../models/types/Resource'
import type { Resource } from '../../models/classes/Resource'
import type { TaskListConfig, TaskListColumnConfig } from '../../models/configs/TaskListConfig'
// @ts-expect-error - ResourceListColumnConfig is used in type unions
import type { ResourceListConfig, ResourceListColumnConfig } from '../../models/configs/ResourceListConfig'
import { DEFAULT_TASK_LIST_COLUMNS } from '../../models/configs/TaskListConfig'
import { DEFAULT_RESOURCE_LIST_COLUMNS } from '../../models/configs/ResourceListConfig'
import { useTaskRowDrag } from '../../composables/useTaskRowDrag'
@@ -53,13 +52,8 @@ const emit = defineEmits<{
const slots = useSlots()
const hasRowSlot = computed(() => Boolean(slots['custom-task-content']))
// v1.9.0 从 GanttChart 注入视图模式和数据源
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
const listConfig = inject<ComputedRef<TaskListConfig | ResourceListConfig | undefined>>(
'gantt-list-config',
computed(() => undefined),
)
// v1.9.9 使用useViewMode统一管理视图模式状态
const { viewMode, dataSource, listConfig } = useViewMode()
// 从 GanttChart 注入列级 slots
const columnSlots = inject<Slots>('gantt-column-slots', {})
@@ -340,8 +334,8 @@ onUnmounted(() => {
<TaskRow
v-for="{ task, level, rowIndex } in visibleTasks"
v-memo="[task.id, task.name, task.collapsed, hoveredTaskId === task.id, task.startDate, task.endDate, task.progress]"
:key="task.id"
v-memo="[task.id, task.name, task.collapsed, hoveredTaskId === task.id, task.startDate, task.endDate, task.progress]"
:task="task"
:level="level"
:row-index="rowIndex"

View File

@@ -1,6 +1,6 @@
import { ref, computed, inject, type Ref, type ComputedRef } from 'vue'
import type { Task } from '../../../../models/classes/Task'
import type { Resource } from '../../../../models/types/Resource'
import type { Resource } from '../../../../models/classes/Resource'
/**
* TaskList 布局计算逻辑

View File

@@ -1,4 +1,4 @@
import { ref, type Ref } from 'vue'
import { ref, watch, type Ref } from 'vue'
/**
* TaskList 容器尺寸管理
@@ -21,6 +21,34 @@ export function useTaskListResize(
let containerResizeObserver: ResizeObserver | null = null
let bodyResizeObserver: ResizeObserver | null = null
/**
* 手动更新容器宽度(用于 Splitter 拖拽结束后)
*/
const updateContainerWidth = () => {
if (taskListRef.value) {
const newWidth = taskListRef.value.offsetWidth
if (Math.abs(newWidth - cachedContainerWidth.value) > 1) {
cachedContainerWidth.value = newWidth
}
}
}
// v1.9.9 监听拖拽结束,手动更新尺寸
watch(isSplitterDragging, (dragging) => {
if (!dragging) {
// 拖拽结束后手动更新容器宽度
updateContainerWidth()
// 同时更新body高度
if (taskListBodyRef.value) {
const newHeight = taskListBodyRef.value.clientHeight
if (Math.abs(newHeight - taskListBodyHeight.value) > 1) {
taskListBodyHeight.value = newHeight
}
}
}
})
/**
* 初始化 ResizeObserver
*/
@@ -46,6 +74,11 @@ export function useTaskListResize(
taskListScrollTop.value = taskListBodyRef.value.scrollTop
bodyResizeObserver = new ResizeObserver(entries => {
// v1.9.9 拖拽期间跳过更新,避免影响拖拽性能
if (isSplitterDragging.value) {
return
}
for (const entry of entries) {
taskListBodyHeight.value = entry.contentRect.height
}
@@ -70,18 +103,6 @@ export function useTaskListResize(
}
}
/**
* 手动更新容器宽度(用于 Splitter 拖拽结束后)
*/
const updateContainerWidth = () => {
if (taskListRef.value) {
const newWidth = taskListRef.value.offsetWidth
if (Math.abs(newWidth - cachedContainerWidth.value) > 1) {
cachedContainerWidth.value = newWidth
}
}
}
return {
taskListRef,
taskListBodyRef,

View File

@@ -2,8 +2,11 @@
import { ref, computed, useSlots, toRef, inject, type ComputedRef } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from '../../../composables/useI18n'
import { useViewMode } from '../../../composables/useViewMode'
import { formatPredecessorDisplay } from '../../../utils/predecessorUtils'
import type { Task } from '../../../models/classes/Task'
import type { Resource } from '../../../models/classes/Resource'
import { isResourceOverloaded } from '../../../utils/resourceUtils'
import type { TaskListColumnConfig } from '../../../models/configs/TaskListConfig'
import type { DeclarativeColumnConfig } from '../composables/taskList/useTaskListColumns'
import TaskContextMenu from '../../TaskContextMenu.vue'
@@ -21,7 +24,7 @@ interface TaskRowSlotProps {
level: number
indent: string
isHovered: boolean
hoveredTaskId: number | null
hoveredTaskId: number | string | null
isParent: boolean
hasChildren: boolean
collapsed: boolean
@@ -42,8 +45,8 @@ interface Props {
level: number
rowIndex?: number
isHovered?: boolean
hoveredTaskId?: number | null
onHover?: (taskId: number | null) => void
hoveredTaskId?: number | string | null
onHover?: (taskId: number | string | null) => void
columns: TaskListColumnConfig[]
declarativeColumns?: DeclarativeColumnConfig[]
renderMode?: 'default' | 'declarative'
@@ -82,6 +85,36 @@ const daysText = computed(() => t.value?.days ?? '')
const slots = useSlots()
const hasContentSlot = computed(() => Boolean(slots['custom-task-content']))
// v1.9.9 使用useViewMode统一管理视图模式状态
const { viewMode } = useViewMode()
// 从 GanttChart 注入资源布局信息
const resourceTaskLayouts = inject<ComputedRef<Map<string, { taskRowMap: Map<string | number, number>, rowHeights: number[], totalHeight: number }>>>('resourceTaskLayouts', computed(() => new Map()))
// v1.9.0 Detect if current row is a resource
const isResourceRow = computed(() => {
return viewMode.value === 'resource' && 'tasks' in props.task
})
// v1.9.0 检测资源是否超载(任务重叠)
const isResourceOverloadedComputed = computed(() => {
if (!isResourceRow.value) return false
// 类型断言为Resource
const resource = props.task as Resource
return isResourceOverloaded(resource)
})
// 计算行高度 - resource视图下使用动态高度
const rowHeight = computed(() => {
if (isResourceRow.value) {
const resourceId = String(props.task.id) // 转换为string
const layout = resourceTaskLayouts.value.get(resourceId)
return layout?.totalHeight || 56 // v1.9.1 默认56px51 + 5px底部padding
}
return 51 // task视图下使用固定高度
})
// 注入右键菜单配置
const enableTaskListContextMenu = inject<ComputedRef<boolean>>('enable-task-list-context-menu', computed(() => true))
const hasTaskListContextMenuSlot = inject<ComputedRef<boolean>>('task-list-context-menu-slot', computed(() => false))
@@ -227,9 +260,16 @@ const slotPayload = computed(() => ({
progressClass: progressClass.value,
}))
// 计算左侧边框颜色 - 支持barColor自定义
// 计算左侧边框颜色 - 支持barColor/color自定义
const leftBorderColor = computed(() => {
// 如果设置了barColor优先使用
// 资源视图优先使用resource.color
if (isResourceRow.value) {
const resource = props.task as any
if (resource.color) {
return resource.color
}
}
// 任务视图使用barColor
if (props.task.barColor) {
return props.task.barColor
}
@@ -238,10 +278,12 @@ const leftBorderColor = computed(() => {
})
// 计算自定义边框样式
const customBorderStyle = computed(() => {
const customBorderStyle = computed((): StyleValue => {
if (leftBorderColor.value) {
return {
borderLeftColor: leftBorderColor.value
borderLeftColor: `${leftBorderColor.value} !important` as any,
borderLeftWidth: '3px',
borderLeftStyle: 'solid' as const,
}
}
return {}
@@ -276,7 +318,7 @@ const assigneeDisplayData = computed(() => {
return {
avatars: displayAvatars,
nameText,
hasMultiple: displayAvatars.length > 1
hasMultiple: displayAvatars.length > 1,
}
})
</script>
@@ -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(() => {
<!-- 叶子节点的占位空间无折叠按钮且无图标显示时 -->
<span
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false"
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false && !isResourceRow"
class="leaf-spacer"
></span>
<!-- v1.9.0 资源视图直接显示资源名称 -->
<div v-if="isResourceRow" class="resource-row-name">
<!-- v1.9.0 资源超载警示图标 -->
<svg
v-if="isResourceOverloadedComputed"
class="resource-warning-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2L2 20h20L12 2z"
fill="var(--gantt-danger, #f56c6c)"
/>
<path
d="M12 8v6M12 16h.01"
stroke="#fff"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<div v-if="(props.task as any).avatar" class="resource-avatar">
<img :src="(props.task as any).avatar" :alt="(props.task as any).name" />
</div>
<span class="resource-name-text">{{ (props.task as any).name || '-' }}</span>
</div>
<!-- 任务视图正常渲染 -->
<TaskRowNameContent
v-else
:task="props.task"
:is-parent-task="isParentTask"
:has-children="hasChildren"
@@ -417,7 +489,12 @@ const assigneeDisplayData = computed(() => {
{{ column.formatter(props.task, column) }}
</template>
<!-- 优先级3: 内置列类型渲染 -->
<!-- v1.9.0 资源视图使用formatter或直接显示资源属性 -->
<template v-else-if="isResourceRow">
{{ (props.task as any)[column.key] || '-' }}
</template>
<!-- 优先级3: 内置列类型渲染任务视图 -->
<!-- 前置任务列 -->
<template v-else-if="column.key === 'predecessor'">
{{ 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;
}

View File

@@ -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<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
// v1.9.9 使用useViewMode统一管理视图模式状态
const { viewMode, dataSource } = useViewMode()
// v1.9.0 从 GanttChart 注入资源冲突信息(由 GanttChart 计算并响应 updateTaskTrigger
const resourceConflicts = inject<ComputedRef<Map<string, Set<number>>>>('resourceConflicts', computed(() => new Map()))
@@ -171,143 +170,16 @@ const isSplitBarDragging = inject<Ref<boolean>>('isSplitBarDragging', ref(false)
// v1.9.5 注入showConflicts配置
const showConflicts = inject<ComputedRef<boolean>>('gantt-show-conflicts', computed(() => true))
// v1.9.6 Sprint1(P0) - 布局缓存用于resourceTaskLayouts按需计算
const layoutCache = new Map<string, {
taskRowMap: Map<string | number, number>,
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<string | number, {
taskRowMap: Map<string | number, number>,
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<string | number, number>()
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<ComputedRef<Map<string | number, any>>>('resourceTaskLayouts', computed(() => new Map()))
const resourceRowPositions = inject<ComputedRef<Map<string | number, number>>>('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 不需要手动调用invalidateLayoutprops.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 不需要手动调用invalidateLayoutprops.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) => {
<!-- v1.9.5 可通过 show-conflicts prop 控制是否显示 -->
<!-- v1.9.6 修复width使用totalTimelineWidth用于坐标计算containerWidth用于Canvas宽度 -->
<!-- v1.9.7 Bug修复使用渲染的tasks而不是allTasks避免滚动后显示已消失TaskBar的冲突 -->
<!-- v1.9.9 优化传递renderLimit只计算已渲染TaskBar的冲突 -->
<GanttConflicts
v-if="showConflicts"
:tasks="(resource as any).tasks"
@@ -5760,6 +5607,7 @@ const handleAddSuccessor = (task: Task) => {
:row-heights="resourceTaskLayouts.get(resource.id)?.rowHeights"
:scroll-left="timelineScrollLeft"
:container-width="timelineContainerWidth"
:render-limit="resourceTaskRenderLimits.get(resource.id)"
/>
</template>
</div>

View File

@@ -31,6 +31,8 @@ interface Props {
scrollLeft?: number
/** 可视区域宽度 */
containerWidth?: number
/** v1.9.9 当前渲染的任务数量限制(用于渐进式渲染) */
renderLimit?: number
}
const props = defineProps<Props>()
@@ -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<typeof setTimeout> | 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) => {

View File

@@ -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
}
})

View File

@@ -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<string | number, number>
rowHeights: number[]
totalHeight: number
}
// 布局缓存(全局单例,避免重复创建)
const layoutCache = new Map<string, ResourceLayout>()
// 默认行高常量
const DEFAULT_ROW_HEIGHT = 51
const VERTICAL_BUFFER = 2
/**
* 核心composable管理资源视图布局
*/
export function useResourceLayout(
viewMode: Ref<'task' | 'resource'>,
dataSource: Ref<Task[] | Resource[]>,
timelineBodyScrollTop: Ref<number>,
timelineBodyHeight: Ref<number>,
) {
// 性能监控计数器
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<string | number, ResourceLayout>()
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<string | number, number>()
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,
}
}

View File

@@ -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<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
// 注入数据源
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
// 注入列表配置
const listConfig = inject<ComputedRef<TaskListConfig | ResourceListConfig | null>>(
'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<Task[] | Resource[]>,
listConfig: ComputedRef<TaskListConfig | ResourceListConfig | null>,
) {
// 注意实际的provide调用仍在GanttChart中进行
// 这个函数主要用于类型安全和文档目的
return {
viewMode,
dataSource,
listConfig,
}
}

View File

@@ -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组合式函数

View File

@@ -1,4 +1,4 @@
import type { Resource } from '../types/Resource'
import type { Resource } from './Resource'
// Task 类型定义
export interface Task {

View File

@@ -1,4 +1,4 @@
import type { Resource } from '../types/Resource'
import type { Resource } from '../classes/Resource'
/**
* 资源列表列类型枚举

View File

@@ -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)

View File

@@ -81,13 +81,7 @@ export const perfMonitor = {
* 测量函数执行时间
*/
measure<T>(fn: () => T): T {
const start = performance.now()
const result = fn()
const end = performance.now()
const duration = end - start
if (duration > 5) { }
return result
},

View File

@@ -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'
/**