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
+39 -1
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/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.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 ### Enhancement
- 🎉 优化:重构组件,Theme作用域从全局调整至局部.gantt-root - 🎉 优化:重构组件,Theme作用域从全局调整至局部.gantt-root
+92 -92
View File
@@ -17,7 +17,7 @@ import { useI18n } from '../src/composables/useI18n'
import { useDemoLocale } from './useDemoLocale' import { useDemoLocale } from './useDemoLocale'
import { getPredecessorIds, predecessorIdsToString } from '../src/utils/predecessorUtils' import { getPredecessorIds, predecessorIdsToString } from '../src/utils/predecessorUtils'
import type { Task } from '../src/models/Task' 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 { createResource, addTaskToResource, updateResourceUtilization } from '../src/utils/resourceUtils'
import type { TaskListConfig, TaskListColumnConfig } from '../src/models/configs/TaskListConfig' import type { TaskListConfig, TaskListColumnConfig } from '../src/models/configs/TaskListConfig'
import type { ResourceListConfig } from '../src/models/configs/ResourceListConfig' import type { ResourceListConfig } from '../src/models/configs/ResourceListConfig'
@@ -626,7 +626,7 @@ const handleTaskClick = (task: Task) => {
} }
// v1.9.7 处理任务双击事件(资源视图下显示资源编辑提示) // v1.9.7 处理任务双击事件(资源视图下显示资源编辑提示)
const handleTaskDoubleClick = (taskOrResource: Task | Resource) => { const handleTaskDoubleClick = (taskOrResource: Task | Resource) => {
// 使用类型守卫严格判断是否为Resource对象 // 使用类型守卫严格判断是否为Resource对象
if (viewMode.value === 'resource' && isResource(taskOrResource)) { if (viewMode.value === 'resource' && isResource(taskOrResource)) {
// 这是Resource对象,显示资源编辑提示 // 这是Resource对象,显示资源编辑提示
@@ -635,7 +635,7 @@ const handleTaskDoubleClick = (taskOrResource: Task | Resource) => {
resourceEditHintVisible.value = true resourceEditHintVisible.value = true
return return
} }
// 对于真正的Task对象,GanttChart会正常打开TaskDrawer // 对于真正的Task对象,GanttChart会正常打开TaskDrawer
// useDefaultDrawer保持为true,确保新建任务和TaskBar双击都能正常工作 // useDefaultDrawer保持为true,确保新建任务和TaskBar双击都能正常工作
} }
@@ -643,9 +643,9 @@ const handleTaskDoubleClick = (taskOrResource: Task | Resource) => {
// v1.9.7 类型守卫:判断是否为Resource对象 // v1.9.7 类型守卫:判断是否为Resource对象
// Resource独有的特征:有tasks数组属性,且没有resources属性 // Resource独有的特征:有tasks数组属性,且没有resources属性
const isResource = (obj: Task | Resource): obj is Resource => { const isResource = (obj: Task | Resource): obj is Resource => {
return obj && return obj &&
typeof obj === 'object' && typeof obj === 'object' &&
'tasks' in obj && 'tasks' in obj &&
Array.isArray((obj as Resource).tasks) && Array.isArray((obj as Resource).tasks) &&
!('resources' in obj) // Task有resources属性,Resource没有 !('resources' in obj) // Task有resources属性,Resource没有
} }
@@ -1169,7 +1169,7 @@ const confirmResourceDrag = () => {
task.resources.push({ task.resources.push({
id: targetResource.id, id: targetResource.id,
name: targetResource.name, 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; background: #1e1e1e !important;
} }
:global(html[data-theme='dark']) body { :global(.gantt-root[data-theme='dark']) body {
background: #1e1e1e !important; background: #1e1e1e !important;
color: #e5e5e5 !important; color: #e5e5e5 !important;
} }
/* 暗色主题下的页面标题 */ /* 暗色主题下的页面标题 */
:global(html[data-theme='dark']) .page-title { :global(.gantt-root[data-theme='dark']) .page-title {
background: #1e1e1e; background: #1e1e1e;
color: #e5e5e5; color: #e5e5e5;
} }
/* 暗色主题下的配置面板样式 */ /* 暗色主题下的配置面板样式 */
:global(html[data-theme='dark']) .config-panel { :global(.gantt-root[data-theme='dark']) .config-panel {
background: var(--gantt-bg-primary, #2d3748); background: var(--gantt-bg-primary, #2d3748);
border-color: var(--gantt-border-color, #4a5568); 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); background: var(--gantt-bg-primary, #2d3748);
border-color: var(--gantt-border-color, #4a5568); border-color: var(--gantt-border-color, #4a5568);
box-shadow: none; 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); 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); background: var(--gantt-bg-secondary, #1a202c);
border-color: var(--gantt-border-color, #4a5568); 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); background: rgba(64, 158, 255, 0.18);
border-color: var(--gantt-primary-color, #66b3ff); border-color: var(--gantt-primary-color, #66b3ff);
} }
:global(html[data-theme='dark']) .ds-label, :global(.gantt-root[data-theme='dark']) .ds-label,
:global(html[data-theme='dark']) .ds-desc, :global(.gantt-root[data-theme='dark']) .ds-desc,
:global(html[data-theme='dark']) .ds-file { :global(.gantt-root[data-theme='dark']) .ds-file {
color: var(--gantt-text-primary, #e2e8f0); 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); 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); 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); color: var(--gantt-text-primary, #e2e8f0);
border-bottom-color: var(--gantt-primary-color, #66b3ff); 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); 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); 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); 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); 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); 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); 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); 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); 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); color: var(--gantt-primary-color, #66b3ff);
background: rgba(102, 179, 255, 0.15); background: rgba(102, 179, 255, 0.15);
border-left-color: var(--gantt-primary-color, #66b3ff); 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); 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); 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); 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); 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); color: var(--gantt-primary-color, #66b3ff);
} }
/* 暗色主题下的TaskBar配置样式 */ /* 暗色主题下的TaskBar配置样式 */
:global(html[data-theme='dark']) .taskbar-control { :global(.gantt-root[data-theme='dark']) .taskbar-control {
background: var(--gantt-bg-secondary, #1a202c); 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); 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); 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); color: var(--gantt-primary-color, #66b3ff);
} }
/* 暗色主题下的 TaskBar 高级配置 */ /* 暗色主题下的 TaskBar 高级配置 */
:global(html[data-theme='dark']) .control-row { :global(.gantt-root[data-theme='dark']) .control-row {
background: var(--gantt-bg-secondary, #1a202c); background: var(--gantt-bg-secondary, #1a202c);
border-color: var(--gantt-border-color, #4a5568); 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); 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); background: var(--gantt-bg-primary, #2d3748);
color: var(--gantt-text-primary, #e2e8f0); color: var(--gantt-text-primary, #e2e8f0);
border-color: var(--gantt-border-color, #4a5568); 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); 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); background: var(--gantt-bg-secondary, #1a202c);
border-color: var(--gantt-border-color, #4a5568); 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); 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); background: var(--gantt-bg-primary, #2d3748);
color: var(--gantt-text-primary, #e2e8f0); color: var(--gantt-text-primary, #e2e8f0);
border-color: var(--gantt-border-color, #4a5568); 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); 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); 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); 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); background-color: var(--gantt-hover-bg, #2d3748);
color: var(--gantt-primary-color, #66b3ff); 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%); background: linear-gradient(135deg, #1a73e8 0%, #00bcd4 50%, #3f51b5 100%);
box-shadow: box-shadow:
0 0 25px rgba(102, 177, 255, 0.4), 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); 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%); background: linear-gradient(135deg, #2196f3 0%, #00e5ff 50%, #5c6bc0 100%);
box-shadow: box-shadow:
0 0 35px rgba(102, 177, 255, 0.6), 0 0 35px rgba(102, 177, 255, 0.6),
@@ -3763,53 +3763,53 @@ const handleCustomMenuAction = (action: string, task: Task) => {
/* 移除旧的基于 SVG color 的样式,现在使用 filter */ /* 移除旧的基于 SVG color 的样式,现在使用 filter */
/* 暗黑模式下覆盖所有链接样式 */ /* 暗黑模式下覆盖所有链接样式 */
:global(html[data-theme='dark']) .doc-link { :global(.gantt-root[data-theme='dark']) .doc-link {
color: #ffffff; color: #ffffff;
} }
:global(html[data-theme='dark']) .doc-link:hover { :global(.gantt-root[data-theme='dark']) .doc-link:hover {
color: #ffffff; color: #ffffff;
background-color: rgba(255, 255, 255, 0.1); 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; 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; color: #ffffff !important;
background-color: rgba(199, 29, 35, 0.1); 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%); 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) filter: brightness(0) saturate(100%) invert(70%) sepia(50%) saturate(2000%) hue-rotate(190deg)
brightness(1.2); 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) filter: brightness(0) saturate(100%) invert(45%) sepia(100%) saturate(1500%) hue-rotate(340deg)
brightness(1.1); 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) filter: brightness(0) saturate(100%) invert(50%) sepia(100%) saturate(1800%) hue-rotate(340deg)
brightness(1.2); 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); color: var(--gantt-danger, #f67c7c);
fill: var(--gantt-danger, #f67c7c); fill: var(--gantt-danger, #f67c7c);
filter: drop-shadow(0 0 6px 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; 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)) filter: drop-shadow(0 0 10px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 16px rgba(246, 124, 124, 0.4)); drop-shadow(0 0 16px rgba(246, 124, 124, 0.4));
animation: milestone-icon-glow-intense-dark 1.8s ease-in-out infinite alternate; 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); 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); 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); 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); background: var(--gantt-bg-hover, #2a2a2a);
color: var(--gantt-text-primary, #e0e0e0); 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); 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); 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; 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; 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; 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; 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; 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; 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; background: #2a2a2a;
border-color: #444; border-color: #444;
} }
:global(html[data-theme='dark']) .custom-menu-header { :global(.gantt-root[data-theme='dark']) .custom-menu-header {
background: #1e1e1e; background: #1e1e1e;
color: #e0e0e0; color: #e0e0e0;
border-bottom-color: #444; border-bottom-color: #444;
} }
:global(html[data-theme='dark']) .custom-menu-item { :global(.gantt-root[data-theme='dark']) .custom-menu-item {
color: #e0e0e0; color: #e0e0e0;
} }
:global(html[data-theme='dark']) .custom-menu-item:hover { :global(.gantt-root[data-theme='dark']) .custom-menu-item:hover {
background: #353535; background: #353535;
} }
:global(html[data-theme='dark']) .custom-menu-item.danger { :global(.gantt-root[data-theme='dark']) .custom-menu-item.danger {
color: #ff6b6b; 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; background: #3a2020;
} }
:global(html[data-theme='dark']) .custom-menu-divider { :global(.gantt-root[data-theme='dark']) .custom-menu-divider {
background: #444; 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); background: var(--gantt-bg-secondary, #1a202c);
border-color: var(--gantt-border-color, #4a5568); 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); background: var(--gantt-bg-secondary, #1a202c);
color: var(--gantt-text-primary, #e2e8f0); color: var(--gantt-text-primary, #e2e8f0);
border-color: var(--gantt-border-color, #4a5568); 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); background: rgba(64, 158, 255, 0.15);
border-color: var(--gantt-primary-color, #66b3ff); border-color: var(--gantt-primary-color, #66b3ff);
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); background: var(--gantt-bg-secondary, #1a202c);
color: var(--gantt-text-primary, #e2e8f0); color: var(--gantt-text-primary, #e2e8f0);
border-color: var(--gantt-border-color, #4a5568); 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); background: var(--gantt-hover-bg, #2d3748);
border-color: var(--gantt-primary-color, #66b3ff); border-color: var(--gantt-primary-color, #66b3ff);
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); background: var(--gantt-primary-color, #409eff);
border-color: var(--gantt-primary-color, #409eff); border-color: var(--gantt-primary-color, #409eff);
color: white; 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); background: var(--gantt-primary-hover, #66b1ff);
border-color: 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); 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); background: var(--gantt-bg-primary, #2d3748);
color: var(--gantt-text-primary, #e2e8f0); color: var(--gantt-text-primary, #e2e8f0);
border-color: var(--gantt-border-color, #4a5568); 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); 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); background: var(--gantt-bg-secondary, #1a202c);
color: var(--gantt-text-secondary, #a0aec0); color: var(--gantt-text-secondary, #a0aec0);
border-left-color: var(--gantt-primary-color, #66b3ff); border-left-color: var(--gantt-primary-color, #66b3ff);
+17 -17
View File
@@ -268,65 +268,65 @@ onMounted(async () => {
} }
/* 暗黑主题适配,完全对齐TaskDrawer风格 */ /* 暗黑主题适配,完全对齐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; background: var(--gantt-bg-primary, #6b6b6b) !important;
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.4) !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; background: var(--gantt-bg-secondary, #4b4b4b) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !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; 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; background: var(--gantt-bg-hover, rgba(255, 255, 255, 0.1)) !important;
border-radius: 4px; 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; background: var(--gantt-bg-primary, #6b6b6b) !important;
scrollbar-color: #888888 #4b4b4b !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; 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; 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; background: var(--gantt-bg-secondary, #4b4b4b) !important;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.32) !important; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.32) !important;
color: var(--gantt-text-white, #fff) !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-group:hover
.version-timeline-content.version-card { .version-timeline-content.version-card {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.44) !important; 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; 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; color: var(--gantt-primary, #3399ff) !important;
text-shadow: 0 1px 4px rgba(51, 153, 255, 0.12) !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; color: #e0e0e0 !important;
} }
:global(html[data-theme='dark']) .version-notes { :global(.gantt-root[data-theme='dark']) .version-notes {
color: #e0e0e0 !important; 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; background: var(--gantt-timeline-dot, #3399ff) !important;
box-shadow: 0 0 0 2px #222 !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; 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; 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; background: repeating-linear-gradient(to bottom, #3399ff 0 8px, transparent 8px 16px) !important;
} }
</style> </style>
+38
View File
@@ -506,5 +506,43 @@
"🎉 Optimized: Refactored components, Perfectly supports Nuxt3, TailwindCSS and other frameworks", "🎉 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>" "<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>"
]
} }
] ]
+261 -248
View File
@@ -3,7 +3,7 @@ import { ref, computed, watch } from 'vue'
import { GanttChart, TaskListColumn, useI18n, TaskListContextMenu, TaskBarContextMenu } from 'jordium-gantt-vue3' import { GanttChart, TaskListColumn, useI18n, TaskListContextMenu, TaskBarContextMenu } from 'jordium-gantt-vue3'
import 'jordium-gantt-vue3/dist/assets/jordium-gantt-vue3.css' import 'jordium-gantt-vue3/dist/assets/jordium-gantt-vue3.css'
const { t, getTranslation } = useI18n() const { t, getTranslation } = useI18n();
// GanttChart ref // GanttChart ref
const ganttRef = ref(null) const ganttRef = ref(null)
@@ -114,7 +114,7 @@ const tasks = ref([
department: '管理部', department: '管理部',
departmentCode: 'D001', departmentCode: 'D001',
type: 'task', type: 'task',
}, }
]) ])
const milestones = ref([ const milestones = ref([
@@ -123,8 +123,234 @@ const milestones = ref([
name: '项目立项', name: '项目立项',
startDate: '2025-10-29', startDate: '2025-10-29',
type: 'milestone', 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 = { const customMessages = {
@@ -139,7 +365,7 @@ const customMessages = {
gantt: { gantt: {
planStartDate: '计划开始时间', planStartDate: '计划开始时间',
//planEndDate: '计划结束时间', //planEndDate: '计划结束时间',
}, }
}, },
'en-US': { 'en-US': {
department: 'Department', department: 'Department',
@@ -152,16 +378,16 @@ const customMessages = {
gantt: { gantt: {
planStartDate: 'Plan Start Date', planStartDate: 'Plan Start Date',
planEndDate: 'Plan End Date', planEndDate: 'Plan End Date',
}, }
}, }
} }
// const tasks = ref([]) // const tasks = ref([])
// const milestones = ref([]) // const milestones = ref([])
const showAddTaskDrawer = ref(false) const showAddTaskDrawer = ref(false);
const showAddMilestoneDialog = ref(false) const showAddMilestoneDialog = ref(false);
const showTodayLocate = ref(true) const showTodayLocate = ref(true);
// 定义可动态配置的列 // 定义可动态配置的列
const availableColumns = ref<TaskListColumnConfig[]>([ const availableColumns = ref<TaskListColumnConfig[]>([
@@ -186,7 +412,7 @@ const taskListConfig = {
defaultWidth: '50%', // 默认展开宽度50% defaultWidth: '50%', // 默认展开宽度50%
minWidth: '300px', // 最小宽度300px(默认280px minWidth: '300px', // 最小宽度300px(默认280px
maxWidth: '1200px', // 最大宽度1200px(默认1160px maxWidth: '1200px', // 最大宽度1200px(默认1160px
columns: availableColumns.value, columns: availableColumns.value
} }
// toolbar配置示例 // toolbar配置示例
@@ -201,17 +427,18 @@ const toolbarConfig: ToolbarConfig = {
showFullscreen: true, // 显示全屏按钮 showFullscreen: true, // 显示全屏按钮
showTimeScale: true, // 显示时间刻度按钮组 showTimeScale: true, // 显示时间刻度按钮组
timeScaleDimensions: [ // 显示所有时间刻度维度 timeScaleDimensions: [ // 显示所有时间刻度维度
'hour', 'day', 'week', 'month', 'quarter', 'year', 'hour', 'day', 'week', 'month', 'quarter', 'year'
], ],
defaultTimeScale: 'week', // 默认选中周视图 defaultTimeScale: 'week', // 默认选中周视图
showExpandCollapse: false, // 显示展开/折叠按钮 showExpandCollapse: false // 显示展开/折叠按钮
} }
const newTask = ref({ const newTask = ref({
name: '', name: '',
startDate: '', startDate: '',
endDate: '', endDate: ''
}) });
const addTask = () => { const addTask = () => {
tasks.value.push({ tasks.value.push({
@@ -220,10 +447,10 @@ const addTask = () => {
startDate: newTask.value.startDate, startDate: newTask.value.startDate,
endDate: newTask.value.endDate, endDate: newTask.value.endDate,
progress: 0, progress: 0,
}) });
newTask.value = { name: '', startDate: '', endDate: '' } newTask.value = { name: '', startDate: '', endDate: '' };
showAddTaskDrawer.value = false showAddTaskDrawer.value = false;
} };
const addMilestone = () => { const addMilestone = () => {
milestones.value.push({ milestones.value.push({
@@ -232,11 +459,11 @@ const addMilestone = () => {
startDate: newTask.value.startDate, startDate: newTask.value.startDate,
progress: 0, progress: 0,
type: 'milestone', type: 'milestone',
icon: 'diamond', icon: 'diamond'
}) });
//console.log('milestones: ', milestones.value) console.log('milestones: ', milestones.value)
newTask.value = { name: '', startDate: '', endDate: '' } newTask.value = { name: '', startDate: '', endDate: '' };
showAddMilestoneDialog.value = false showAddMilestoneDialog.value = false;
} }
const onTaskDblclick = (task) => { const onTaskDblclick = (task) => {
@@ -293,18 +520,18 @@ const handleTaskRowMoved = async (payload: {
// 此回调仅用于补充业务逻辑,例如根据assignee填充assigneeName等 // 此回调仅用于补充业务逻辑,例如根据assignee填充assigneeName等
const onTaskAdded = (res) => { 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) { if (addedTask && addedTask.assignee) {
// 根据assignee值查找对应的label并赋值给assigneeName // 根据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) { if (assigneeOption) {
addedTask.assigneeName = assigneeOption.label addedTask.assigneeName = assigneeOption.label;
} }
} }
// 不需要手动push,组件已处理 // 不需要手动push,组件已处理
} };
// 自定义右键菜单操作处理 // 自定义右键菜单操作处理
const handleCustomMenuAction = (action: string, task: Task, onClose: () => void) => { const handleCustomMenuAction = (action: string, task: Task, onClose: () => void) => {
@@ -315,234 +542,16 @@ const handleCustomMenuAction = (action: string, task: Task, onClose: () => void)
<template> <template>
<div> <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 --> <!-- Gantt Chart -->
<div style="height: 600px; margin-top: 20px;"> <div style="height: 600px; margin-top: 20px;">
<GanttChart <GanttChart
ref="ganttRef" ref="ganttRef"
:tasks="tasks" :tasks="tasks"
:resources="resources"
:milestones="milestones" :milestones="milestones"
:locale="controlMode === 'props' ? propsLocale : undefined" :locale="controlMode === 'props' ? propsLocale : undefined"
:theme="controlMode === 'props' ? propsTheme : undefined"
:time-scale="controlMode === 'props' ? propsTimeScale : undefined" :time-scale="controlMode === 'props' ? propsTimeScale : undefined"
:fullscreen="controlMode === 'props' ? propsFullscreen : undefined" :fullscreen="controlMode === 'props' ? propsFullscreen : undefined"
:expand-all="controlMode === 'props' ? propsExpandAll : undefined" :expand-all="controlMode === 'props' ? propsExpandAll : undefined"
@@ -559,6 +568,10 @@ const handleCustomMenuAction = (action: string, task: Task, onClose: () => void)
@task-click="onTaskClick" @task-click="onTaskClick"
@milestone-double-click="onMilestoneDblclick" @milestone-double-click="onMilestoneDblclick"
@task-added="onTaskAdded" @task-added="onTaskAdded"
@some-event="(e) => {
console.log('resourceConflicts:', /* 通过 ref 获取 */)
console.log('conflictTasks:', /* 检查传递的数据 */)
}"
> >
<TaskListColumn prop="name" label="任务名称" width="300"> <TaskListColumn prop="name" label="任务名称" width="300">
<template #header> <template #header>
@@ -655,8 +668,8 @@ const handleCustomMenuAction = (action: string, task: Task, onClose: () => void)
<!-- 自定义Dialog组件基于element plus --> <!-- 自定义Dialog组件基于element plus -->
<el-dialog <el-dialog
v-model="showAddMilestoneDialog"
title="自定义添加里程碑组件 - Element Plus" title="自定义添加里程碑组件 - Element Plus"
v-model="showAddMilestoneDialog"
width="400px" width="400px"
@close="newTask = { name: '', startDate: '', endDate: '' }" @close="newTask = { name: '', startDate: '', endDate: '' }"
> >
+533 -285
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> <template>
<div> <div>
<!-- 工具设置面板 --> <!-- 工具设置面板 -->
@@ -511,6 +227,7 @@ const onTaskAdded = (res) => {
ref="ganttRef" ref="ganttRef"
:tasks="tasks" :tasks="tasks"
:milestones="milestones" :milestones="milestones"
:resources="resources"
:locale="controlMode === 'props' ? propsLocale : undefined" :locale="controlMode === 'props' ? propsLocale : undefined"
:theme="controlMode === 'props' ? propsTheme : undefined" :theme="controlMode === 'props' ? propsTheme : undefined"
:time-scale="controlMode === 'props' ? propsTimeScale : undefined" :time-scale="controlMode === 'props' ? propsTimeScale : undefined"
@@ -574,8 +291,8 @@ const onTaskAdded = (res) => {
<!-- 自定义Dialog组件基于element plus --> <!-- 自定义Dialog组件基于element plus -->
<el-dialog <el-dialog
v-model="showAddMilestoneDialog"
title="自定义添加里程碑组件 - Element Plus" title="自定义添加里程碑组件 - Element Plus"
v-model="showAddMilestoneDialog"
width="400px" width="400px"
@close="newTask = { name: '', startDate: '', endDate: '' }" @close="newTask = { name: '', startDate: '', endDate: '' }"
> >
@@ -599,6 +316,537 @@ const onTaskAdded = (res) => {
</div> </div>
</template> </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> <style scoped>
/* 工具设置面板 */ /* 工具设置面板 */
.tool-settings-panel { .tool-settings-panel {
+17 -14
View File
@@ -1,12 +1,12 @@
{ {
"name": "jordium-gantt-vue3", "name": "jordium-gantt-vue3",
"version": "1.7.0", "version": "1.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jordium-gantt-vue3", "name": "jordium-gantt-vue3",
"version": "1.7.0", "version": "1.9.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -1118,8 +1118,9 @@
}, },
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "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==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@types/whatwg-mimetype": { "node_modules/@types/whatwg-mimetype": {
@@ -2136,9 +2137,10 @@
} }
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.2.6", "version": "3.3.1",
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.6.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true, "optional": true,
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
@@ -3131,9 +3133,9 @@
"dev": true "dev": true
}, },
"node_modules/jspdf": { "node_modules/jspdf": {
"version": "4.0.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.0.0.tgz", "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz",
"integrity": "sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==", "integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
@@ -3143,7 +3145,7 @@
"optionalDependencies": { "optionalDependencies": {
"canvg": "^3.0.11", "canvg": "^3.0.11",
"core-js": "^3.6.0", "core-js": "^3.6.0",
"dompurify": "^3.2.4", "dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5" "html2canvas": "^1.0.0-rc.5"
} }
}, },
@@ -3185,10 +3187,11 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.23",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true "dev": true,
"license": "MIT"
}, },
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jordium-gantt-vue3", "name": "jordium-gantt-vue3",
"version": "1.8.1", "version": "1.9.0",
"type": "module", "type": "module",
"main": "npm-package/dist/jordium-gantt-vue3.cjs.js", "main": "npm-package/dist/jordium-gantt-vue3.cjs.js",
"module": "npm-package/dist/jordium-gantt-vue3.es.js", "module": "npm-package/dist/jordium-gantt-vue3.es.js",
+47 -6
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 { ref, onUnmounted, onMounted, computed, watch, nextTick, useSlots, provide } from 'vue'
import type { StyleValue } from 'vue' import type { StyleValue } from 'vue'
import TaskList from './TaskList/TaskList.vue' import TaskList from './TaskList/TaskList.vue'
@@ -14,7 +14,7 @@ import jsPDF from 'jspdf'
import html2canvas from 'html2canvas' import html2canvas from 'html2canvas'
import type { Task } from '../models/classes/Task' import type { Task } from '../models/classes/Task'
import type { Milestone } from '../models/classes/Milestone' 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 { useTaskListContextMenu } from './TaskList/composables/taskList/useTaskListContextMenu'
import { useTaskBarContextMenu } from './Timeline/composables/useTaskBarContextMenu' import { useTaskBarContextMenu } from './Timeline/composables/useTaskBarContextMenu'
import type { ToolbarConfig } from '../models/configs/ToolbarConfig' 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 { detectConflicts } from '../utils/conflictUtils'
import { useMessage } from '../composables/useMessage' import { useMessage } from '../composables/useMessage'
//
const ganttRootRef = ref<HTMLElement>()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
tasks: () => [], tasks: () => [],
milestones: () => [], milestones: () => [],
@@ -114,6 +111,9 @@ const emit = defineEmits([
'resource-drag-end', // v1.9.0 'resource-drag-end', // v1.9.0
]) ])
//
const ganttRootRef = ref<HTMLElement>()
const { showMessage } = useMessage() const { showMessage } = useMessage()
const slots = useSlots() const slots = useSlots()
@@ -736,6 +736,17 @@ watch(
{ deep: true }, { deep: true },
) )
// v1.9.9 props.resourcesTimeline
// TaskBar
watch(
() => props.resources,
() => {
// props
triggerFullUpdate()
},
{ deep: true },
)
// //
const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY) const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
@@ -766,6 +777,8 @@ const showCloseButton = computed(() => {
return taskId !== null && taskId !== undefined return taskId !== null && taskId !== undefined
}) })
// v1.9.7 bugfix: TaskBarupdateTimeScale
// TimelinetimeScaleupdateTaskTrigger
watch( watch(
() => timelineRef.value, () => timelineRef.value,
newTimeline => { newTimeline => {
@@ -773,6 +786,7 @@ watch(
newTimeline.updateTimeScale(currentTimeScale.value) newTimeline.updateTimeScale(currentTimeScale.value)
} }
}, },
{ once: true }, //
) )
const dragging = ref(false) const dragging = ref(false)
@@ -840,6 +854,10 @@ function onMouseDown(e: MouseEvent) {
document.addEventListener('wheel', blockAllEvents, { capture: true, passive: false }) document.addEventListener('wheel', blockAllEvents, { capture: true, passive: false })
document.addEventListener('contextmenu', blockAllEvents, { capture: true }) document.addEventListener('contextmenu', blockAllEvents, { capture: true })
// v1.9.9 使requestAnimationFrame
let rafId: number | null = null
let pendingWidth: number | null = null
function onMouseMove(ev: MouseEvent) { function onMouseMove(ev: MouseEvent) {
if (!dragging.value) return if (!dragging.value) return
@@ -853,12 +871,35 @@ function onMouseDown(e: MouseEvent) {
// 使 // 使
const finalWidth = checkWidthLimits(proposedWidth) 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() { function onMouseUp() {
dragging.value = false 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('mousedown', blockAllEvents, { capture: true })
document.removeEventListener('click', blockAllEvents, { capture: true }) document.removeEventListener('click', blockAllEvents, { capture: true })
+7 -7
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); background: var(--gantt-bg-secondary, #4b4b4b);
border-color: var(--gantt-border-color, #808080); 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); 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); background: var(--gantt-primary, #3399ff);
box-shadow: box-shadow:
0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.3),
0 1px 6px -1px 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; 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); color: var(--gantt-primary, #3399ff);
background: rgba(51, 153, 255, 0.12); 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); 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; color: #ffffff;
} }
+23 -7
View File
@@ -72,7 +72,9 @@ const resourcePercent = computed(() => {
} }
} }
// 100% // v1.9.10 100%
// resources
// 100%
return 100 return 100
}) })
@@ -95,9 +97,9 @@ const currentResourceName = computed(() => {
}) })
// v1.9.0 <100% // v1.9.0 <100%
const shouldShowPercentText = computed(() => { // const shouldShowPercentText = computed(() => {
return viewMode.value === 'resource' && resourcePercent.value < 100 // return viewMode.value === 'resource' && resourcePercent.value < 100
}) // })
interface Props { interface Props {
task: Task task: Task
@@ -356,6 +358,8 @@ const resizeStartLeft = ref(0)
// v1.9.2 Timeline // v1.9.2 Timeline
const timelineIsDraggingTaskBar = inject<Ref<boolean>>('isDraggingTaskBar', ref(false)) const timelineIsDraggingTaskBar = inject<Ref<boolean>>('isDraggingTaskBar', ref(false))
// v1.9.7 TimelineTaskBarTimeline
const isTimelineDragging = inject<Ref<boolean>>('isDraggingTimeline', ref(false))
// v1.9.0 // v1.9.0
const dragPreviewVisible = ref(false) 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') => { const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-right') => {
// v1.9.7 TimelineTaskBarTimeline
if (isTimelineDragging.value) {
// Timeline
return
}
// Timeline // Timeline
if (props.isHighlighted || props.isPrimaryHighlight) { if (props.isHighlighted || props.isPrimaryHighlight) {
// e.preventDefault() e.stopPropagation() // e.preventDefault() e.stopPropagation()
@@ -1267,7 +1277,7 @@ const dragTooltipContent = ref({ startDate: '', endDate: '' })
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
// Y // Y
if (viewMode.value === 'resource') { if (viewMode.value === 'resource') {
;(window as any).lastDragMouseY = e.clientY (window as any).lastDragMouseY = e.clientY
// v1.9.0 // v1.9.0
const timelineBody = document.querySelector('.timeline-body') const timelineBody = document.querySelector('.timeline-body')
@@ -1927,6 +1937,9 @@ const handleMouseUp = () => {
dragType.value = null dragType.value = null
tempTaskPixelLeft.value = null // v1.9.0 tempTaskPixelLeft.value = null // v1.9.0
// v1.9.7 timelineIsDraggingTaskBar
// watchwatchtimelineIsDraggingTaskBar
document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp) document.removeEventListener('mouseup', handleMouseUp)
} }
@@ -2732,6 +2745,7 @@ const handleTaskBarMouseLeave = () => {
} }
// //tooltip // //tooltip
// v1.9.7 使 flush: 'sync' watch
watch([isDragging, isResizingLeft, isResizingRight], ([dragging, resizingL, resizingR]) => { watch([isDragging, isResizingLeft, isResizingRight], ([dragging, resizingL, resizingR]) => {
if (dragging || resizingL || resizingR) { if (dragging || resizingL || resizingR) {
showHoverTooltip.value = false showHoverTooltip.value = false
@@ -2746,6 +2760,8 @@ watch([isDragging, isResizingLeft, isResizingRight], ([dragging, resizingL, resi
if (timelineIsDraggingTaskBar.value !== isDraggingOrResizing) { if (timelineIsDraggingTaskBar.value !== isDraggingOrResizing) {
timelineIsDraggingTaskBar.value = isDraggingOrResizing timelineIsDraggingTaskBar.value = isDraggingOrResizing
} }
}, {
flush: 'sync', //
}) })
// v1.9.2 Tab Tab TaskBar tooltip // v1.9.2 Tab Tab TaskBar tooltip
@@ -3505,9 +3521,9 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
<div v-else class="task-name"> <div v-else class="task-name">
{{ task.name }} {{ task.name }}
<!-- v1.9.0 资源视图显示占比文字 --> <!-- v1.9.0 资源视图显示占比文字 -->
<span v-if="shouldShowPercentText" class="resource-capacity-text"> <!--<span v-if="shouldShowPercentText" class="resource-capacity-text">
{{ resourcePercent }}% {{ resourcePercent }}%
</span> </span>-->
</div> </div>
</div> </div>
+5 -5
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; 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; 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; 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; 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; 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; background: var(--gantt-primary-dark, rgba(64, 158, 255, 0.2)) !important;
} }
</style> </style>
+6 -12
View File
@@ -1,13 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, useSlots, computed, inject } from 'vue' 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 TaskRow from './taskRow/TaskRow.vue'
import { useI18n } from '../../composables/useI18n' import { useI18n } from '../../composables/useI18n'
import { useViewMode } from '../../composables/useViewMode'
import type { Task } from '../../models/classes/Task' 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' 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_TASK_LIST_COLUMNS } from '../../models/configs/TaskListConfig'
import { DEFAULT_RESOURCE_LIST_COLUMNS } from '../../models/configs/ResourceListConfig' import { DEFAULT_RESOURCE_LIST_COLUMNS } from '../../models/configs/ResourceListConfig'
import { useTaskRowDrag } from '../../composables/useTaskRowDrag' import { useTaskRowDrag } from '../../composables/useTaskRowDrag'
@@ -53,13 +52,8 @@ const emit = defineEmits<{
const slots = useSlots() const slots = useSlots()
const hasRowSlot = computed(() => Boolean(slots['custom-task-content'])) const hasRowSlot = computed(() => Boolean(slots['custom-task-content']))
// v1.9.0 GanttChart // v1.9.9 使useViewMode
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task')) const { viewMode, dataSource, listConfig } = useViewMode()
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
const listConfig = inject<ComputedRef<TaskListConfig | ResourceListConfig | undefined>>(
'gantt-list-config',
computed(() => undefined),
)
// GanttChart slots // GanttChart slots
const columnSlots = inject<Slots>('gantt-column-slots', {}) const columnSlots = inject<Slots>('gantt-column-slots', {})
@@ -340,8 +334,8 @@ onUnmounted(() => {
<TaskRow <TaskRow
v-for="{ task, level, rowIndex } in visibleTasks" 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" :key="task.id"
v-memo="[task.id, task.name, task.collapsed, hoveredTaskId === task.id, task.startDate, task.endDate, task.progress]"
:task="task" :task="task"
:level="level" :level="level"
:row-index="rowIndex" :row-index="rowIndex"
@@ -1,6 +1,6 @@
import { ref, computed, inject, type Ref, type ComputedRef } from 'vue' import { ref, computed, inject, type Ref, type ComputedRef } from 'vue'
import type { Task } from '../../../../models/classes/Task' import type { Task } from '../../../../models/classes/Task'
import type { Resource } from '../../../../models/types/Resource' import type { Resource } from '../../../../models/classes/Resource'
/** /**
* TaskList * TaskList
@@ -1,4 +1,4 @@
import { ref, type Ref } from 'vue' import { ref, watch, type Ref } from 'vue'
/** /**
* TaskList * TaskList
@@ -21,6 +21,34 @@ export function useTaskListResize(
let containerResizeObserver: ResizeObserver | null = null let containerResizeObserver: ResizeObserver | null = null
let bodyResizeObserver: 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 * ResizeObserver
*/ */
@@ -46,6 +74,11 @@ export function useTaskListResize(
taskListScrollTop.value = taskListBodyRef.value.scrollTop taskListScrollTop.value = taskListBodyRef.value.scrollTop
bodyResizeObserver = new ResizeObserver(entries => { bodyResizeObserver = new ResizeObserver(entries => {
// v1.9.9 拖拽期间跳过更新,避免影响拖拽性能
if (isSplitterDragging.value) {
return
}
for (const entry of entries) { for (const entry of entries) {
taskListBodyHeight.value = entry.contentRect.height 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 { return {
taskListRef, taskListRef,
taskListBodyRef, taskListBodyRef,
+170 -50
View File
@@ -2,8 +2,11 @@
import { ref, computed, useSlots, toRef, inject, type ComputedRef } from 'vue' import { ref, computed, useSlots, toRef, inject, type ComputedRef } from 'vue'
import type { StyleValue } from 'vue' import type { StyleValue } from 'vue'
import { useI18n } from '../../../composables/useI18n' import { useI18n } from '../../../composables/useI18n'
import { useViewMode } from '../../../composables/useViewMode'
import { formatPredecessorDisplay } from '../../../utils/predecessorUtils' import { formatPredecessorDisplay } from '../../../utils/predecessorUtils'
import type { Task } from '../../../models/classes/Task' 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 { TaskListColumnConfig } from '../../../models/configs/TaskListConfig'
import type { DeclarativeColumnConfig } from '../composables/taskList/useTaskListColumns' import type { DeclarativeColumnConfig } from '../composables/taskList/useTaskListColumns'
import TaskContextMenu from '../../TaskContextMenu.vue' import TaskContextMenu from '../../TaskContextMenu.vue'
@@ -21,7 +24,7 @@ interface TaskRowSlotProps {
level: number level: number
indent: string indent: string
isHovered: boolean isHovered: boolean
hoveredTaskId: number | null hoveredTaskId: number | string | null
isParent: boolean isParent: boolean
hasChildren: boolean hasChildren: boolean
collapsed: boolean collapsed: boolean
@@ -42,8 +45,8 @@ interface Props {
level: number level: number
rowIndex?: number rowIndex?: number
isHovered?: boolean isHovered?: boolean
hoveredTaskId?: number | null hoveredTaskId?: number | string | null
onHover?: (taskId: number | null) => void onHover?: (taskId: number | string | null) => void
columns: TaskListColumnConfig[] columns: TaskListColumnConfig[]
declarativeColumns?: DeclarativeColumnConfig[] declarativeColumns?: DeclarativeColumnConfig[]
renderMode?: 'default' | 'declarative' renderMode?: 'default' | 'declarative'
@@ -82,6 +85,36 @@ const daysText = computed(() => t.value?.days ?? '')
const slots = useSlots() const slots = useSlots()
const hasContentSlot = computed(() => Boolean(slots['custom-task-content'])) 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 + 5pxpadding
}
return 51 // task使
})
// //
const enableTaskListContextMenu = inject<ComputedRef<boolean>>('enable-task-list-context-menu', computed(() => true)) const enableTaskListContextMenu = inject<ComputedRef<boolean>>('enable-task-list-context-menu', computed(() => true))
const hasTaskListContextMenuSlot = inject<ComputedRef<boolean>>('task-list-context-menu-slot', computed(() => false)) const hasTaskListContextMenuSlot = inject<ComputedRef<boolean>>('task-list-context-menu-slot', computed(() => false))
@@ -227,9 +260,16 @@ const slotPayload = computed(() => ({
progressClass: progressClass.value, progressClass: progressClass.value,
})) }))
// - barColor // - barColor/color
const leftBorderColor = computed(() => { 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) { if (props.task.barColor) {
return props.task.barColor return props.task.barColor
} }
@@ -238,10 +278,12 @@ const leftBorderColor = computed(() => {
}) })
// //
const customBorderStyle = computed(() => { const customBorderStyle = computed((): StyleValue => {
if (leftBorderColor.value) { if (leftBorderColor.value) {
return { return {
borderLeftColor: leftBorderColor.value borderLeftColor: `${leftBorderColor.value} !important` as any,
borderLeftWidth: '3px',
borderLeftStyle: 'solid' as const,
} }
} }
return {} return {}
@@ -276,7 +318,7 @@ const assigneeDisplayData = computed(() => {
return { return {
avatars: displayAvatars, avatars: displayAvatars,
nameText, nameText,
hasMultiple: displayAvatars.length > 1 hasMultiple: displayAvatars.length > 1,
} }
}) })
</script> </script>
@@ -289,6 +331,7 @@ const assigneeDisplayData = computed(() => {
:data-task-id="props.task.id" :data-task-id="props.task.id"
:class="{ :class="{
'task-row-hovered': isHovered, 'task-row-hovered': isHovered,
'task-type-resource': isResourceRow, /* v1.9.0 资源视图始终显示左边框 */
'parent-task': isParentTask, 'parent-task': isParentTask,
'milestone-group-row': isMilestoneGroup, 'milestone-group-row': isMilestoneGroup,
'task-type-story': isStoryTask, 'task-type-story': isStoryTask,
@@ -296,7 +339,7 @@ const assigneeDisplayData = computed(() => {
'task-type-milestone': isMilestoneTask, 'task-type-milestone': isMilestoneTask,
[customRowClass]: customRowClass, [customRowClass]: customRowClass,
}" }"
:style="[customRowStyle, customBorderStyle]" :style="[customRowStyle, customBorderStyle, { height: `${rowHeight}px` }]"
@click="handleRowClick" @click="handleRowClick"
@dblclick="handleTaskRowDoubleClick" @dblclick="handleTaskRowDoubleClick"
@mousedown="handleMouseDown" @mousedown="handleMouseDown"
@@ -358,11 +401,40 @@ const assigneeDisplayData = computed(() => {
<!-- 叶子节点的占位空间无折叠按钮且无图标显示时 --> <!-- 叶子节点的占位空间无折叠按钮且无图标显示时 -->
<span <span
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false" v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false && !isResourceRow"
class="leaf-spacer" class="leaf-spacer"
></span> ></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 <TaskRowNameContent
v-else
:task="props.task" :task="props.task"
:is-parent-task="isParentTask" :is-parent-task="isParentTask"
:has-children="hasChildren" :has-children="hasChildren"
@@ -417,7 +489,12 @@ const assigneeDisplayData = computed(() => {
{{ column.formatter(props.task, column) }} {{ column.formatter(props.task, column) }}
</template> </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'"> <template v-else-if="column.key === 'predecessor'">
{{ formatPredecessorDisplay(props.task.predecessor) }} {{ formatPredecessorDisplay(props.task.predecessor) }}
@@ -534,30 +611,26 @@ const assigneeDisplayData = computed(() => {
.task-row { .task-row {
display: flex; display: flex;
border-bottom: 1px solid var(--gantt-border-light); 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包含在高度计算中 */ box-sizing: border-box; /* 确保border包含在高度计算中 */
background: var(--gantt-bg-primary); background: var(--gantt-bg-primary);
align-items: center; align-items: center;
color: var(--gantt-text-secondary); color: var(--gantt-text-secondary);
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: background-color 0.15s ease, box-shadow 0.15s ease;
transform: scale(1);
transform-origin: 5px center; /* 从左侧偏右5px的位置作为放大中心 */
z-index: 1; z-index: 1;
position: relative; position: relative;
} }
.task-row:hover { .task-row:hover {
background-color: var(--gantt-bg-hover); background-color: var(--gantt-bg-hover);
transform: scale(1.02); box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10; z-index: 10;
} }
.task-row-hovered { .task-row-hovered {
background-color: var(--gantt-bg-hover) !important; background-color: var(--gantt-bg-hover) !important;
transform: scale(1.02) !important; box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
z-index: 10 !important; z-index: 10 !important;
} }
@@ -568,15 +641,13 @@ const assigneeDisplayData = computed(() => {
.task-row.parent-task:hover { .task-row.parent-task:hover {
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover)); background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover));
transform: scale(1.02); box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
z-index: 10; z-index: 10;
} }
.task-row.parent-task.task-row-hovered { .task-row.parent-task.task-row-hovered {
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover)) !important; background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover)) !important;
transform: scale(1.02) !important; box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15) !important;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2) !important;
z-index: 10 !important; z-index: 10 !important;
} }
@@ -588,13 +659,9 @@ const assigneeDisplayData = computed(() => {
.milestone-group-row:hover { .milestone-group-row:hover {
background: linear-gradient(90deg, var(--gantt-bg-hover-parent) 0%, var(--gantt-bg-hover) 100%); background: linear-gradient(90deg, var(--gantt-bg-hover-parent) 0%, var(--gantt-bg-hover) 100%);
transform: scale(1.02); box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15);
box-shadow:
0 6px 16px rgba(245, 108, 108, 0.3),
0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 10; z-index: 10;
border-left-color: var(--gantt-danger, #f56c6c); 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); 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 { .task-type-story:hover {
border-left: 5px solid var(--gantt-primary, #409eff); border-left: 3px solid var(--gantt-primary, #409eff);
} }
.task-type-task:hover { .task-type-task:hover {
border-left: 5px solid var(--gantt-warning, #e6a23c); border-left: 3px solid var(--gantt-warning, #e6a23c);
} }
.task-type-milestone:hover { .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 { .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 { .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 { .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 { :global(.gantt-root[data-theme='dark']) .milestone-group-row {
@@ -653,6 +733,10 @@ const assigneeDisplayData = computed(() => {
border-left-color: var(--gantt-danger, #f67c7c); 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 { .collapse-btn:hover {
background-color: var(--gantt-primary-light); background-color: var(--gantt-primary-light);
} }
@@ -787,35 +871,25 @@ const assigneeDisplayData = computed(() => {
/* 暗黑模式的悬停效果 */ /* 暗黑模式的悬停效果 */
:global(.gantt-root[data-theme='dark']) .task-row:hover { :global(.gantt-root[data-theme='dark']) .task-row:hover {
box-shadow: box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
0 4px 12px rgba(255, 255, 255, 0.1),
0 2px 8px rgba(0, 0, 0, 0.3);
} }
:global(.gantt-root[data-theme='dark']) .task-row.task-row-hovered { :global(.gantt-root[data-theme='dark']) .task-row.task-row-hovered {
background-color: var(--gantt-bg-hover) !important; background-color: var(--gantt-bg-hover) !important;
box-shadow: box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15) !important;
0 4px 12px rgba(255, 255, 255, 0.1),
0 2px 8px rgba(0, 0, 0, 0.3) !important;
} }
:global(.gantt-root[data-theme='dark']) .task-row.parent-task:hover { :global(.gantt-root[data-theme='dark']) .task-row.parent-task:hover {
box-shadow: box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
0 6px 16px rgba(255, 255, 255, 0.15),
0 2px 8px rgba(0, 0, 0, 0.4);
} }
:global(.gantt-root[data-theme='dark']) .task-row.parent-task.task-row-hovered { :global(.gantt-root[data-theme='dark']) .task-row.parent-task.task-row-hovered {
background: var(--gantt-bg-hover-parent) !important; background: var(--gantt-bg-hover-parent) !important;
box-shadow: box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15) !important;
0 6px 16px rgba(255, 255, 255, 0.15),
0 2px 8px rgba(0, 0, 0, 0.4) !important;
} }
:global(.gantt-root[data-theme='dark']) .milestone-group-row:hover { :global(.gantt-root[data-theme='dark']) .milestone-group-row:hover {
box-shadow: box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
0 6px 16px rgba(246, 124, 124, 0.4),
0 2px 8px rgba(255, 255, 255, 0.1);
} }
/* TaskRow拖拽样式 */ /* TaskRow拖拽样式 */
@@ -835,6 +909,52 @@ const assigneeDisplayData = computed(() => {
background-color: rgba(64, 158, 255, 0.05) !important; 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 { :global(.gantt-root[data-theme='dark']) .task-row-drop-target.drop-after {
background-color: rgba(125, 180, 240, 0.1) !important; background-color: rgba(125, 180, 240, 0.1) !important;
} }
+59 -211
View File
@@ -8,16 +8,16 @@ import LinkDragGuide from './LinkDragGuide.vue'
import GanttConflicts from './Timeline/GanttConflicts.vue' import GanttConflicts from './Timeline/GanttConflicts.vue'
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { useI18n } from '../composables/useI18n' import { useI18n } from '../composables/useI18n'
import { useViewMode } from '../composables/useViewMode' // v1.9.9
import type { TaskBarConfig } from '../models/configs/TaskBarConfig' import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
import { getPredecessorIds } from '../utils/predecessorUtils' import { getPredecessorIds } from '../utils/predecessorUtils'
import { perfMonitor } from '../utils/perfMonitor' import { perfMonitor } from '../utils/perfMonitor'
import { perfMonitor2 } from '../utils/perfMonitor2' // v1.9.6 import { perfMonitor2 } from '../utils/perfMonitor2' // v1.9.6
import type { Task } from '../models/classes/Task' 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 { Milestone } from '../models/classes/Milestone'
import type { TimelineConfig } from '../models/configs/TimelineConfig' import type { TimelineConfig } from '../models/configs/TimelineConfig'
import { TimelineScale } from '../models/types/TimelineScale' import { TimelineScale } from '../models/types/TimelineScale'
import { assignTaskRows } from '../utils/taskLayoutUtils' // v1.9.0
import { positionCache } from '../utils/positionCache' // v1.9.6 Phase1 import { positionCache } from '../utils/positionCache' // v1.9.6 Phase1
// Props // Props
@@ -103,9 +103,8 @@ const t = (key: string): string => {
return getTranslation(key) return getTranslation(key)
} }
// v1.9.0 GanttChart // v1.9.9 使useViewMode
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task')) const { viewMode, dataSource } = useViewMode()
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
// v1.9.0 GanttChart GanttChart updateTaskTrigger // v1.9.0 GanttChart GanttChart updateTaskTrigger
const resourceConflicts = inject<ComputedRef<Map<string, Set<number>>>>('resourceConflicts', computed(() => new Map())) 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 // v1.9.5 showConflicts
const showConflicts = inject<ComputedRef<boolean>>('gantt-show-conflicts', computed(() => true)) const showConflicts = inject<ComputedRef<boolean>>('gantt-show-conflicts', computed(() => true))
// v1.9.6 Sprint1(P0) - resourceTaskLayouts // useResourceLayout
const layoutCache = new Map<string, { const ROW_HEIGHT = 51 // 51px (50px + 1px border)
taskRowMap: Map<string | number, number>, const VERTICAL_BUFFER = 5 //
rowHeights: number[], const timelineBodyScrollTop = ref(0) //
totalHeight: number const timelineBodyHeight = ref(0) //
}>()
// v1.9.6 Phase2 - + // v1.9.9 GanttChart
// // GanttChartprovideresourceTaskLayoutsresourceRowPositions
let resourceTaskLayoutsCallCount = 0 const resourceTaskLayouts = inject<ComputedRef<Map<string | number, any>>>('resourceTaskLayouts', computed(() => new Map()))
const resourceTaskLayouts = computed(() => { const resourceRowPositions = inject<ComputedRef<Map<string | number, number>>>('resourceRowPositions', computed(() => new Map()))
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
// 🎯 Phase2scrollBottom
// 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
})
// 线 // 线
const cachedTodayCenteredRange = (() => { const cachedTodayCenteredRange = (() => {
@@ -356,6 +228,7 @@ watch([timelineStartDate, timelineEndDate], ([newStart, newEnd]) => {
} }
} }
}) })
// 使 // 使
const EMPTY_TASKS_ARRAY: Task[] = [] const EMPTY_TASKS_ARRAY: Task[] = []
@@ -1482,12 +1355,6 @@ let hideBubblesTimeout: number | null = null // 半圆显示恢复定时器
const HOUR_WIDTH = 40 // 40px const HOUR_WIDTH = 40 // 40px
const VIRTUAL_BUFFER = 10 // 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 - // v1.9.5 P2-3 -
interface TimelineCacheEntry { interface TimelineCacheEntry {
data: unknown data: unknown
@@ -1720,8 +1587,8 @@ const visibleTaskRange = computed(() => {
for (let i = 0; i < resources.length; i++) { for (let i = 0; i < resources.length; i++) {
const resourceId = resources[i].id const resourceId = resources[i].id
const rowTop = resourceRowPositions.value?.get(resourceId) || 0 const rowTop = resourceRowPositions.value?.get(resourceId) || 0
// 🎯 使 // v1.9.9 resourceTaskLayouts
const layout = getResourceLayout(resources[i]) const layout = resourceTaskLayouts.value.get(resourceId)
const rowHeight = layout?.totalHeight || ROW_HEIGHT const rowHeight = layout?.totalHeight || ROW_HEIGHT
const rowBottom = rowTop + rowHeight const rowBottom = rowTop + rowHeight
@@ -1968,10 +1835,10 @@ const rebuildResourceTaskQueues = () => {
resourceId, resourceId,
rendered: isRendered, rendered: isRendered,
timestamp: isRendered ? timestamp: isRendered ?
(existingCache ? (existingCache ?
existingCache.timestamp existingCache.timestamp
: currentTimestamp) : currentTimestamp)
: currentTimestamp, : currentTimestamp,
}) })
}) })
@@ -2412,7 +2279,7 @@ const getOtherMilestonesInfo = (currentId: number) => {
// v1.9.5 P2-4 - Split Bar // v1.9.5 P2-4 - Split Bar
watch(isSplitBarDragging, (dragging) => { watch(isSplitBarDragging, (dragging) => {
if (!dragging) { if (!dragging) {
// // v1.9.9 ResizeObserver
if (timelineContainerElement.value) { if (timelineContainerElement.value) {
const newWidth = timelineContainerElement.value.clientWidth const newWidth = timelineContainerElement.value.clientWidth
if (Math.abs(newWidth - timelineContainerWidth.value) > 1) { if (Math.abs(newWidth - timelineContainerWidth.value) > 1) {
@@ -2420,6 +2287,14 @@ watch(isSplitBarDragging, (dragging) => {
} }
} }
// bodySplitterBar
if (timelineBodyElement.value) {
const newHeight = timelineBodyElement.value.clientHeight
if (Math.abs(newHeight - timelineBodyHeight.value) > 1) {
timelineBodyHeight.value = newHeight
}
}
// Splitter // Splitter
// Timeline // Timeline
hideBubbles.value = true hideBubbles.value = true
@@ -2492,8 +2367,8 @@ const contentHeight = computed(() => {
let totalHeight = 0 let totalHeight = 0
resources.forEach(resource => { resources.forEach(resource => {
// 🎯 使 // v1.9.9 resourceTaskLayouts
const layout = getResourceLayout(resource) const layout = resourceTaskLayouts.value.get(resource.id)
totalHeight += layout?.totalHeight || 51 totalHeight += layout?.totalHeight || 51
}) })
@@ -3658,42 +3533,19 @@ function handleBarMounted(payload: {
const handleTaskBarDragEnd = (updatedTask: Task) => { const handleTaskBarDragEnd = (updatedTask: Task) => {
// dataSource // dataSource
if (viewMode.value === 'resource' && dataSource.value) { if (viewMode.value === 'resource' && dataSource.value) {
let targetResourceId: string | number | null = null
let targetResource: any = null
for (const resource of dataSource.value as any[]) { for (const resource of dataSource.value as any[]) {
if (resource.tasks) { if (resource.tasks) {
const taskIndex = resource.tasks.findIndex((t: Task) => t.id === updatedTask.id) const taskIndex = resource.tasks.findIndex((t: Task) => t.id === updatedTask.id)
if (taskIndex !== -1) { if (taskIndex !== -1) {
// //
resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask } resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask }
targetResourceId = resource.id
targetResource = resource
break break
} }
} }
} }
// 🎯 // 🎯 GanttChartwatch
if (targetResourceId !== null && targetResource) { // v1.9.9 invalidateLayoutprops.resourcesdeep watch
//
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++
}
}
} }
// TaskBar ID // TaskBar ID
lastChangedTaskId.value = updatedTask.id lastChangedTaskId.value = updatedTask.id
@@ -3703,43 +3555,19 @@ const handleTaskBarDragEnd = (updatedTask: Task) => {
const handleTaskBarResizeEnd = (updatedTask: Task) => { const handleTaskBarResizeEnd = (updatedTask: Task) => {
// dataSource // dataSource
if (viewMode.value === 'resource' && dataSource.value) { if (viewMode.value === 'resource' && dataSource.value) {
let targetResourceId: string | number | null = null
let targetResource: any = null
for (const resource of dataSource.value as any[]) { for (const resource of dataSource.value as any[]) {
if (resource.tasks) { if (resource.tasks) {
const taskIndex = resource.tasks.findIndex((t: Task) => t.id === updatedTask.id) const taskIndex = resource.tasks.findIndex((t: Task) => t.id === updatedTask.id)
if (taskIndex !== -1) { if (taskIndex !== -1) {
// //
resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask } resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask }
targetResourceId = resource.id
targetResource = resource
break break
} }
} }
} }
// 🎯 // 🎯 GanttChartwatch
if (targetResourceId !== null && targetResource) { // v1.9.9 invalidateLayoutprops.resourcesdeep watch
//
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++
// }
}
} }
// TaskBar ID // TaskBar ID
lastChangedTaskId.value = updatedTask.id lastChangedTaskId.value = updatedTask.id
@@ -3839,6 +3667,11 @@ onMounted(() => {
timelineBodyHeight.value = timelineBody.clientHeight timelineBodyHeight.value = timelineBody.clientHeight
resizeObserver = new ResizeObserver(entries => { resizeObserver = new ResizeObserver(entries => {
// v1.9.9 SplitterBar
if (isSplitBarDragging.value) {
return
}
for (const entry of entries) { for (const entry of entries) {
timelineBodyHeight.value = entry.contentRect.height timelineBodyHeight.value = entry.contentRect.height
} }
@@ -3854,6 +3687,11 @@ onMounted(() => {
// ResizeObserver // ResizeObserver
const containerResizeObserver = new ResizeObserver(entries => { const containerResizeObserver = new ResizeObserver(entries => {
// v1.9.9 SplitterBar
if (isSplitBarDragging.value) {
return
}
for (const entry of entries) { for (const entry of entries) {
const newWidth = entry.contentRect.width const newWidth = entry.contentRect.width
// //
@@ -3992,6 +3830,7 @@ const handleTaskBarHighlighted = () => {
// 🔧 TaskBar resize // 🔧 TaskBar resize
// Timeline TaskBar // Timeline TaskBar
if (isDraggingTaskBar.value) { if (isDraggingTaskBar.value) {
// TaskBar Timeline
document.removeEventListener('mousemove', handleNextMouseMove) document.removeEventListener('mousemove', handleNextMouseMove)
return return
} }
@@ -4063,8 +3902,8 @@ const handleResourceTaskBarDrop = (event: Event) => {
const resource = resources[i] const resource = resources[i]
const resourceId = String(resource.id) const resourceId = String(resource.id)
const rowTop = resourceRowPositions.value.get(resourceId) || 0 const rowTop = resourceRowPositions.value.get(resourceId) || 0
// v1.9.6 Phase2: 使getResourceLayout // v1.9.9 resoureTaskLayouts
const layout = resourceTaskLayouts.value.get(resourceId) || getResourceLayout(resource) const layout = resourceTaskLayouts.value.get(resourceId)
const rowHeight = layout?.totalHeight || 51 const rowHeight = layout?.totalHeight || 51
const rowCenter = rowTop + rowHeight / 2 const rowCenter = rowTop + rowHeight / 2
const distance = Math.abs(relativeY - rowCenter) const distance = Math.abs(relativeY - rowCenter)
@@ -4395,8 +4234,8 @@ const handleDragBoundaryCheck = (event: CustomEvent) => {
const resource = resources[i] const resource = resources[i]
const resourceId = String(resource.id) const resourceId = String(resource.id)
const rowTop = resourceRowPositions.value.get(resourceId) || 0 const rowTop = resourceRowPositions.value.get(resourceId) || 0
// v1.9.6 Phase2: 使getResourceLayout // v1.9.9 resoureTaskLayouts
const layout = resourceTaskLayouts.value.get(resourceId) || getResourceLayout(resource) const layout = resourceTaskLayouts.value.get(resourceId)
const rowHeight = layout?.totalHeight || 51 const rowHeight = layout?.totalHeight || 51
const rowCenter = rowTop + rowHeight / 2 const rowCenter = rowTop + rowHeight / 2
const distance = Math.abs(relativeY - rowCenter) const distance = Math.abs(relativeY - rowCenter)
@@ -4669,6 +4508,13 @@ const debouncedUpdateTimelineRange = (delay = 50) => {
// tasks // tasks
const updateTimelineRange = () => { const updateTimelineRange = () => {
// Timelineheader +
// timelineDataheader
// visibleTimeRangeTaskBar
if (viewMode.value === 'resource') {
return
}
let newRange: { startDate: Date; endDate: Date } | null = null let newRange: { startDate: Date; endDate: Date } | null = null
if (currentTimeScale.value === TimelineScale.HOUR) { if (currentTimeScale.value === TimelineScale.HOUR) {
@@ -5739,6 +5585,7 @@ const handleAddSuccessor = (task: Task) => {
<!-- v1.9.5 可通过 show-conflicts prop 控制是否显示 --> <!-- v1.9.5 可通过 show-conflicts prop 控制是否显示 -->
<!-- v1.9.6 修复width使用totalTimelineWidth用于坐标计算containerWidth用于Canvas宽度 --> <!-- v1.9.6 修复width使用totalTimelineWidth用于坐标计算containerWidth用于Canvas宽度 -->
<!-- v1.9.7 Bug修复使用渲染的tasks而不是allTasks避免滚动后显示已消失TaskBar的冲突 --> <!-- v1.9.7 Bug修复使用渲染的tasks而不是allTasks避免滚动后显示已消失TaskBar的冲突 -->
<!-- v1.9.9 优化传递renderLimit只计算已渲染TaskBar的冲突 -->
<GanttConflicts <GanttConflicts
v-if="showConflicts" v-if="showConflicts"
:tasks="(resource as any).tasks" :tasks="(resource as any).tasks"
@@ -5760,6 +5607,7 @@ const handleAddSuccessor = (task: Task) => {
:row-heights="resourceTaskLayouts.get(resource.id)?.rowHeights" :row-heights="resourceTaskLayouts.get(resource.id)?.rowHeights"
:scroll-left="timelineScrollLeft" :scroll-left="timelineScrollLeft"
:container-width="timelineContainerWidth" :container-width="timelineContainerWidth"
:render-limit="resourceTaskRenderLimits.get(resource.id)"
/> />
</template> </template>
</div> </div>
+25 -12
View File
@@ -31,6 +31,8 @@ interface Props {
scrollLeft?: number scrollLeft?: number
/** 可视区域宽度 */ /** 可视区域宽度 */
containerWidth?: number containerWidth?: number
/** v1.9.9 当前渲染的任务数量限制(用于渐进式渲染) */
renderLimit?: number
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -90,11 +92,17 @@ const canvasStyle = computed(() => ({
watch(() => props.tasks, () => { watch(() => props.tasks, () => {
if (isDraggingTaskBar.value) { if (isDraggingTaskBar.value) {
needsRecalculation.value = true needsRecalculation.value = true
} else {
recalculateConflicts()
} }
}, { deep: true }) }, { deep: true })
// v1.9.9 renderLimitTaskBar
watch(() => props.renderLimit, () => {
// DOM
nextTick(() => {
recalculateConflicts()
})
})
// //
watch(isDraggingTaskBar, (dragging) => { watch(isDraggingTaskBar, (dragging) => {
if (!dragging && needsRecalculation.value) { if (!dragging && needsRecalculation.value) {
@@ -155,7 +163,6 @@ watch([canvasWidth, canvasHeight], () => {
// timelineDatacurrentTimeScale // timelineDatacurrentTimeScale
watch([() => props.timelineData, () => props.currentTimeScale], () => { watch([() => props.timelineData, () => props.currentTimeScale], () => {
if (import.meta.env.DEV) {}
// v1.9.4 BUG - // v1.9.4 BUG -
coordsCache.clear() coordsCache.clear()
// v1.9.7 Bug - 使nextTick // v1.9.7 Bug - 使nextTick
@@ -166,6 +173,7 @@ watch([() => props.timelineData, () => props.currentTimeScale], () => {
}, { deep: true }) }, { deep: true })
// v1.9.6 scrollLeft // v1.9.6 scrollLeft
// v1.9.9 Canvas
let scrollTimer: ReturnType<typeof setTimeout> | null = null let scrollTimer: ReturnType<typeof setTimeout> | null = null
watch(() => props.scrollLeft, () => { watch(() => props.scrollLeft, () => {
// //
@@ -173,15 +181,14 @@ watch(() => props.scrollLeft, () => {
clearTimeout(scrollTimer) clearTimeout(scrollTimer)
} }
// 100ms // Canvas
clearCanvas()
// 50ms
scrollTimer = setTimeout(() => { scrollTimer = setTimeout(() => {
nextTick(() => { recalculateConflicts()
// Canvas scrollTimer = null
clearCanvas() }, 50)
// conflictZonesscrollLeft
recalculateConflicts()
})
}, 100)
}) })
// TaskBar // TaskBar
@@ -298,8 +305,14 @@ function recalculateConflictsIncremental(changedTaskId: string | number) {
// //
function recalculateConflicts() { function recalculateConflicts() {
// v1.9.9 TaskBar
// renderLimitN
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 - 使 // v1.9.4 P1 - 使
conflictZones.value = conflicts.map((zone) => { conflictZones.value = conflicts.map((zone) => {
+6
View File
@@ -205,6 +205,8 @@ const conflictInfoList = computed(() => {
const conflictEnd = new Date(conflictTask.endDate).getTime() const conflictEnd = new Date(conflictTask.endDate).getTime()
// //
// v1.9.10 resources 使 100%
//
let conflictPercent = 100 let conflictPercent = 100
if (conflictTask.resources && Array.isArray(conflictTask.resources)) { if (conflictTask.resources && Array.isArray(conflictTask.resources)) {
const allocation = conflictTask.resources.find( const allocation = conflictTask.resources.find(
@@ -214,6 +216,7 @@ const conflictInfoList = computed(() => {
conflictPercent = Math.max(20, Math.min(100, allocation.capacity)) conflictPercent = Math.max(20, Math.min(100, allocation.capacity))
} }
} }
// else: 100%
// //
const overlapStart = Math.max(currentStart, conflictStart) const overlapStart = Math.max(currentStart, conflictStart)
@@ -282,6 +285,8 @@ const totalOverloadPercent = computed(() => {
// 使 +1 endDate // 使 +1 endDate
if (tStart < overlapEndPlus && tEndPlus > overlapStart) { if (tStart < overlapEndPlus && tEndPlus > overlapStart) {
// v1.9.10 resources 使 100%
//
let taskPercent = 100 let taskPercent = 100
if (task.resources && Array.isArray(task.resources)) { if (task.resources && Array.isArray(task.resources)) {
const allocation = task.resources.find( const allocation = task.resources.find(
@@ -291,6 +296,7 @@ const totalOverloadPercent = computed(() => {
taskPercent = allocation.capacity taskPercent = allocation.capacity
} }
} }
// else: 100%
intervalTotal += taskPercent intervalTotal += taskPercent
} }
}) })
+211
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,
}
}
+76
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,
}
}
+1 -1
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 MilestoneDialog } from './components/MilestoneDialog.vue'
export { default as TaskRow } from './components/TaskList/taskRow/TaskRow.vue' export { default as TaskRow } from './components/TaskList/taskRow/TaskRow.vue'
export type { Task } from './models/classes/Task.ts' // 导出Task类型 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 { createResource } from './utils/resourceUtils.ts' // v2.0.0 导出资源创建工厂函数
export { useMessage } from './composables/useMessage.ts' // 导出useMessage组合式函数 export { useMessage } from './composables/useMessage.ts' // 导出useMessage组合式函数
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Resource } from '../types/Resource' import type { Resource } from './Resource'
// Task 类型定义 // Task 类型定义
export interface Task { export interface Task {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Resource } from '../types/Resource' import type { Resource } from '../classes/Resource'
/** /**
* *
+4 -2
View File
@@ -56,7 +56,8 @@ export function detectConflicts(
tasks: Task[], tasks: Task[],
resourceId: string | number, resourceId: string | number,
): ConflictZone[] { ): ConflictZone[] {
// 过滤出包含指定资源的任务(没有resources字段时视为100%分配给该资源) // v1.9.10 过滤出包含指定资源的任务
// 注意:如果任务没有resources字段或为空,视为100%分配给该资源(资源视图中任务必然属于某个资源)
const resourceTasks = tasks.filter((task) => { const resourceTasks = tasks.filter((task) => {
// 如果没有resources字段或为空,视为100%分配 // 如果没有resources字段或为空,视为100%分配
if (!task.resources || task.resources.length === 0) return true if (!task.resources || task.resources.length === 0) return true
@@ -125,7 +126,8 @@ function detectConflictsBruteForce(
// 计算总投入比例 // 计算总投入比例
let totalPercent = 0 let totalPercent = 0
const taskDetails = overlappingTasks.map((task) => { 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 resource = task.resources?.find((r) => String(r.id) === String(resourceId))
const capacity = const capacity =
!task.resources || task.resources.length === 0 ? 100 : (resource?.capacity || 0) !task.resources || task.resources.length === 0 ? 100 : (resource?.capacity || 0)
-6
View File
@@ -81,13 +81,7 @@ export const perfMonitor = {
* *
*/ */
measure<T>(fn: () => T): T { measure<T>(fn: () => T): T {
const start = performance.now()
const result = fn() const result = fn()
const end = performance.now()
const duration = end - start
if (duration > 5) { }
return result return result
}, },
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Task } from '../models/classes/Task' 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' import { detectConflicts } from './conflictUtils'
/** /**