v1.9.0-rc.8 - SIT finished
This commit is contained in:
40
CHANGELOG.md
40
CHANGELOG.md
@@ -5,7 +5,45 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.8.0] - 2026-02-05
|
||||
## [1.9.0] - 2026-02-07
|
||||
|
||||
### Added
|
||||
- 🎉 新增:GanttChart支持资源视图模式,通过viewMode属性切换任务视图和资源视图
|
||||
- 🎉 新增:资源视图以资源为行维度展示,支持一行多任务条布局
|
||||
- 🎉 新增:Task对象增加resources字段,支持配置每个资源的投入占比(10%-100%)
|
||||
- 🎉 新增:TaskBar高度按资源占比动态缩放,支持占比文字标注和Tooltip显示
|
||||
- 🎉 新增:基于占比累计的资源超负荷检测和视觉预警
|
||||
- 🎉 新增:TaskDrawer增加资源占比配置控件,支持输入校验
|
||||
- 🎉 新增:资源视图支持任务拖拽、拉伸、计时等交互操作
|
||||
- 🎉 新增:资源列表支持声明式组件配置列
|
||||
- 🎉 新增:Resource数据模型类和useResourceLayout、useViewMode等composables
|
||||
- 🎉 Added: GanttChart supports resource view mode, switch via viewMode property
|
||||
- 🎉 Added: Resource view displays by resource dimension with multiple task bars per row
|
||||
- 🎉 Added: Task object adds resources field for configuring resource allocation percentage (10%-100%)
|
||||
- 🎉 Added: TaskBar height scales by percentage with text label and Tooltip support
|
||||
- 🎉 Added: Resource overload detection and visual alerts based on percentage accumulation
|
||||
- 🎉 Added: TaskDrawer adds resource percentage configuration with input validation
|
||||
- 🎉 Added: Resource view supports task drag, resize, timing and other interactions
|
||||
- 🎉 Added: Resource list supports declarative component column configuration
|
||||
- 🎉 Added: Resource model class and composables including useResourceLayout, useViewMode
|
||||
|
||||
### Enhancement
|
||||
- 🎉 优化:抽取资源布局计算逻辑到独立composable,避免组件职责膨胀
|
||||
- 🎉 优化:扩展v-memo优化覆盖范围,提升渲染性能
|
||||
- 🎉 优化:重构provide/inject依赖传递,简化组件依赖关系
|
||||
- 🎉 优化:恢复滚动防抖机制,平衡响应性和性能
|
||||
- 🎉 优化:历史数据向后兼容,未配置占比时默认为100%
|
||||
- 🎉 Optimized: Extracted resource layout logic to independent composable
|
||||
- 🎉 Optimized: Extended v-memo optimization coverage for better performance
|
||||
- 🎉 Optimized: Refactored provide/inject dependency passing
|
||||
- 🎉 Optimized: Restored scroll debounce mechanism
|
||||
- 🎉 Optimized: Backward compatible with historical data, defaults to 100%
|
||||
|
||||
### Fixed
|
||||
- 🔧 修复: 将Theme设置从HTML root变更到Gantt Component root
|
||||
- 🔧 Fixed: Changed Theme setting from HTML root to Gantt Component root
|
||||
|
||||
## [1.8.1] - 2026-02-05
|
||||
|
||||
### Enhancement
|
||||
- 🎉 优化:重构组件,Theme作用域从全局调整至局部.gantt-root
|
||||
|
||||
184
demo/App.vue
184
demo/App.vue
@@ -17,7 +17,7 @@ import { useI18n } from '../src/composables/useI18n'
|
||||
import { useDemoLocale } from './useDemoLocale'
|
||||
import { getPredecessorIds, predecessorIdsToString } from '../src/utils/predecessorUtils'
|
||||
import type { Task } from '../src/models/Task'
|
||||
import type { Resource } from '../src/models/types/Resource'
|
||||
import type { Resource } from '../src/models/classes/Resource'
|
||||
import { createResource, addTaskToResource, updateResourceUtilization } from '../src/utils/resourceUtils'
|
||||
import type { TaskListConfig, TaskListColumnConfig } from '../src/models/configs/TaskListConfig'
|
||||
import type { ResourceListConfig } from '../src/models/configs/ResourceListConfig'
|
||||
@@ -626,7 +626,7 @@ const handleTaskClick = (task: Task) => {
|
||||
}
|
||||
|
||||
// v1.9.7 处理任务双击事件(资源视图下显示资源编辑提示)
|
||||
const handleTaskDoubleClick = (taskOrResource: Task | Resource) => {
|
||||
const handleTaskDoubleClick = (taskOrResource: Task | Resource) => {
|
||||
// 使用类型守卫严格判断是否为Resource对象
|
||||
if (viewMode.value === 'resource' && isResource(taskOrResource)) {
|
||||
// 这是Resource对象,显示资源编辑提示
|
||||
@@ -635,7 +635,7 @@ const handleTaskDoubleClick = (taskOrResource: Task | Resource) => {
|
||||
resourceEditHintVisible.value = true
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 对于真正的Task对象,GanttChart会正常打开TaskDrawer
|
||||
// useDefaultDrawer保持为true,确保新建任务和TaskBar双击都能正常工作
|
||||
}
|
||||
@@ -643,9 +643,9 @@ const handleTaskDoubleClick = (taskOrResource: Task | Resource) => {
|
||||
// v1.9.7 类型守卫:判断是否为Resource对象
|
||||
// Resource独有的特征:有tasks数组属性,且没有resources属性
|
||||
const isResource = (obj: Task | Resource): obj is Resource => {
|
||||
return obj &&
|
||||
typeof obj === 'object' &&
|
||||
'tasks' in obj &&
|
||||
return obj &&
|
||||
typeof obj === 'object' &&
|
||||
'tasks' in obj &&
|
||||
Array.isArray((obj as Resource).tasks) &&
|
||||
!('resources' in obj) // Task有resources属性,Resource没有
|
||||
}
|
||||
@@ -1169,7 +1169,7 @@ const confirmResourceDrag = () => {
|
||||
task.resources.push({
|
||||
id: targetResource.id,
|
||||
name: targetResource.name,
|
||||
capacity: oldCapacity
|
||||
capacity: oldCapacity,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3453,196 +3453,196 @@ const handleCustomMenuAction = (action: string, task: Task) => {
|
||||
}
|
||||
|
||||
/* 全局暗色主题支持 */
|
||||
:global(html[data-theme='dark']) {
|
||||
:global(.gantt-root[data-theme='dark']) {
|
||||
background: #1e1e1e !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) body {
|
||||
:global(.gantt-root[data-theme='dark']) body {
|
||||
background: #1e1e1e !important;
|
||||
color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
/* 暗色主题下的页面标题 */
|
||||
:global(html[data-theme='dark']) .page-title {
|
||||
:global(.gantt-root[data-theme='dark']) .page-title {
|
||||
background: #1e1e1e;
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
/* 暗色主题下的配置面板样式 */
|
||||
:global(html[data-theme='dark']) .config-panel {
|
||||
:global(.gantt-root[data-theme='dark']) .config-panel {
|
||||
background: var(--gantt-bg-primary, #2d3748);
|
||||
border-color: var(--gantt-border-color, #4a5568);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .data-source-panel {
|
||||
:global(.gantt-root[data-theme='dark']) .data-source-panel {
|
||||
background: var(--gantt-bg-primary, #2d3748);
|
||||
border-color: var(--gantt-border-color, #4a5568);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .data-source-sub {
|
||||
:global(.gantt-root[data-theme='dark']) .data-source-sub {
|
||||
color: var(--gantt-text-secondary, #a0aec0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .data-source-button {
|
||||
:global(.gantt-root[data-theme='dark']) .data-source-button {
|
||||
background: var(--gantt-bg-secondary, #1a202c);
|
||||
border-color: var(--gantt-border-color, #4a5568);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .data-source-button.active {
|
||||
:global(.gantt-root[data-theme='dark']) .data-source-button.active {
|
||||
background: rgba(64, 158, 255, 0.18);
|
||||
border-color: var(--gantt-primary-color, #66b3ff);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .ds-label,
|
||||
:global(html[data-theme='dark']) .ds-desc,
|
||||
:global(html[data-theme='dark']) .ds-file {
|
||||
:global(.gantt-root[data-theme='dark']) .ds-label,
|
||||
:global(.gantt-root[data-theme='dark']) .ds-desc,
|
||||
:global(.gantt-root[data-theme='dark']) .ds-file {
|
||||
color: var(--gantt-text-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .ds-file {
|
||||
:global(.gantt-root[data-theme='dark']) .ds-file {
|
||||
color: var(--gantt-text-muted, #a0aec0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .config-title {
|
||||
:global(.gantt-root[data-theme='dark']) .config-title {
|
||||
color: var(--gantt-text-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .section-title {
|
||||
:global(.gantt-root[data-theme='dark']) .section-title {
|
||||
color: var(--gantt-text-primary, #e2e8f0);
|
||||
border-bottom-color: var(--gantt-primary-color, #66b3ff);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .section-header {
|
||||
:global(.gantt-root[data-theme='dark']) .section-header {
|
||||
border-bottom-color: var(--gantt-primary-color, #66b3ff);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .section-header:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .section-header:hover {
|
||||
border-bottom-color: var(--gantt-primary-color-light, #74c0fc);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .section-header-title {
|
||||
:global(.gantt-root[data-theme='dark']) .section-header-title {
|
||||
color: var(--gantt-text-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .section-collapse-button {
|
||||
:global(.gantt-root[data-theme='dark']) .section-collapse-button {
|
||||
color: var(--gantt-text-secondary, #a0aec0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .section-collapse-button:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .section-collapse-button:hover {
|
||||
color: var(--gantt-primary-color, #66b3ff);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .section-icon {
|
||||
:global(.gantt-root[data-theme='dark']) .section-icon {
|
||||
color: var(--gantt-primary-color, #66b3ff);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .subsection {
|
||||
:global(.gantt-root[data-theme='dark']) .subsection {
|
||||
border-left-color: var(--gantt-border-color, #4a5568);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .subsection-title {
|
||||
:global(.gantt-root[data-theme='dark']) .subsection-title {
|
||||
color: var(--gantt-text-secondary, #a0aec0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .section-subtitle {
|
||||
:global(.gantt-root[data-theme='dark']) .section-subtitle {
|
||||
color: var(--gantt-primary-color, #66b3ff);
|
||||
background: rgba(102, 179, 255, 0.15);
|
||||
border-left-color: var(--gantt-primary-color, #66b3ff);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .subsection-title::before {
|
||||
:global(.gantt-root[data-theme='dark']) .subsection-title::before {
|
||||
background-color: var(--gantt-primary-color, #66b3ff);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .column-control {
|
||||
:global(.gantt-root[data-theme='dark']) .column-control {
|
||||
background: var(--gantt-bg-secondary, #1a202c);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .column-control:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .column-control:hover {
|
||||
background: var(--gantt-hover-bg, #2d3748);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .column-label {
|
||||
:global(.gantt-root[data-theme='dark']) .column-label {
|
||||
color: var(--gantt-text-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .column-control:hover .column-label {
|
||||
:global(.gantt-root[data-theme='dark']) .column-control:hover .column-label {
|
||||
color: var(--gantt-primary-color, #66b3ff);
|
||||
}
|
||||
|
||||
/* 暗色主题下的TaskBar配置样式 */
|
||||
:global(html[data-theme='dark']) .taskbar-control {
|
||||
:global(.gantt-root[data-theme='dark']) .taskbar-control {
|
||||
background: var(--gantt-bg-secondary, #1a202c);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .taskbar-control:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .taskbar-control:hover {
|
||||
background: var(--gantt-hover-bg, #2d3748);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .taskbar-label {
|
||||
:global(.gantt-root[data-theme='dark']) .taskbar-label {
|
||||
color: var(--gantt-text-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .taskbar-control:hover .taskbar-label {
|
||||
:global(.gantt-root[data-theme='dark']) .taskbar-control:hover .taskbar-label {
|
||||
color: var(--gantt-primary-color, #66b3ff);
|
||||
}
|
||||
|
||||
/* 暗色主题下的 TaskBar 高级配置 */
|
||||
:global(html[data-theme='dark']) .control-row {
|
||||
:global(.gantt-root[data-theme='dark']) .control-row {
|
||||
background: var(--gantt-bg-secondary, #1a202c);
|
||||
border-color: var(--gantt-border-color, #4a5568);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .control-label {
|
||||
:global(.gantt-root[data-theme='dark']) .control-label {
|
||||
color: var(--gantt-text-secondary, #a0aec0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .control-input {
|
||||
:global(.gantt-root[data-theme='dark']) .control-input {
|
||||
background: var(--gantt-bg-primary, #2d3748);
|
||||
color: var(--gantt-text-primary, #e2e8f0);
|
||||
border-color: var(--gantt-border-color, #4a5568);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .control-hint {
|
||||
:global(.gantt-root[data-theme='dark']) .control-hint {
|
||||
color: var(--gantt-text-muted, #718096);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .taskbar-field-control {
|
||||
:global(.gantt-root[data-theme='dark']) .taskbar-field-control {
|
||||
background: var(--gantt-bg-secondary, #1a202c);
|
||||
border-color: var(--gantt-border-color, #4a5568);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .field-label {
|
||||
:global(.gantt-root[data-theme='dark']) .field-label {
|
||||
color: var(--gantt-text-secondary, #a0aec0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .field-select {
|
||||
:global(.gantt-root[data-theme='dark']) .field-select {
|
||||
background: var(--gantt-bg-primary, #2d3748);
|
||||
color: var(--gantt-text-primary, #e2e8f0);
|
||||
border-color: var(--gantt-border-color, #4a5568);
|
||||
}
|
||||
|
||||
/* 暗色主题下的折叠面板样式 */
|
||||
:global(html[data-theme='dark']) .config-header {
|
||||
:global(.gantt-root[data-theme='dark']) .config-header {
|
||||
border-bottom-color: var(--gantt-border-color, #4a5568);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .config-header:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .config-header:hover {
|
||||
background-color: var(--gantt-hover-bg, #2d3748);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .collapse-button {
|
||||
:global(.gantt-root[data-theme='dark']) .collapse-button {
|
||||
color: var(--gantt-text-secondary, #a0aec0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .collapse-button:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .collapse-button:hover {
|
||||
background-color: var(--gantt-hover-bg, #2d3748);
|
||||
color: var(--gantt-primary-color, #66b3ff);
|
||||
}
|
||||
|
||||
/* 暗黑模式下的版本标签 */
|
||||
:global(html[data-theme='dark']) .version-badge {
|
||||
:global(.gantt-root[data-theme='dark']) .version-badge {
|
||||
background: linear-gradient(135deg, #1a73e8 0%, #00bcd4 50%, #3f51b5 100%);
|
||||
box-shadow:
|
||||
0 0 25px rgba(102, 177, 255, 0.4),
|
||||
@@ -3650,7 +3650,7 @@ const handleCustomMenuAction = (action: string, task: Task) => {
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .version-badge:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .version-badge:hover {
|
||||
background: linear-gradient(135deg, #2196f3 0%, #00e5ff 50%, #5c6bc0 100%);
|
||||
box-shadow:
|
||||
0 0 35px rgba(102, 177, 255, 0.6),
|
||||
@@ -3763,53 +3763,53 @@ const handleCustomMenuAction = (action: string, task: Task) => {
|
||||
/* 移除旧的基于 SVG color 的样式,现在使用 filter */
|
||||
|
||||
/* 暗黑模式下覆盖所有链接样式 */
|
||||
:global(html[data-theme='dark']) .doc-link {
|
||||
:global(.gantt-root[data-theme='dark']) .doc-link {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .doc-link:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .doc-link:hover {
|
||||
color: #ffffff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .doc-link:nth-child(2) {
|
||||
:global(.gantt-root[data-theme='dark']) .doc-link:nth-child(2) {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .doc-link:nth-child(2):hover {
|
||||
:global(.gantt-root[data-theme='dark']) .doc-link:nth-child(2):hover {
|
||||
color: #ffffff !important;
|
||||
background-color: rgba(199, 29, 35, 0.1);
|
||||
}
|
||||
|
||||
/* 暗黑模式下图标样式 */
|
||||
:global(html[data-theme='dark']) .github-link .doc-icon {
|
||||
:global(.gantt-root[data-theme='dark']) .github-link .doc-icon {
|
||||
filter: brightness(0) saturate(100%) invert(100%);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .github-link:hover .doc-icon {
|
||||
:global(.gantt-root[data-theme='dark']) .github-link:hover .doc-icon {
|
||||
filter: brightness(0) saturate(100%) invert(70%) sepia(50%) saturate(2000%) hue-rotate(190deg)
|
||||
brightness(1.2);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .gitee-link .doc-icon {
|
||||
:global(.gantt-root[data-theme='dark']) .gitee-link .doc-icon {
|
||||
filter: brightness(0) saturate(100%) invert(45%) sepia(100%) saturate(1500%) hue-rotate(340deg)
|
||||
brightness(1.1);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .gitee-link:hover .doc-icon {
|
||||
:global(.gantt-root[data-theme='dark']) .gitee-link:hover .doc-icon {
|
||||
filter: brightness(0) saturate(100%) invert(50%) sepia(100%) saturate(1800%) hue-rotate(340deg)
|
||||
brightness(1.2);
|
||||
}
|
||||
|
||||
/* 暗黑模式下的里程碑图标发光效果 */
|
||||
:global(html[data-theme='dark']) .milestone-group-icon {
|
||||
:global(.gantt-root[data-theme='dark']) .milestone-group-icon {
|
||||
color: var(--gantt-danger, #f67c7c);
|
||||
fill: var(--gantt-danger, #f67c7c);
|
||||
filter: drop-shadow(0 0 6px var(--gantt-danger, #f67c7c));
|
||||
animation: milestone-icon-glow-dark 2.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .task-row:hover .milestone-group-icon {
|
||||
:global(.gantt-root[data-theme='dark']) .task-row:hover .milestone-group-icon {
|
||||
filter: drop-shadow(0 0 10px var(--gantt-danger, #f67c7c))
|
||||
drop-shadow(0 0 16px rgba(246, 124, 124, 0.4));
|
||||
animation: milestone-icon-glow-intense-dark 1.8s ease-in-out infinite alternate;
|
||||
@@ -4019,28 +4019,28 @@ const handleCustomMenuAction = (action: string, task: Task) => {
|
||||
}
|
||||
|
||||
/* 暗黑模式适配 */
|
||||
:global(html[data-theme='dark']) .task-click-dialog {
|
||||
:global(.gantt-root[data-theme='dark']) .task-click-dialog {
|
||||
background: var(--gantt-bg-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .task-click-dialog-header h3 {
|
||||
:global(.gantt-root[data-theme='dark']) .task-click-dialog-header h3 {
|
||||
color: var(--gantt-text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .close-button {
|
||||
:global(.gantt-root[data-theme='dark']) .close-button {
|
||||
color: var(--gantt-text-secondary, #b0b0b0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .close-button:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .close-button:hover {
|
||||
background: var(--gantt-bg-hover, #2a2a2a);
|
||||
color: var(--gantt-text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .property-key {
|
||||
:global(.gantt-root[data-theme='dark']) .property-key {
|
||||
color: var(--gantt-text-secondary, #b0b0b0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .property-value {
|
||||
:global(.gantt-root[data-theme='dark']) .property-value {
|
||||
color: var(--gantt-text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
@@ -4079,27 +4079,27 @@ const handleCustomMenuAction = (action: string, task: Task) => {
|
||||
}
|
||||
|
||||
/* 暗黑模式下的任务行样式 */
|
||||
:global(html[data-theme='dark']) :deep(.task-row-success) {
|
||||
:global(.gantt-root[data-theme='dark']) :deep(.task-row-success) {
|
||||
background: linear-gradient(90deg, #0a3a2a 0%, #1b4d3e 100%) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) :deep(.task-row-success:hover) {
|
||||
:global(.gantt-root[data-theme='dark']) :deep(.task-row-success:hover) {
|
||||
background: linear-gradient(90deg, #0f4d35 0%, #276749 100%) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) :deep(.task-row-warning) {
|
||||
:global(.gantt-root[data-theme='dark']) :deep(.task-row-warning) {
|
||||
background: linear-gradient(90deg, #3d2f1f 0%, #4d3b2a 100%) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) :deep(.task-row-warning:hover) {
|
||||
:global(.gantt-root[data-theme='dark']) :deep(.task-row-warning:hover) {
|
||||
background: linear-gradient(90deg, #4d3b26 0%, #5d4a35 100%) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) :deep(.task-row-info) {
|
||||
:global(.gantt-root[data-theme='dark']) :deep(.task-row-info) {
|
||||
background: linear-gradient(90deg, #2a2a2a 0%, #333333 100%) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) :deep(.task-row-info:hover) {
|
||||
:global(.gantt-root[data-theme='dark']) :deep(.task-row-info:hover) {
|
||||
background: linear-gradient(90deg, #353535 0%, #3d3d3d 100%) !important;
|
||||
}
|
||||
|
||||
@@ -4151,34 +4151,34 @@ const handleCustomMenuAction = (action: string, task: Task) => {
|
||||
}
|
||||
|
||||
/* 暗色主题下的自定义菜单 */
|
||||
:global(html[data-theme='dark']) .custom-menu {
|
||||
:global(.gantt-root[data-theme='dark']) .custom-menu {
|
||||
background: #2a2a2a;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .custom-menu-header {
|
||||
:global(.gantt-root[data-theme='dark']) .custom-menu-header {
|
||||
background: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .custom-menu-item {
|
||||
:global(.gantt-root[data-theme='dark']) .custom-menu-item {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .custom-menu-item:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .custom-menu-item:hover {
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .custom-menu-item.danger {
|
||||
:global(.gantt-root[data-theme='dark']) .custom-menu-item.danger {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .custom-menu-item.danger:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .custom-menu-item.danger:hover {
|
||||
background: #3a2020;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .custom-menu-divider {
|
||||
:global(.gantt-root[data-theme='dark']) .custom-menu-divider {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
@@ -4485,61 +4485,61 @@ const handleCustomMenuAction = (action: string, task: Task) => {
|
||||
}
|
||||
|
||||
/* 暗色主题支持 */
|
||||
:global(html[data-theme='dark']) .status-panel {
|
||||
:global(.gantt-root[data-theme='dark']) .status-panel {
|
||||
background: var(--gantt-bg-secondary, #1a202c);
|
||||
border-color: var(--gantt-border-color, #4a5568);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .status-value {
|
||||
:global(.gantt-root[data-theme='dark']) .status-value {
|
||||
background: var(--gantt-bg-secondary, #1a202c);
|
||||
color: var(--gantt-text-primary, #e2e8f0);
|
||||
border-color: var(--gantt-border-color, #4a5568);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .status-value.active {
|
||||
:global(.gantt-root[data-theme='dark']) .status-value.active {
|
||||
background: rgba(64, 158, 255, 0.15);
|
||||
border-color: var(--gantt-primary-color, #66b3ff);
|
||||
color: var(--gantt-primary-color, #66b3ff);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .tool-button {
|
||||
:global(.gantt-root[data-theme='dark']) .tool-button {
|
||||
background: var(--gantt-bg-secondary, #1a202c);
|
||||
color: var(--gantt-text-primary, #e2e8f0);
|
||||
border-color: var(--gantt-border-color, #4a5568);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .tool-button:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .tool-button:hover {
|
||||
background: var(--gantt-hover-bg, #2d3748);
|
||||
border-color: var(--gantt-primary-color, #66b3ff);
|
||||
color: var(--gantt-primary-color, #66b3ff);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .tool-button.primary {
|
||||
:global(.gantt-root[data-theme='dark']) .tool-button.primary {
|
||||
background: var(--gantt-primary-color, #409eff);
|
||||
border-color: var(--gantt-primary-color, #409eff);
|
||||
color: white;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .tool-button.primary:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .tool-button.primary:hover {
|
||||
background: var(--gantt-primary-hover, #66b1ff);
|
||||
border-color: var(--gantt-primary-hover, #66b1ff);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .tool-divider {
|
||||
:global(.gantt-root[data-theme='dark']) .tool-divider {
|
||||
background: var(--gantt-border-color, #4a5568);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .tool-input {
|
||||
:global(.gantt-root[data-theme='dark']) .tool-input {
|
||||
background: var(--gantt-bg-primary, #2d3748);
|
||||
color: var(--gantt-text-primary, #e2e8f0);
|
||||
border-color: var(--gantt-border-color, #4a5568);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .tool-label {
|
||||
:global(.gantt-root[data-theme='dark']) .tool-label {
|
||||
color: var(--gantt-text-secondary, #a0aec0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .tool-note {
|
||||
:global(.gantt-root[data-theme='dark']) .tool-note {
|
||||
background: var(--gantt-bg-secondary, #1a202c);
|
||||
color: var(--gantt-text-secondary, #a0aec0);
|
||||
border-left-color: var(--gantt-primary-color, #66b3ff);
|
||||
|
||||
@@ -268,65 +268,65 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
/* 暗黑主题适配,完全对齐TaskDrawer风格 */
|
||||
:global(html[data-theme='dark']) .version-history-drawer {
|
||||
:global(.gantt-root[data-theme='dark']) .version-history-drawer {
|
||||
background: var(--gantt-bg-primary, #6b6b6b) !important;
|
||||
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .drawer-header {
|
||||
:global(.gantt-root[data-theme='dark']) .drawer-header {
|
||||
background: var(--gantt-bg-secondary, #4b4b4b) !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .drawer-title {
|
||||
:global(.gantt-root[data-theme='dark']) .drawer-title {
|
||||
color: var(--gantt-text-white, #fff) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .drawer-close-btn:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .drawer-close-btn:hover {
|
||||
background: var(--gantt-bg-hover, rgba(255, 255, 255, 0.1)) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
:global(html[data-theme='dark']) .drawer-body {
|
||||
:global(.gantt-root[data-theme='dark']) .drawer-body {
|
||||
background: var(--gantt-bg-primary, #6b6b6b) !important;
|
||||
scrollbar-color: #888888 #4b4b4b !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .drawer-body::-webkit-scrollbar-thumb {
|
||||
:global(.gantt-root[data-theme='dark']) .drawer-body::-webkit-scrollbar-thumb {
|
||||
background: #888888 !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .drawer-body::-webkit-scrollbar-track {
|
||||
:global(.gantt-root[data-theme='dark']) .drawer-body::-webkit-scrollbar-track {
|
||||
background: #4b4b4b !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .version-timeline-content.version-card {
|
||||
:global(.gantt-root[data-theme='dark']) .version-timeline-content.version-card {
|
||||
background: var(--gantt-bg-secondary, #4b4b4b) !important;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.32) !important;
|
||||
color: var(--gantt-text-white, #fff) !important;
|
||||
}
|
||||
:global(html[data-theme='dark'])
|
||||
:global(.gantt-root[data-theme='dark'])
|
||||
.version-timeline-group:hover
|
||||
.version-timeline-content.version-card {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.44) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .version-timeline-content.version-card::before {
|
||||
:global(.gantt-root[data-theme='dark']) .version-timeline-content.version-card::before {
|
||||
filter: drop-shadow(-2px 0 2px #222) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .dot-version {
|
||||
:global(.gantt-root[data-theme='dark']) .dot-version {
|
||||
color: var(--gantt-primary, #3399ff) !important;
|
||||
text-shadow: 0 1px 4px rgba(51, 153, 255, 0.12) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .dot-date {
|
||||
:global(.gantt-root[data-theme='dark']) .dot-date {
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .version-notes {
|
||||
:global(.gantt-root[data-theme='dark']) .version-notes {
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .version-timeline-dot {
|
||||
:global(.gantt-root[data-theme='dark']) .version-timeline-dot {
|
||||
background: var(--gantt-timeline-dot, #3399ff) !important;
|
||||
box-shadow: 0 0 0 2px #222 !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .version-timeline-dot.latest {
|
||||
:global(.gantt-root[data-theme='dark']) .version-timeline-dot.latest {
|
||||
background: var(--gantt-primary, #3399ff) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .version-timeline-group:hover .version-timeline-dot {
|
||||
:global(.gantt-root[data-theme='dark']) .version-timeline-group:hover .version-timeline-dot {
|
||||
background: var(--gantt-primary, #3399ff) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .version-timeline::before {
|
||||
:global(.gantt-root[data-theme='dark']) .version-timeline::before {
|
||||
background: repeating-linear-gradient(to bottom, #3399ff 0 8px, transparent 8px 16px) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -506,5 +506,43 @@
|
||||
"🎉 Optimized: Refactored components, Perfectly supports Nuxt3, TailwindCSS and other frameworks",
|
||||
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to andrijor@gmail.com for their professional feedback</span>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.9.0",
|
||||
"date": "2026-02-07",
|
||||
"notes": [
|
||||
"🎉 新增:GanttChart支持资源视图模式,通过viewMode属性切换任务视图和资源视图",
|
||||
"🎉 新增:资源视图以资源为行维度展示,支持一行多任务条布局",
|
||||
"🎉 新增:Task对象增加resources字段,支持配置每个资源的投入占比(10%-100%)",
|
||||
"🎉 新增:TaskBar高度按资源占比动态缩放,支持占比文字标注和Tooltip显示",
|
||||
"🎉 新增:基于占比累计的资源超负荷检测和视觉预警",
|
||||
"🎉 新增:TaskDrawer增加资源占比配置控件,支持输入校验",
|
||||
"🎉 新增:资源视图支持任务拖拽、拉伸、计时等交互操作",
|
||||
"🎉 新增:资源列表支持声明式组件配置列",
|
||||
"🎉 新增:Resource数据模型类和useResourceLayout、useViewMode等composables",
|
||||
"🎉 优化:抽取资源布局计算逻辑到独立composable,避免组件职责膨胀",
|
||||
"🎉 优化:扩展v-memo优化覆盖范围,提升渲染性能",
|
||||
"🎉 优化:重构provide/inject依赖传递,简化组件依赖关系",
|
||||
"🎉 优化:恢复滚动防抖机制,平衡响应性和性能",
|
||||
"🎉 优化:历史数据向后兼容,未配置占比时默认为100%",
|
||||
"🔧 修复: 将Theme设置从HTML root变更到Gantt Component root",
|
||||
"<span style=\"font-weight: bold; color: #f00;\">特别感谢a_sunshine@gitee的使用与反馈</span>",
|
||||
"🎉 Added: GanttChart supports resource view mode, switch via viewMode property",
|
||||
"🎉 Added: Resource view displays by resource dimension with multiple task bars per row",
|
||||
"🎉 Added: Task object adds resources field for configuring resource allocation percentage (10%-100%)",
|
||||
"🎉 Added: TaskBar height scales by percentage with text label and Tooltip support",
|
||||
"🎉 Added: Resource overload detection and visual alerts based on percentage accumulation",
|
||||
"🎉 Added: TaskDrawer adds resource percentage configuration with input validation",
|
||||
"🎉 Added: Resource view supports task drag, resize, timing and other interactions",
|
||||
"🎉 Added: Resource list supports declarative component column configuration",
|
||||
"🎉 Added: Resource model class and composables including useResourceLayout, useViewMode",
|
||||
"🎉 Optimized: Extracted resource layout logic to independent composable",
|
||||
"🎉 Optimized: Extended v-memo optimization coverage for better performance",
|
||||
"🎉 Optimized: Refactored provide/inject dependency passing",
|
||||
"🎉 Optimized: Restored scroll debounce mechanism",
|
||||
"🎉 Optimized: Backward compatible with historical data, defaults to 100%",
|
||||
"🔧 Fixed: Changed Theme setting from HTML root to Gantt Component root",
|
||||
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to a_sunshine@gitee for their use and feedback</span>"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, computed, watch } from 'vue'
|
||||
import { GanttChart, TaskListColumn, useI18n, TaskListContextMenu, TaskBarContextMenu } from 'jordium-gantt-vue3'
|
||||
import 'jordium-gantt-vue3/dist/assets/jordium-gantt-vue3.css'
|
||||
|
||||
const { t, getTranslation } = useI18n()
|
||||
const { t, getTranslation } = useI18n();
|
||||
|
||||
// GanttChart ref
|
||||
const ganttRef = ref(null)
|
||||
@@ -114,7 +114,7 @@ const tasks = ref([
|
||||
department: '管理部',
|
||||
departmentCode: 'D001',
|
||||
type: 'task',
|
||||
},
|
||||
}
|
||||
])
|
||||
|
||||
const milestones = ref([
|
||||
@@ -123,8 +123,234 @@ const milestones = ref([
|
||||
name: '项目立项',
|
||||
startDate: '2025-10-29',
|
||||
type: 'milestone',
|
||||
icon: 'diamond',
|
||||
icon: 'diamond'
|
||||
}
|
||||
])
|
||||
|
||||
// 资源数据源 - 按照Resource类型结构定义
|
||||
const resources = ref([
|
||||
{
|
||||
id: 'R001',
|
||||
name: '张三',
|
||||
type: '开发工程师',
|
||||
avatar: 'https://i.pravatar.cc/150?img=1',
|
||||
description: '前端开发专家,擅长Vue.js和React',
|
||||
department: '技术部',
|
||||
skills: ['Vue.js', 'React', 'TypeScript', 'Node.js'],
|
||||
capacity: 85,
|
||||
color: '#1890ff',
|
||||
tasks: [
|
||||
{
|
||||
id: 1000,
|
||||
name: '前端框架搭建',
|
||||
startDate: '2025-11-01',
|
||||
endDate: '2025-11-05',
|
||||
progress: 80,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1001,
|
||||
name: '组件库开发',
|
||||
startDate: '2025-11-06',
|
||||
endDate: '2025-11-15',
|
||||
progress: 50,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1002,
|
||||
name: '页面开发',
|
||||
startDate: '2025-11-16',
|
||||
endDate: '2025-11-25',
|
||||
progress: 20,
|
||||
type: 'task'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'R002',
|
||||
name: '李四',
|
||||
type: '后端工程师',
|
||||
avatar: 'https://i.pravatar.cc/150?img=2',
|
||||
description: 'Java后端开发,熟悉Spring全家桶',
|
||||
department: '技术部',
|
||||
skills: ['Java', 'Spring Boot', 'MySQL', 'Redis'],
|
||||
capacity: 85,
|
||||
color: '#52c41a',
|
||||
tasks: [
|
||||
{
|
||||
id: 1003,
|
||||
name: '数据库设计',
|
||||
startDate: '2025-11-01',
|
||||
endDate: '2025-11-03',
|
||||
progress: 100,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1004,
|
||||
name: 'API接口开发',
|
||||
startDate: '2025-11-04',
|
||||
endDate: '2025-11-12',
|
||||
progress: 60,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1005,
|
||||
name: '性能优化',
|
||||
startDate: '2025-11-20',
|
||||
endDate: '2025-11-28',
|
||||
progress: 0,
|
||||
type: 'task'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'R003',
|
||||
name: '王五',
|
||||
type: 'UI设计师',
|
||||
avatar: 'https://i.pravatar.cc/150?img=3',
|
||||
description: '资深UI/UX设计师',
|
||||
department: '设计部',
|
||||
skills: ['Figma', 'Sketch', 'Photoshop', 'Illustrator'],
|
||||
capacity: 8,
|
||||
color: '#faad14',
|
||||
tasks: [
|
||||
{
|
||||
id: 1006,
|
||||
name: 'UI原型设计',
|
||||
startDate: '2025-10-28',
|
||||
endDate: '2025-11-02',
|
||||
progress: 100,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1007,
|
||||
name: '视觉规范制定',
|
||||
startDate: '2025-11-03',
|
||||
endDate: '2025-11-08',
|
||||
progress: 90,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1008,
|
||||
name: '界面设计',
|
||||
startDate: '2025-11-09',
|
||||
endDate: '2025-11-18',
|
||||
progress: 40,
|
||||
type: 'task'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'R004',
|
||||
name: '赵六',
|
||||
type: '测试工程师',
|
||||
avatar: 'https://i.pravatar.cc/150?img=4',
|
||||
description: '软件测试专家,自动化测试经验丰富',
|
||||
department: '质量部',
|
||||
skills: ['Selenium', 'Jest', 'Cypress', 'JMeter'],
|
||||
capacity: 8,
|
||||
color: '#f5222d',
|
||||
tasks: [
|
||||
{
|
||||
id: 1009,
|
||||
name: '测试计划编写',
|
||||
startDate: '2025-11-10',
|
||||
endDate: '2025-11-12',
|
||||
progress: 70,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1010,
|
||||
name: '自动化测试脚本',
|
||||
startDate: '2025-11-13',
|
||||
endDate: '2025-11-20',
|
||||
progress: 30,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1011,
|
||||
name: '功能测试',
|
||||
startDate: '2025-11-21',
|
||||
endDate: '2025-11-30',
|
||||
progress: 0,
|
||||
type: 'task'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'R005',
|
||||
name: '钱七',
|
||||
type: '产品经理',
|
||||
avatar: 'https://i.pravatar.cc/150?img=5',
|
||||
description: '5年产品经验,擅长用户需求分析',
|
||||
department: '产品部',
|
||||
skills: ['需求分析', 'Axure', 'PRD撰写', '用户研究'],
|
||||
capacity: 8,
|
||||
color: '#722ed1',
|
||||
tasks: [
|
||||
{
|
||||
id: 1012,
|
||||
name: '需求调研',
|
||||
startDate: '2025-10-25',
|
||||
endDate: '2025-10-30',
|
||||
progress: 100,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1013,
|
||||
name: 'PRD文档编写',
|
||||
startDate: '2025-10-31',
|
||||
endDate: '2025-11-05',
|
||||
progress: 85,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1014,
|
||||
name: '产品验收',
|
||||
startDate: '2025-11-25',
|
||||
endDate: '2025-11-30',
|
||||
progress: 0,
|
||||
type: 'task'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'R006',
|
||||
name: '孙八',
|
||||
type: '全栈工程师',
|
||||
avatar: 'https://i.pravatar.cc/150?img=6',
|
||||
description: '全栈开发,前后端通吃',
|
||||
department: '技术部',
|
||||
skills: ['Vue.js', 'Node.js', 'Python', 'Docker'],
|
||||
capacity: 8,
|
||||
color: '#13c2c2',
|
||||
tasks: [
|
||||
{
|
||||
id: 1015,
|
||||
name: '服务器部署',
|
||||
startDate: '2025-11-01',
|
||||
endDate: '2025-11-04',
|
||||
progress: 100,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1016,
|
||||
name: 'CI/CD配置',
|
||||
startDate: '2025-11-05',
|
||||
endDate: '2025-11-10',
|
||||
progress: 75,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1017,
|
||||
name: '微服务架构',
|
||||
startDate: '2025-11-11',
|
||||
endDate: '2025-11-22',
|
||||
progress: 35,
|
||||
type: 'task'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const customMessages = {
|
||||
@@ -139,7 +365,7 @@ const customMessages = {
|
||||
gantt: {
|
||||
planStartDate: '计划开始时间',
|
||||
//planEndDate: '计划结束时间',
|
||||
},
|
||||
}
|
||||
},
|
||||
'en-US': {
|
||||
department: 'Department',
|
||||
@@ -152,16 +378,16 @@ const customMessages = {
|
||||
gantt: {
|
||||
planStartDate: 'Plan Start Date',
|
||||
planEndDate: 'Plan End Date',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
// const tasks = ref([])
|
||||
|
||||
// const milestones = ref([])
|
||||
|
||||
const showAddTaskDrawer = ref(false)
|
||||
const showAddMilestoneDialog = ref(false)
|
||||
const showTodayLocate = ref(true)
|
||||
const showAddTaskDrawer = ref(false);
|
||||
const showAddMilestoneDialog = ref(false);
|
||||
const showTodayLocate = ref(true);
|
||||
|
||||
// 定义可动态配置的列
|
||||
const availableColumns = ref<TaskListColumnConfig[]>([
|
||||
@@ -186,7 +412,7 @@ const taskListConfig = {
|
||||
defaultWidth: '50%', // 默认展开宽度50%
|
||||
minWidth: '300px', // 最小宽度300px(默认280px)
|
||||
maxWidth: '1200px', // 最大宽度1200px(默认1160px)
|
||||
columns: availableColumns.value,
|
||||
columns: availableColumns.value
|
||||
}
|
||||
|
||||
// toolbar配置示例
|
||||
@@ -201,17 +427,18 @@ const toolbarConfig: ToolbarConfig = {
|
||||
showFullscreen: true, // 显示全屏按钮
|
||||
showTimeScale: true, // 显示时间刻度按钮组
|
||||
timeScaleDimensions: [ // 显示所有时间刻度维度
|
||||
'hour', 'day', 'week', 'month', 'quarter', 'year',
|
||||
'hour', 'day', 'week', 'month', 'quarter', 'year'
|
||||
],
|
||||
defaultTimeScale: 'week', // 默认选中周视图
|
||||
showExpandCollapse: false, // 显示展开/折叠按钮
|
||||
showExpandCollapse: false // 显示展开/折叠按钮
|
||||
}
|
||||
|
||||
|
||||
const newTask = ref({
|
||||
name: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
})
|
||||
endDate: ''
|
||||
});
|
||||
|
||||
const addTask = () => {
|
||||
tasks.value.push({
|
||||
@@ -220,10 +447,10 @@ const addTask = () => {
|
||||
startDate: newTask.value.startDate,
|
||||
endDate: newTask.value.endDate,
|
||||
progress: 0,
|
||||
})
|
||||
newTask.value = { name: '', startDate: '', endDate: '' }
|
||||
showAddTaskDrawer.value = false
|
||||
}
|
||||
});
|
||||
newTask.value = { name: '', startDate: '', endDate: '' };
|
||||
showAddTaskDrawer.value = false;
|
||||
};
|
||||
|
||||
const addMilestone = () => {
|
||||
milestones.value.push({
|
||||
@@ -232,11 +459,11 @@ const addMilestone = () => {
|
||||
startDate: newTask.value.startDate,
|
||||
progress: 0,
|
||||
type: 'milestone',
|
||||
icon: 'diamond',
|
||||
})
|
||||
//console.log('milestones: ', milestones.value)
|
||||
newTask.value = { name: '', startDate: '', endDate: '' }
|
||||
showAddMilestoneDialog.value = false
|
||||
icon: 'diamond'
|
||||
});
|
||||
console.log('milestones: ', milestones.value)
|
||||
newTask.value = { name: '', startDate: '', endDate: '' };
|
||||
showAddMilestoneDialog.value = false;
|
||||
}
|
||||
|
||||
const onTaskDblclick = (task) => {
|
||||
@@ -293,18 +520,18 @@ const handleTaskRowMoved = async (payload: {
|
||||
// 此回调仅用于补充业务逻辑,例如根据assignee填充assigneeName等
|
||||
const onTaskAdded = (res) => {
|
||||
// 组件已自动添加任务,这里只需要找到并更新额外字段
|
||||
const addedTask = tasks.value.find(t => t.id === res.task.id)
|
||||
const addedTask = tasks.value.find(t => t.id === res.task.id);
|
||||
|
||||
if (addedTask && addedTask.assignee) {
|
||||
// 根据assignee值查找对应的label并赋值给assigneeName
|
||||
const assigneeOption = assigneeOptions.value.find(option => option.value === addedTask.assignee)
|
||||
const assigneeOption = assigneeOptions.value.find(option => option.value === addedTask.assignee);
|
||||
if (assigneeOption) {
|
||||
addedTask.assigneeName = assigneeOption.label
|
||||
addedTask.assigneeName = assigneeOption.label;
|
||||
}
|
||||
}
|
||||
|
||||
// 不需要手动push,组件已处理
|
||||
}
|
||||
};
|
||||
|
||||
// 自定义右键菜单操作处理
|
||||
const handleCustomMenuAction = (action: string, task: Task, onClose: () => void) => {
|
||||
@@ -315,234 +542,16 @@ const handleCustomMenuAction = (action: string, task: Task, onClose: () => void)
|
||||
<template>
|
||||
<div>
|
||||
<!-- 工具设置面板 -->
|
||||
<div class="tool-settings-panel">
|
||||
<h3>🔧 External Control Demo</h3>
|
||||
|
||||
<!-- 当前状态显示 -->
|
||||
<div class="status-section">
|
||||
<div class="status-item">
|
||||
<span class="status-label">Fullscreen:</span>
|
||||
<span :class="['status-value', { active: fullscreenStatus }]">
|
||||
{{ fullscreenStatus ? '✓ Yes' : '✗ No' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Expand All:</span>
|
||||
<span :class="['status-value', { active: expandStatus }]">
|
||||
{{ expandStatus ? '✓ Yes' : '✗ No' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Locale:</span>
|
||||
<span class="status-value active">{{ currentLocaleStatus }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Time Scale:</span>
|
||||
<span class="status-value active">{{ currentScaleStatus }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Theme:</span>
|
||||
<span class="status-value active">{{ currentThemeStatus }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Control Mode:</span>
|
||||
<span class="status-value active" :style="{ color: controlMode === 'props' ? '#67c23a' : '#409eff' }">
|
||||
{{ controlMode === 'props' ? '📝 Props' : '⚡ Expose' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制模式切换 -->
|
||||
<div class="control-mode-section">
|
||||
<h4>🎛️ Control Mode</h4>
|
||||
<div class="button-group">
|
||||
<button
|
||||
class="mode-button"
|
||||
:class="{ active: controlMode === 'expose' }"
|
||||
@click="controlMode = 'expose'"
|
||||
>
|
||||
⚡ Expose Methods
|
||||
</button>
|
||||
<button
|
||||
class="mode-button"
|
||||
:class="{ active: controlMode === 'props' }"
|
||||
@click="controlMode = 'props'"
|
||||
>
|
||||
📝 Props Control
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expose 方法控制 -->
|
||||
<div v-show="controlMode === 'expose'" class="control-section">
|
||||
<h4>⚡ Expose Methods Control</h4>
|
||||
|
||||
<div class="controls-flow">
|
||||
<div class="control-group">
|
||||
<label>Fullscreen:</label>
|
||||
<button class="control-btn" @click="handleToggleFullscreen">Toggle Fullscreen</button>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Expand All:</label>
|
||||
<button class="control-btn" @click="handleToggleExpandAll">Toggle Expand All</button>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Locale:</label>
|
||||
<div class="button-group">
|
||||
<button class="control-btn" @click="handleSetLocale('zh-CN')">中文</button>
|
||||
<button class="control-btn" @click="handleSetLocale('en-US')">English</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Time Scale:</label>
|
||||
<div class="button-group">
|
||||
<button class="control-btn" @click="handleSetTimeScale('day')">Day</button>
|
||||
<button class="control-btn" @click="handleSetTimeScale('week')">Week</button>
|
||||
<button class="control-btn" @click="handleSetTimeScale('month')">Month</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Theme:</label>
|
||||
<div class="button-group">
|
||||
<button class="control-btn" @click="handleSetTheme('light')">☀️ Light</button>
|
||||
<button class="control-btn" @click="handleSetTheme('dark')">🌙 Dark</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Props 控制 -->
|
||||
<div v-show="controlMode === 'props'" class="control-section">
|
||||
<h4>📝 Props Control</h4>
|
||||
|
||||
<div class="controls-flow">
|
||||
<div class="control-group">
|
||||
<label>Locale Prop:</label>
|
||||
<div class="button-group">
|
||||
<button
|
||||
class="control-btn"
|
||||
:class="{ primary: propsLocale === 'zh-CN' }"
|
||||
@click="propsLocale = 'zh-CN'"
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
<button
|
||||
class="control-btn"
|
||||
:class="{ primary: propsLocale === 'en-US' }"
|
||||
@click="propsLocale = 'en-US'"
|
||||
>
|
||||
English
|
||||
</button>
|
||||
</div>
|
||||
<p class="prop-info">:locale="{{ propsLocale }}"</p>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Theme Prop:</label>
|
||||
<div class="button-group">
|
||||
<button
|
||||
class="control-btn"
|
||||
:class="{ primary: propsTheme === 'light' }"
|
||||
@click="propsTheme = 'light'"
|
||||
>
|
||||
☀️ Light
|
||||
</button>
|
||||
<button
|
||||
class="control-btn"
|
||||
:class="{ primary: propsTheme === 'dark' }"
|
||||
@click="propsTheme = 'dark'"
|
||||
>
|
||||
🌙 Dark
|
||||
</button>
|
||||
</div>
|
||||
<p class="prop-info">:theme="{{ propsTheme }}"</p>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Time Scale Prop:</label>
|
||||
<div class="button-group">
|
||||
<button
|
||||
class="control-btn"
|
||||
:class="{ primary: propsTimeScale === 'day' }"
|
||||
@click="propsTimeScale = 'day'"
|
||||
>
|
||||
Day
|
||||
</button>
|
||||
<button
|
||||
class="control-btn"
|
||||
:class="{ primary: propsTimeScale === 'week' }"
|
||||
@click="propsTimeScale = 'week'"
|
||||
>
|
||||
Week
|
||||
</button>
|
||||
<button
|
||||
class="control-btn"
|
||||
:class="{ primary: propsTimeScale === 'month' }"
|
||||
@click="propsTimeScale = 'month'"
|
||||
>
|
||||
Month
|
||||
</button>
|
||||
</div>
|
||||
<p class="prop-info">:time-scale="{{ propsTimeScale }}"</p>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Fullscreen Prop:</label>
|
||||
<div class="button-group">
|
||||
<button
|
||||
class="control-btn"
|
||||
:class="{ primary: propsFullscreen }"
|
||||
@click="propsFullscreen = true"
|
||||
>
|
||||
✓ True
|
||||
</button>
|
||||
<button
|
||||
class="control-btn"
|
||||
:class="{ primary: !propsFullscreen }"
|
||||
@click="propsFullscreen = false"
|
||||
>
|
||||
✗ False
|
||||
</button>
|
||||
</div>
|
||||
<p class="prop-info">:fullscreen="{{ propsFullscreen }}"</p>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Expand All Prop:</label>
|
||||
<div class="button-group">
|
||||
<button
|
||||
class="control-btn"
|
||||
:class="{ primary: propsExpandAll }"
|
||||
@click="propsExpandAll = true"
|
||||
>
|
||||
✓ True
|
||||
</button>
|
||||
<button
|
||||
class="control-btn"
|
||||
:class="{ primary: !propsExpandAll }"
|
||||
@click="propsExpandAll = false"
|
||||
>
|
||||
✗ False
|
||||
</button>
|
||||
</div>
|
||||
<p class="prop-info">:expand-all="{{ propsExpandAll }}"</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Gantt Chart -->
|
||||
<div style="height: 600px; margin-top: 20px;">
|
||||
<GanttChart
|
||||
ref="ganttRef"
|
||||
:tasks="tasks"
|
||||
:resources="resources"
|
||||
:milestones="milestones"
|
||||
:locale="controlMode === 'props' ? propsLocale : undefined"
|
||||
:theme="controlMode === 'props' ? propsTheme : undefined"
|
||||
:time-scale="controlMode === 'props' ? propsTimeScale : undefined"
|
||||
:fullscreen="controlMode === 'props' ? propsFullscreen : undefined"
|
||||
:expand-all="controlMode === 'props' ? propsExpandAll : undefined"
|
||||
@@ -559,6 +568,10 @@ const handleCustomMenuAction = (action: string, task: Task, onClose: () => void)
|
||||
@task-click="onTaskClick"
|
||||
@milestone-double-click="onMilestoneDblclick"
|
||||
@task-added="onTaskAdded"
|
||||
@some-event="(e) => {
|
||||
console.log('resourceConflicts:', /* 通过 ref 获取 */)
|
||||
console.log('conflictTasks:', /* 检查传递的数据 */)
|
||||
}"
|
||||
>
|
||||
<TaskListColumn prop="name" label="任务名称" width="300">
|
||||
<template #header>
|
||||
@@ -655,8 +668,8 @@ const handleCustomMenuAction = (action: string, task: Task, onClose: () => void)
|
||||
|
||||
<!-- 自定义Dialog组件基于element plus -->
|
||||
<el-dialog
|
||||
v-model="showAddMilestoneDialog"
|
||||
title="自定义添加里程碑组件 - Element Plus"
|
||||
v-model="showAddMilestoneDialog"
|
||||
width="400px"
|
||||
@close="newTask = { name: '', startDate: '', endDate: '' }"
|
||||
>
|
||||
|
||||
@@ -1,287 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { GanttChart, Task } from 'jordium-gantt-vue3'
|
||||
import 'jordium-gantt-vue3/dist/assets/jordium-gantt-vue3.css'
|
||||
|
||||
// GanttChart ref
|
||||
const ganttRef = ref(null)
|
||||
|
||||
// 控制模式:'expose' 使用expose方法,'props' 使用Props
|
||||
const controlMode = ref<'expose' | 'props'>('expose')
|
||||
|
||||
// 状态变量
|
||||
const fullscreenStatus = ref(false)
|
||||
const expandStatus = ref(false)
|
||||
const currentLocaleStatus = ref<'zh-CN' | 'en-US'>('zh-CN')
|
||||
const currentScaleStatus = ref<'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'>('week')
|
||||
const currentThemeStatus = ref<'light' | 'dark'>('light')
|
||||
|
||||
// Props控制变量
|
||||
const propsLocale = ref<'zh-CN' | 'en-US'>('zh-CN')
|
||||
const propsTheme = ref<'light' | 'dark'>('light')
|
||||
const propsTimeScale = ref<'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'>('week')
|
||||
const propsFullscreen = ref(false)
|
||||
const propsExpandAll = ref(false)
|
||||
|
||||
// 监听 Props 变化,同步 status
|
||||
watch(propsLocale, (newLocale) => {
|
||||
currentLocaleStatus.value = newLocale
|
||||
})
|
||||
watch(propsTheme, (newTheme) => {
|
||||
currentThemeStatus.value = newTheme
|
||||
})
|
||||
watch(propsTimeScale, (newScale) => {
|
||||
currentScaleStatus.value = newScale
|
||||
})
|
||||
watch(propsFullscreen, (newFullscreen) => {
|
||||
fullscreenStatus.value = newFullscreen
|
||||
})
|
||||
watch(propsExpandAll, (newExpandAll) => {
|
||||
expandStatus.value = newExpandAll
|
||||
})
|
||||
|
||||
// 更新状态函数
|
||||
const updateStatus = () => {
|
||||
if (ganttRef.value) {
|
||||
fullscreenStatus.value = ganttRef.value.isFullscreen()
|
||||
expandStatus.value = ganttRef.value.isExpandAll()
|
||||
currentLocaleStatus.value = ganttRef.value.currentLocale()
|
||||
currentScaleStatus.value = ganttRef.value.currentScale()
|
||||
currentThemeStatus.value = ganttRef.value.currentTheme()
|
||||
}
|
||||
}
|
||||
|
||||
// Expose 方法处理器
|
||||
const handleToggleFullscreen = () => {
|
||||
ganttRef.value?.toggleFullscreen()
|
||||
updateStatus()
|
||||
propsFullscreen.value = ganttRef.value?.isFullscreen() ?? false
|
||||
}
|
||||
|
||||
const handleToggleExpandAll = () => {
|
||||
ganttRef.value?.toggleExpandAll()
|
||||
updateStatus()
|
||||
propsExpandAll.value = ganttRef.value?.isExpandAll() ?? false
|
||||
}
|
||||
|
||||
const handleSetLocale = (locale: 'zh-CN' | 'en-US') => {
|
||||
ganttRef.value?.setLocale(locale)
|
||||
currentLocaleStatus.value = locale
|
||||
propsLocale.value = locale
|
||||
}
|
||||
|
||||
const handleSetTimeScale = (scale: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year') => {
|
||||
ganttRef.value?.setTimeScale(scale)
|
||||
updateStatus()
|
||||
propsTimeScale.value = scale
|
||||
}
|
||||
|
||||
const handleSetTheme = (mode: 'light' | 'dark') => {
|
||||
ganttRef.value?.setTheme(mode)
|
||||
updateStatus()
|
||||
propsTheme.value = mode
|
||||
}
|
||||
|
||||
// 定义类型接口
|
||||
interface TaskListColumnConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
visible: boolean;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface ToolbarConfig {
|
||||
showAddTask?: boolean;
|
||||
showAddMilestone?: boolean;
|
||||
showTodayLocate?: boolean;
|
||||
showExportCsv?: boolean;
|
||||
showExportPdf?: boolean;
|
||||
showLanguage?: boolean;
|
||||
showTheme?: boolean;
|
||||
showFullscreen?: boolean;
|
||||
showTimeScale?: boolean;
|
||||
timeScaleDimensions?: string[];
|
||||
defaultTimeScale?: string;
|
||||
showExpandCollapse?: boolean;
|
||||
}
|
||||
|
||||
const tasks = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: '项目启动',
|
||||
startDate: '2025-10-30',
|
||||
endDate: '2025-11-5',
|
||||
progress: 100,
|
||||
department: '管理部',
|
||||
departmentCode: 'D001',
|
||||
type: 'task',
|
||||
assignee: '',
|
||||
assigneeName: '',
|
||||
},
|
||||
])
|
||||
|
||||
const milestones = ref([
|
||||
{
|
||||
id: 101,
|
||||
name: '项目立项',
|
||||
startDate: '2025-10-29',
|
||||
type: 'milestone',
|
||||
icon: 'diamond',
|
||||
},
|
||||
])
|
||||
|
||||
const customMessages = {
|
||||
'zh-CN': {
|
||||
department: '部门',
|
||||
departmentCode: '部门编号',
|
||||
},
|
||||
'en-US': {
|
||||
department: 'Department',
|
||||
departmentCode: 'Department Code',
|
||||
},
|
||||
}
|
||||
// const tasks = ref([])
|
||||
|
||||
// const milestones = ref([])
|
||||
const showAddTaskDrawer = ref(false)
|
||||
const showAddMilestoneDialog = ref(false)
|
||||
|
||||
// 定义可动态配置的列
|
||||
const availableColumns = ref<TaskListColumnConfig[]>([
|
||||
{ key: 'startDate', label: '开始日期', visible: true },
|
||||
{ key: 'endDate', label: '结束日期', visible: true },
|
||||
{ key: 'progress', label: '进度', visible: true },
|
||||
{ key: 'department', label: '部门', visible: true, width: 120 },
|
||||
{ key: 'departmentCode', label: '部门编号', visible: true },
|
||||
{ key: 'assigneeName', label: '负责人', visible: true },
|
||||
])
|
||||
|
||||
// 自定义负责人列表
|
||||
const assigneeOptions = ref([
|
||||
{ value: 'zhangsan', label: '张三' },
|
||||
{ value: 'lisi', label: '李四' },
|
||||
{ value: 'wangwu', label: '王五' },
|
||||
])
|
||||
|
||||
// TaskList宽度配置示例
|
||||
const taskListConfig = {
|
||||
defaultWidth: '50%', // 默认展开宽度50%
|
||||
minWidth: '300px', // 最小宽度300px(默认280px)
|
||||
maxWidth: '1200px', // 最大宽度1200px(默认1160px)
|
||||
columns: availableColumns.value,
|
||||
}
|
||||
|
||||
// toolbar配置示例
|
||||
const toolbarConfig: ToolbarConfig = {
|
||||
showAddTask: true, // 显示添加任务按钮
|
||||
showAddMilestone: true, // 显示添加里程碑按钮
|
||||
showTodayLocate: true, // 显示定位到今天按钮
|
||||
showExportCsv: true, // 显示导出CSV按钮
|
||||
showExportPdf: true, // 显示导出PDF按钮
|
||||
showLanguage: true, // 显示语言切换按钮
|
||||
showTheme: true, // 显示主题切换按钮
|
||||
showFullscreen: true, // 显示全屏按钮
|
||||
showTimeScale: true, // 显示时间刻度按钮组
|
||||
timeScaleDimensions: [ // 显示所有时间刻度维度
|
||||
'hour', 'day', 'week', 'month', 'quarter', 'year',
|
||||
],
|
||||
defaultTimeScale: 'week', // 默认选中周视图
|
||||
showExpandCollapse: false, // 显示展开/折叠按钮
|
||||
}
|
||||
|
||||
const newTask = ref({
|
||||
name: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
})
|
||||
|
||||
const addTask = () => {
|
||||
tasks.value.push({
|
||||
id: tasks.value.length + 1,
|
||||
name: newTask.value.name,
|
||||
startDate: newTask.value.startDate,
|
||||
endDate: newTask.value.endDate,
|
||||
progress: 0,
|
||||
department: '未分配',
|
||||
departmentCode: 'D000',
|
||||
assignee: '',
|
||||
assigneeName: '',
|
||||
type: 'task',
|
||||
})
|
||||
newTask.value = { name: '', startDate: '', endDate: '' }
|
||||
showAddTaskDrawer.value = false
|
||||
}
|
||||
|
||||
const addMilestone = () => {
|
||||
milestones.value.push({
|
||||
id: milestones.value.length + 101,
|
||||
name: newTask.value.name,
|
||||
startDate: newTask.value.startDate,
|
||||
type: 'milestone',
|
||||
icon: 'diamond',
|
||||
})
|
||||
//console.log('milestones: ', milestones.value)
|
||||
newTask.value = { name: '', startDate: '', endDate: '' }
|
||||
showAddMilestoneDialog.value = false
|
||||
}
|
||||
|
||||
const onTaskDblclick = (task: any) => {
|
||||
alert(`双击任务: ${task.name}`)
|
||||
}
|
||||
const onTaskClick = (task: any) => {
|
||||
alert(`单击任务: ${task.name}`)
|
||||
}
|
||||
const onMilestoneDblclick = (milestone: any) => {
|
||||
alert(`双击里程碑: ${milestone.name}`)
|
||||
}
|
||||
|
||||
// 任务行拖拽完成事件
|
||||
const handleTaskRowMoved = (payload: {
|
||||
draggedTask: Task
|
||||
targetTask: Task
|
||||
position: 'after' | 'child'
|
||||
}) => {
|
||||
const { draggedTask, targetTask, position } = payload
|
||||
|
||||
alert(`任务 [${draggedTask.name}] 被拖拽到任务 [${targetTask.name}] ${position === 'after' ? '之后' : '下方作为子任务'}`)
|
||||
|
||||
// 组件已自动更新任务的层级关系和位置
|
||||
// position === 'after': 任务被放置在目标任务之后(同级)
|
||||
// position === 'child': 任务被放置为目标任务的子任务(第一个子任务位置)
|
||||
|
||||
// 这里可以:
|
||||
// 1. 显示确认对话框,让用户确认是否移动
|
||||
// 2. 调用后端 API 保存新的任务层级关系
|
||||
// 3. 更新相关的依赖关系
|
||||
|
||||
// 示例:调用后端 API
|
||||
// await api.updateTaskHierarchy({
|
||||
// taskId: draggedTask.id,
|
||||
// targetTaskId: targetTask.id,
|
||||
// position: position
|
||||
// })
|
||||
}
|
||||
|
||||
// 任务添加后回调
|
||||
const onTaskAdded = (res) => {
|
||||
const addedTask = tasks.value.find(t => t.id === res.task.id)
|
||||
if (addedTask) {
|
||||
// 使用addedTask.assignee去查找assigneeOptions的label进行赋值
|
||||
const assigneeOption = assigneeOptions.value.find(option => option.value === addedTask.assignee)
|
||||
if (assigneeOption) {
|
||||
addedTask.assigneeName = assigneeOption.label
|
||||
}
|
||||
} else {
|
||||
// 使用addedTask.assignee去查找assigneeOptions的label进行赋值
|
||||
const assigneeOption = assigneeOptions.value.find(option => option.value === res.task.assignee)
|
||||
if (assigneeOption) {
|
||||
res.task.assigneeName = assigneeOption.label
|
||||
}
|
||||
tasks.value.push(res.task)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 工具设置面板 -->
|
||||
@@ -511,6 +227,7 @@ const onTaskAdded = (res) => {
|
||||
ref="ganttRef"
|
||||
:tasks="tasks"
|
||||
:milestones="milestones"
|
||||
:resources="resources"
|
||||
:locale="controlMode === 'props' ? propsLocale : undefined"
|
||||
:theme="controlMode === 'props' ? propsTheme : undefined"
|
||||
:time-scale="controlMode === 'props' ? propsTimeScale : undefined"
|
||||
@@ -574,8 +291,8 @@ const onTaskAdded = (res) => {
|
||||
|
||||
<!-- 自定义Dialog组件基于element plus -->
|
||||
<el-dialog
|
||||
v-model="showAddMilestoneDialog"
|
||||
title="自定义添加里程碑组件 - Element Plus"
|
||||
v-model="showAddMilestoneDialog"
|
||||
width="400px"
|
||||
@close="newTask = { name: '', startDate: '', endDate: '' }"
|
||||
>
|
||||
@@ -599,6 +316,537 @@ const onTaskAdded = (res) => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { GanttChart, Task } from 'jordium-gantt-vue3'
|
||||
import 'jordium-gantt-vue3/dist/assets/jordium-gantt-vue3.css'
|
||||
|
||||
// GanttChart ref
|
||||
const ganttRef = ref(null)
|
||||
|
||||
// 控制模式:'expose' 使用expose方法,'props' 使用Props
|
||||
const controlMode = ref<'expose' | 'props'>('expose')
|
||||
|
||||
// 状态变量
|
||||
const fullscreenStatus = ref(false)
|
||||
const expandStatus = ref(false)
|
||||
const currentLocaleStatus = ref<'zh-CN' | 'en-US'>('zh-CN')
|
||||
const currentScaleStatus = ref<'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'>('week')
|
||||
const currentThemeStatus = ref<'light' | 'dark'>('light')
|
||||
|
||||
// Props控制变量
|
||||
const propsLocale = ref<'zh-CN' | 'en-US'>('zh-CN')
|
||||
const propsTheme = ref<'light' | 'dark'>('light')
|
||||
const propsTimeScale = ref<'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'>('week')
|
||||
const propsFullscreen = ref(false)
|
||||
const propsExpandAll = ref(false)
|
||||
|
||||
// 监听 Props 变化,同步 status
|
||||
watch(propsLocale, (newLocale) => {
|
||||
currentLocaleStatus.value = newLocale
|
||||
})
|
||||
watch(propsTheme, (newTheme) => {
|
||||
currentThemeStatus.value = newTheme
|
||||
})
|
||||
watch(propsTimeScale, (newScale) => {
|
||||
currentScaleStatus.value = newScale
|
||||
})
|
||||
watch(propsFullscreen, (newFullscreen) => {
|
||||
fullscreenStatus.value = newFullscreen
|
||||
})
|
||||
watch(propsExpandAll, (newExpandAll) => {
|
||||
expandStatus.value = newExpandAll
|
||||
})
|
||||
|
||||
// 更新状态函数
|
||||
const updateStatus = () => {
|
||||
if (ganttRef.value) {
|
||||
fullscreenStatus.value = ganttRef.value.isFullscreen()
|
||||
expandStatus.value = ganttRef.value.isExpandAll()
|
||||
currentLocaleStatus.value = ganttRef.value.currentLocale()
|
||||
currentScaleStatus.value = ganttRef.value.currentScale()
|
||||
currentThemeStatus.value = ganttRef.value.currentTheme()
|
||||
}
|
||||
}
|
||||
|
||||
// Expose 方法处理器
|
||||
const handleToggleFullscreen = () => {
|
||||
ganttRef.value?.toggleFullscreen()
|
||||
updateStatus()
|
||||
propsFullscreen.value = ganttRef.value?.isFullscreen() ?? false
|
||||
}
|
||||
|
||||
const handleToggleExpandAll = () => {
|
||||
ganttRef.value?.toggleExpandAll()
|
||||
updateStatus()
|
||||
propsExpandAll.value = ganttRef.value?.isExpandAll() ?? false
|
||||
}
|
||||
|
||||
const handleSetLocale = (locale: 'zh-CN' | 'en-US') => {
|
||||
ganttRef.value?.setLocale(locale)
|
||||
currentLocaleStatus.value = locale
|
||||
propsLocale.value = locale
|
||||
}
|
||||
|
||||
const handleSetTimeScale = (scale: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year') => {
|
||||
ganttRef.value?.setTimeScale(scale)
|
||||
updateStatus()
|
||||
propsTimeScale.value = scale
|
||||
}
|
||||
|
||||
const handleSetTheme = (mode: 'light' | 'dark') => {
|
||||
ganttRef.value?.setTheme(mode)
|
||||
updateStatus()
|
||||
propsTheme.value = mode
|
||||
}
|
||||
|
||||
// 定义类型接口
|
||||
interface TaskListColumnConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
visible: boolean;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface ToolbarConfig {
|
||||
showAddTask?: boolean;
|
||||
showAddMilestone?: boolean;
|
||||
showTodayLocate?: boolean;
|
||||
showExportCsv?: boolean;
|
||||
showExportPdf?: boolean;
|
||||
showLanguage?: boolean;
|
||||
showTheme?: boolean;
|
||||
showFullscreen?: boolean;
|
||||
showTimeScale?: boolean;
|
||||
timeScaleDimensions?: string[];
|
||||
defaultTimeScale?: string;
|
||||
showExpandCollapse?: boolean;
|
||||
}
|
||||
|
||||
const tasks = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: '项目启动',
|
||||
startDate: '2025-10-30',
|
||||
endDate: '2025-11-5',
|
||||
progress: 100,
|
||||
department: '管理部',
|
||||
departmentCode: 'D001',
|
||||
type: 'task',
|
||||
assignee: '',
|
||||
assigneeName: ''
|
||||
}
|
||||
])
|
||||
|
||||
const milestones = ref([
|
||||
{
|
||||
id: 101,
|
||||
name: '项目立项',
|
||||
startDate: '2025-10-29',
|
||||
type: 'milestone',
|
||||
icon: 'diamond'
|
||||
}
|
||||
])
|
||||
|
||||
// 资源数据源 - 按照Resource类型结构定义
|
||||
const resources = ref([
|
||||
{
|
||||
id: 'R001',
|
||||
name: '张三',
|
||||
type: '开发工程师',
|
||||
avatar: 'https://i.pravatar.cc/150?img=1',
|
||||
description: '前端开发专家,擅长Vue.js和React',
|
||||
department: '技术部',
|
||||
skills: ['Vue.js', 'React', 'TypeScript', 'Node.js'],
|
||||
capacity: 85,
|
||||
color: '#1890ff',
|
||||
tasks: [
|
||||
{
|
||||
id: 1000,
|
||||
name: '前端框架搭建',
|
||||
startDate: '2025-11-01',
|
||||
endDate: '2025-11-05',
|
||||
progress: 80,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1001,
|
||||
name: '组件库开发',
|
||||
startDate: '2025-11-06',
|
||||
endDate: '2025-11-15',
|
||||
progress: 50,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1002,
|
||||
name: '页面开发',
|
||||
startDate: '2025-11-16',
|
||||
endDate: '2025-11-25',
|
||||
progress: 20,
|
||||
type: 'task'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'R002',
|
||||
name: '李四',
|
||||
type: '后端工程师',
|
||||
avatar: 'https://i.pravatar.cc/150?img=2',
|
||||
description: 'Java后端开发,熟悉Spring全家桶',
|
||||
department: '技术部',
|
||||
skills: ['Java', 'Spring Boot', 'MySQL', 'Redis'],
|
||||
capacity: 85,
|
||||
color: '#52c41a',
|
||||
tasks: [
|
||||
{
|
||||
id: 1003,
|
||||
name: '数据库设计',
|
||||
startDate: '2025-11-01',
|
||||
endDate: '2025-11-03',
|
||||
progress: 100,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1004,
|
||||
name: 'API接口开发',
|
||||
startDate: '2025-11-04',
|
||||
endDate: '2025-11-12',
|
||||
progress: 60,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1005,
|
||||
name: '性能优化',
|
||||
startDate: '2025-11-20',
|
||||
endDate: '2025-11-28',
|
||||
progress: 0,
|
||||
type: 'task'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'R003',
|
||||
name: '王五',
|
||||
type: 'UI设计师',
|
||||
avatar: 'https://i.pravatar.cc/150?img=3',
|
||||
description: '资深UI/UX设计师',
|
||||
department: '设计部',
|
||||
skills: ['Figma', 'Sketch', 'Photoshop', 'Illustrator'],
|
||||
capacity: 8,
|
||||
color: '#faad14',
|
||||
tasks: [
|
||||
{
|
||||
id: 1006,
|
||||
name: 'UI原型设计',
|
||||
startDate: '2025-10-28',
|
||||
endDate: '2025-11-02',
|
||||
progress: 100,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1007,
|
||||
name: '视觉规范制定',
|
||||
startDate: '2025-11-03',
|
||||
endDate: '2025-11-08',
|
||||
progress: 90,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1008,
|
||||
name: '界面设计',
|
||||
startDate: '2025-11-09',
|
||||
endDate: '2025-11-18',
|
||||
progress: 40,
|
||||
type: 'task'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'R004',
|
||||
name: '赵六',
|
||||
type: '测试工程师',
|
||||
avatar: 'https://i.pravatar.cc/150?img=4',
|
||||
description: '软件测试专家,自动化测试经验丰富',
|
||||
department: '质量部',
|
||||
skills: ['Selenium', 'Jest', 'Cypress', 'JMeter'],
|
||||
capacity: 8,
|
||||
color: '#f5222d',
|
||||
tasks: [
|
||||
{
|
||||
id: 1009,
|
||||
name: '测试计划编写',
|
||||
startDate: '2025-11-10',
|
||||
endDate: '2025-11-12',
|
||||
progress: 70,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1010,
|
||||
name: '自动化测试脚本',
|
||||
startDate: '2025-11-13',
|
||||
endDate: '2025-11-20',
|
||||
progress: 30,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1011,
|
||||
name: '功能测试',
|
||||
startDate: '2025-11-21',
|
||||
endDate: '2025-11-30',
|
||||
progress: 0,
|
||||
type: 'task'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'R005',
|
||||
name: '钱七',
|
||||
type: '产品经理',
|
||||
avatar: 'https://i.pravatar.cc/150?img=5',
|
||||
description: '5年产品经验,擅长用户需求分析',
|
||||
department: '产品部',
|
||||
skills: ['需求分析', 'Axure', 'PRD撰写', '用户研究'],
|
||||
capacity: 8,
|
||||
color: '#722ed1',
|
||||
tasks: [
|
||||
{
|
||||
id: 1012,
|
||||
name: '需求调研',
|
||||
startDate: '2025-10-25',
|
||||
endDate: '2025-10-30',
|
||||
progress: 100,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1013,
|
||||
name: 'PRD文档编写',
|
||||
startDate: '2025-10-31',
|
||||
endDate: '2025-11-05',
|
||||
progress: 85,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1014,
|
||||
name: '产品验收',
|
||||
startDate: '2025-11-25',
|
||||
endDate: '2025-11-30',
|
||||
progress: 0,
|
||||
type: 'task'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'R006',
|
||||
name: '孙八',
|
||||
type: '全栈工程师',
|
||||
avatar: 'https://i.pravatar.cc/150?img=6',
|
||||
description: '全栈开发,前后端通吃',
|
||||
department: '技术部',
|
||||
skills: ['Vue.js', 'Node.js', 'Python', 'Docker'],
|
||||
capacity: 8,
|
||||
color: '#13c2c2',
|
||||
tasks: [
|
||||
{
|
||||
id: 1015,
|
||||
name: '服务器部署',
|
||||
startDate: '2025-11-01',
|
||||
endDate: '2025-11-04',
|
||||
progress: 100,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1016,
|
||||
name: 'CI/CD配置',
|
||||
startDate: '2025-11-05',
|
||||
endDate: '2025-11-10',
|
||||
progress: 75,
|
||||
type: 'task'
|
||||
},
|
||||
{
|
||||
id: 1017,
|
||||
name: '微服务架构',
|
||||
startDate: '2025-11-11',
|
||||
endDate: '2025-11-22',
|
||||
progress: 35,
|
||||
type: 'task'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const customMessages = {
|
||||
'zh-CN': {
|
||||
department: '部门',
|
||||
departmentCode: '部门编号',
|
||||
resourceView: {
|
||||
desc: '资源视图',
|
||||
capacity: '利用率',
|
||||
overloaded: '超负荷',
|
||||
duration: '时间周期',
|
||||
overloadWarning: '资源超负荷警告',
|
||||
conflictDuration: '冲突时段',
|
||||
conflictWith: '与',
|
||||
conflictSuffix: '冲突',
|
||||
},
|
||||
},
|
||||
'en-US': {
|
||||
department: 'Department',
|
||||
departmentCode: 'Department Code',
|
||||
resourceView: {
|
||||
desc: 'Resource View',
|
||||
capacity: 'capacity',
|
||||
overloaded: 'overLoaded',
|
||||
duration: 'duration',
|
||||
overloadWarning: 'Overload Warning',
|
||||
conflictDuration: 'conflict duration',
|
||||
conflictWith: 'conflict with',
|
||||
conflictSuffix: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
// const tasks = ref([])
|
||||
|
||||
// const milestones = ref([])
|
||||
const showAddTaskDrawer = ref(false);
|
||||
const showAddMilestoneDialog = ref(false);
|
||||
|
||||
// 定义可动态配置的列
|
||||
const availableColumns = ref<TaskListColumnConfig[]>([
|
||||
{ key: 'startDate', label: '开始日期', visible: true },
|
||||
{ key: 'endDate', label: '结束日期', visible: true },
|
||||
{ key: 'progress', label: '进度', visible: true },
|
||||
{ key: 'department', label: '部门', visible: true, width: 120 },
|
||||
{ key: 'departmentCode', label: '部门编号', visible: true },
|
||||
{ key: 'assigneeName', label: '负责人', visible: true },
|
||||
])
|
||||
|
||||
// 自定义负责人列表
|
||||
const assigneeOptions = ref([
|
||||
{ value: 'zhangsan', label: '张三' },
|
||||
{ value: 'lisi', label: '李四' },
|
||||
{ value: 'wangwu', label: '王五' },
|
||||
])
|
||||
|
||||
// TaskList宽度配置示例
|
||||
const taskListConfig = {
|
||||
defaultWidth: '50%', // 默认展开宽度50%
|
||||
minWidth: '300px', // 最小宽度300px(默认280px)
|
||||
maxWidth: '1200px', // 最大宽度1200px(默认1160px)
|
||||
columns: availableColumns.value
|
||||
}
|
||||
|
||||
// toolbar配置示例
|
||||
const toolbarConfig: ToolbarConfig = {
|
||||
showAddTask: true, // 显示添加任务按钮
|
||||
showAddMilestone: true, // 显示添加里程碑按钮
|
||||
showTodayLocate: true, // 显示定位到今天按钮
|
||||
showExportCsv: true, // 显示导出CSV按钮
|
||||
showExportPdf: true, // 显示导出PDF按钮
|
||||
showLanguage: true, // 显示语言切换按钮
|
||||
showTheme: true, // 显示主题切换按钮
|
||||
showFullscreen: true, // 显示全屏按钮
|
||||
showTimeScale: true, // 显示时间刻度按钮组
|
||||
timeScaleDimensions: [ // 显示所有时间刻度维度
|
||||
'hour', 'day', 'week', 'month', 'quarter', 'year'
|
||||
],
|
||||
defaultTimeScale: 'week', // 默认选中周视图
|
||||
showExpandCollapse: false // 显示展开/折叠按钮
|
||||
}
|
||||
|
||||
|
||||
const newTask = ref({
|
||||
name: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
});
|
||||
|
||||
const addTask = () => {
|
||||
tasks.value.push({
|
||||
id: tasks.value.length + 1,
|
||||
name: newTask.value.name,
|
||||
startDate: newTask.value.startDate,
|
||||
endDate: newTask.value.endDate,
|
||||
progress: 0,
|
||||
department: '未分配',
|
||||
departmentCode: 'D000',
|
||||
assignee: '',
|
||||
assigneeName: '',
|
||||
type: 'task',
|
||||
});
|
||||
newTask.value = { name: '', startDate: '', endDate: '' };
|
||||
showAddTaskDrawer.value = false;
|
||||
};
|
||||
|
||||
const addMilestone = () => {
|
||||
milestones.value.push({
|
||||
id: milestones.value.length + 101,
|
||||
name: newTask.value.name,
|
||||
startDate: newTask.value.startDate,
|
||||
type: 'milestone',
|
||||
icon: 'diamond'
|
||||
});
|
||||
console.log('milestones: ', milestones.value)
|
||||
newTask.value = { name: '', startDate: '', endDate: '' };
|
||||
showAddMilestoneDialog.value = false;
|
||||
}
|
||||
|
||||
const onTaskDblclick = (task: any) => {
|
||||
alert(`双击任务: ${task.name}`)
|
||||
}
|
||||
const onTaskClick = (task: any) => {
|
||||
alert(`单击任务: ${task.name}`)
|
||||
}
|
||||
const onMilestoneDblclick = (milestone: any) => {
|
||||
alert(`双击里程碑: ${milestone.name}`)
|
||||
}
|
||||
|
||||
// 任务行拖拽完成事件
|
||||
const handleTaskRowMoved = (payload: {
|
||||
draggedTask: Task
|
||||
targetTask: Task
|
||||
position: 'after' | 'child'
|
||||
}) => {
|
||||
const { draggedTask, targetTask, position } = payload
|
||||
|
||||
alert(`任务 [${draggedTask.name}] 被拖拽到任务 [${targetTask.name}] ${position === 'after' ? '之后' : '下方作为子任务'}`)
|
||||
|
||||
// 组件已自动更新任务的层级关系和位置
|
||||
// position === 'after': 任务被放置在目标任务之后(同级)
|
||||
// position === 'child': 任务被放置为目标任务的子任务(第一个子任务位置)
|
||||
|
||||
// 这里可以:
|
||||
// 1. 显示确认对话框,让用户确认是否移动
|
||||
// 2. 调用后端 API 保存新的任务层级关系
|
||||
// 3. 更新相关的依赖关系
|
||||
|
||||
// 示例:调用后端 API
|
||||
// await api.updateTaskHierarchy({
|
||||
// taskId: draggedTask.id,
|
||||
// targetTaskId: targetTask.id,
|
||||
// position: position
|
||||
// })
|
||||
}
|
||||
|
||||
// 任务添加后回调
|
||||
const onTaskAdded = (res) => {
|
||||
const addedTask = tasks.value.find(t => t.id === res.task.id);
|
||||
if (addedTask) {
|
||||
// 使用addedTask.assignee去查找assigneeOptions的label进行赋值
|
||||
const assigneeOption = assigneeOptions.value.find(option => option.value === addedTask.assignee);
|
||||
if (assigneeOption) {
|
||||
addedTask.assigneeName = assigneeOption.label;
|
||||
}
|
||||
} else {
|
||||
// 使用addedTask.assignee去查找assigneeOptions的label进行赋值
|
||||
const assigneeOption = assigneeOptions.value.find(option => option.value === res.task.assignee);
|
||||
if (assigneeOption) {
|
||||
res.task.assigneeName = assigneeOption.label;
|
||||
}
|
||||
tasks.value.push(res.task);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 工具设置面板 */
|
||||
.tool-settings-panel {
|
||||
|
||||
31
package-lock.json
generated
31
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jordium-gantt-vue3",
|
||||
"version": "1.7.0",
|
||||
"version": "1.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "jordium-gantt-vue3",
|
||||
"version": "1.7.0",
|
||||
"version": "1.9.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -1118,8 +1118,9 @@
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
@@ -2136,9 +2137,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.6.tgz",
|
||||
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -3131,9 +3133,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.0.0.tgz",
|
||||
"integrity": "sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz",
|
||||
"integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
@@ -3143,7 +3145,7 @@
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"dompurify": "^3.3.1",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
@@ -3185,10 +3187,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jordium-gantt-vue3",
|
||||
"version": "1.8.1",
|
||||
"version": "1.9.0",
|
||||
"type": "module",
|
||||
"main": "npm-package/dist/jordium-gantt-vue3.cjs.js",
|
||||
"module": "npm-package/dist/jordium-gantt-vue3.es.js",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted, onMounted, computed, watch, nextTick, useSlots, provide } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
import TaskList from './TaskList/TaskList.vue'
|
||||
@@ -14,7 +14,7 @@ import jsPDF from 'jspdf'
|
||||
import html2canvas from 'html2canvas'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
import type { Milestone } from '../models/classes/Milestone'
|
||||
import type { Resource } from '../models/types/Resource'
|
||||
import type { Resource } from '../models/classes/Resource'
|
||||
import { useTaskListContextMenu } from './TaskList/composables/taskList/useTaskListContextMenu'
|
||||
import { useTaskBarContextMenu } from './Timeline/composables/useTaskBarContextMenu'
|
||||
import type { ToolbarConfig } from '../models/configs/ToolbarConfig'
|
||||
@@ -31,9 +31,6 @@ import { TimelineScale, SCALE_CONFIGS } from '../models/types/TimelineScale'
|
||||
import { detectConflicts } from '../utils/conflictUtils'
|
||||
import { useMessage } from '../composables/useMessage'
|
||||
|
||||
// 根元素引用
|
||||
const ganttRootRef = ref<HTMLElement>()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
tasks: () => [],
|
||||
milestones: () => [],
|
||||
@@ -114,6 +111,9 @@ const emit = defineEmits([
|
||||
'resource-drag-end', // v1.9.0 资源视图垂直拖拽结束事件
|
||||
])
|
||||
|
||||
// 根元素引用
|
||||
const ganttRootRef = ref<HTMLElement>()
|
||||
|
||||
const { showMessage } = useMessage()
|
||||
const slots = useSlots()
|
||||
|
||||
@@ -736,6 +736,17 @@ watch(
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// v1.9.9 监听props.resources变化,自动触发Timeline更新
|
||||
// 这对于资源视图下的TaskBar跨资源拖拽很重要
|
||||
watch(
|
||||
() => props.resources,
|
||||
() => {
|
||||
// props变化时清空增量追踪,执行全量更新
|
||||
triggerFullUpdate()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 时间刻度状态
|
||||
const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
|
||||
|
||||
@@ -766,6 +777,8 @@ const showCloseButton = computed(() => {
|
||||
return taskId !== null && taskId !== undefined
|
||||
})
|
||||
|
||||
// v1.9.7 bugfix: 修复拖拽TaskBar后不必要地触发updateTimeScale的问题
|
||||
// 只在Timeline首次挂载时初始化timeScale,避免在updateTaskTrigger变化时重复调用
|
||||
watch(
|
||||
() => timelineRef.value,
|
||||
newTimeline => {
|
||||
@@ -773,6 +786,7 @@ watch(
|
||||
newTimeline.updateTimeScale(currentTimeScale.value)
|
||||
}
|
||||
},
|
||||
{ once: true }, // 只执行一次,避免不必要的重复调用
|
||||
)
|
||||
|
||||
const dragging = ref(false)
|
||||
@@ -840,6 +854,10 @@ function onMouseDown(e: MouseEvent) {
|
||||
document.addEventListener('wheel', blockAllEvents, { capture: true, passive: false })
|
||||
document.addEventListener('contextmenu', blockAllEvents, { capture: true })
|
||||
|
||||
// v1.9.9 性能优化:使用requestAnimationFrame节流,避免高频更新导致卡顿
|
||||
let rafId: number | null = null
|
||||
let pendingWidth: number | null = null
|
||||
|
||||
function onMouseMove(ev: MouseEvent) {
|
||||
if (!dragging.value) return
|
||||
|
||||
@@ -853,12 +871,35 @@ function onMouseDown(e: MouseEvent) {
|
||||
|
||||
// 直接使用面板宽度限制检查,无需复杂的坐标计算
|
||||
const finalWidth = checkWidthLimits(proposedWidth)
|
||||
leftPanelWidth.value = finalWidth
|
||||
|
||||
// 保存待更新的宽度,使用requestAnimationFrame批量更新
|
||||
pendingWidth = finalWidth
|
||||
|
||||
if (rafId === null) {
|
||||
rafId = requestAnimationFrame(() => {
|
||||
if (pendingWidth !== null) {
|
||||
leftPanelWidth.value = pendingWidth
|
||||
pendingWidth = null
|
||||
}
|
||||
rafId = null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
dragging.value = false
|
||||
|
||||
// 取消待处理的requestAnimationFrame
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
// 如果有待更新的宽度,立即应用
|
||||
if (pendingWidth !== null) {
|
||||
leftPanelWidth.value = pendingWidth
|
||||
pendingWidth = null
|
||||
}
|
||||
}
|
||||
|
||||
// 移除全局事件拦截器
|
||||
document.removeEventListener('mousedown', blockAllEvents, { capture: true })
|
||||
document.removeEventListener('click', blockAllEvents, { capture: true })
|
||||
|
||||
@@ -1423,36 +1423,36 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
/* 暗黑模式下的视图模式切换样式 */
|
||||
:global(html[data-theme='dark']) .gantt-view-mode-control {
|
||||
:global(.gantt-root[data-theme='dark']) .gantt-view-mode-control {
|
||||
background: var(--gantt-bg-secondary, #4b4b4b);
|
||||
border-color: var(--gantt-border-color, #808080);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .gantt-view-mode-control:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .gantt-view-mode-control:hover {
|
||||
border-color: var(--gantt-primary, #3399ff);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .view-mode-thumb {
|
||||
:global(.gantt-root[data-theme='dark']) .view-mode-thumb {
|
||||
background: var(--gantt-primary, #3399ff);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.3),
|
||||
0 1px 6px -1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .view-mode-item {
|
||||
:global(.gantt-root[data-theme='dark']) .view-mode-item {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .view-mode-item:hover:not(.active) {
|
||||
:global(.gantt-root[data-theme='dark']) .view-mode-item:hover:not(.active) {
|
||||
color: var(--gantt-primary, #3399ff);
|
||||
background: rgba(51, 153, 255, 0.12);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .view-mode-item:active:not(.active) {
|
||||
:global(.gantt-root[data-theme='dark']) .view-mode-item:active:not(.active) {
|
||||
background: rgba(51, 153, 255, 0.2);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .view-mode-item.active {
|
||||
:global(.gantt-root[data-theme='dark']) .view-mode-item.active {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,9 @@ const resourcePercent = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// 默认100%(向后兼容)
|
||||
// v1.9.10 注释:默认100%(向后兼容)
|
||||
// 在资源视图中,如果任务没有 resources 字段或未找到匹配的资源分配,
|
||||
// 说明任务隶属于该资源但未明确指定占比,视为全职投入(100%)
|
||||
return 100
|
||||
})
|
||||
|
||||
@@ -95,9 +97,9 @@ const currentResourceName = computed(() => {
|
||||
})
|
||||
|
||||
// v1.9.0 是否显示占比文字(占比<100%时显示)
|
||||
const shouldShowPercentText = computed(() => {
|
||||
return viewMode.value === 'resource' && resourcePercent.value < 100
|
||||
})
|
||||
// const shouldShowPercentText = computed(() => {
|
||||
// return viewMode.value === 'resource' && resourcePercent.value < 100
|
||||
// })
|
||||
|
||||
interface Props {
|
||||
task: Task
|
||||
@@ -356,6 +358,8 @@ const resizeStartLeft = ref(0)
|
||||
|
||||
// v1.9.2 注入Timeline的拖拽状态(用于冲突检测优化)
|
||||
const timelineIsDraggingTaskBar = inject<Ref<boolean>>('isDraggingTaskBar', ref(false))
|
||||
// v1.9.7 注入Timeline容器的拖拽状态(用于禁止TaskBar在Timeline拖拽时被拖拽)
|
||||
const isTimelineDragging = inject<Ref<boolean>>('isDraggingTimeline', ref(false))
|
||||
|
||||
// v1.9.0 拖拽预览效果(资源视图垂直拖拽)
|
||||
const dragPreviewVisible = ref(false)
|
||||
@@ -1095,6 +1099,12 @@ const needsOverflowEffect = computed(() => isWeekView.value && isShortTaskBar.va
|
||||
|
||||
// 鼠标事件处理 - 使用相对位置拖拽方案(带防误触机制)
|
||||
const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-right') => {
|
||||
// v1.9.7 如果Timeline正在被拖拽,禁止TaskBar的拖拽和拉伸,让事件传播到Timeline
|
||||
if (isTimelineDragging.value) {
|
||||
// 不阻止事件传播,让Timeline可以继续滚动
|
||||
return
|
||||
}
|
||||
|
||||
// 如果处于高亮状态,不阻止事件传播,让Timeline可以滚动
|
||||
if (props.isHighlighted || props.isPrimaryHighlight) {
|
||||
// 不调用 e.preventDefault() 和 e.stopPropagation()
|
||||
@@ -1267,7 +1277,7 @@ const dragTooltipContent = ref({ startDate: '', endDate: '' })
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
// 记录最新的鼠标Y位置(用于资源视图垂直拖拽)
|
||||
if (viewMode.value === 'resource') {
|
||||
;(window as any).lastDragMouseY = e.clientY
|
||||
(window as any).lastDragMouseY = e.clientY
|
||||
|
||||
// v1.9.0 检测是否跨行拖拽(基于资源行的实际高度)
|
||||
const timelineBody = document.querySelector('.timeline-body')
|
||||
@@ -1927,6 +1937,9 @@ const handleMouseUp = () => {
|
||||
dragType.value = null
|
||||
tempTaskPixelLeft.value = null // v1.9.0 清除资源视图的像素位置缓存
|
||||
|
||||
// v1.9.7 不需要显式设置timelineIsDraggingTaskBar
|
||||
// 上面的状态重置会自动触发watch,watch会同步timelineIsDraggingTaskBar的值
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
@@ -2732,6 +2745,7 @@ const handleTaskBarMouseLeave = () => {
|
||||
}
|
||||
|
||||
// 监听拖拽/拉伸状态,如果开始拖拽/拉伸,立即隐藏tooltip
|
||||
// v1.9.7 使用 flush: 'sync' 确保状态变化时立即同步执行,避免在资源视图拖拽时因组件更新导致watch延迟执行
|
||||
watch([isDragging, isResizingLeft, isResizingRight], ([dragging, resizingL, resizingR]) => {
|
||||
if (dragging || resizingL || resizingR) {
|
||||
showHoverTooltip.value = false
|
||||
@@ -2746,6 +2760,8 @@ watch([isDragging, isResizingLeft, isResizingRight], ([dragging, resizingL, resi
|
||||
if (timelineIsDraggingTaskBar.value !== isDraggingOrResizing) {
|
||||
timelineIsDraggingTaskBar.value = isDraggingOrResizing
|
||||
}
|
||||
}, {
|
||||
flush: 'sync', // 同步执行,确保状态重置时立即触发
|
||||
})
|
||||
|
||||
// v1.9.2 监听 Tab 悬停状态,当 Tab 悬停时立即隐藏 TaskBar 的 tooltip
|
||||
@@ -3505,9 +3521,9 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
<div v-else class="task-name">
|
||||
{{ task.name }}
|
||||
<!-- v1.9.0 资源视图:显示占比文字 -->
|
||||
<span v-if="shouldShowPercentText" class="resource-capacity-text">
|
||||
<!--<span v-if="shouldShowPercentText" class="resource-capacity-text">
|
||||
{{ resourcePercent }}%
|
||||
</span>
|
||||
</span>-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1819,24 +1819,24 @@ const taskStatus = computed(() => {
|
||||
}
|
||||
|
||||
/* 暗黑模式 */
|
||||
:global(html[data-theme='dark']) .resource-header {
|
||||
:global(.gantt-root[data-theme='dark']) .resource-header {
|
||||
background: var(--gantt-bg-toolbar, rgba(255, 255, 255, 0.03)) !important;
|
||||
border-color: var(--gantt-border-light, rgba(255, 255, 255, 0.1)) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .resource-header-label {
|
||||
:global(.gantt-root[data-theme='dark']) .resource-header-label {
|
||||
color: var(--gantt-text-secondary, #a8a8a8) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .resource-item {
|
||||
:global(.gantt-root[data-theme='dark']) .resource-item {
|
||||
background: var(--gantt-bg-secondary, rgba(255, 255, 255, 0.05)) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .btn-remove-resource:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .btn-remove-resource:hover {
|
||||
background: var(--gantt-danger-dark, rgba(245, 108, 108, 0.2)) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .btn-add-resource:hover {
|
||||
:global(.gantt-root[data-theme='dark']) .btn-add-resource:hover {
|
||||
background: var(--gantt-primary-dark, rgba(64, 158, 255, 0.2)) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, useSlots, computed, inject } from 'vue'
|
||||
import type { StyleValue, Slots, Ref, ComputedRef } from 'vue'
|
||||
import type { StyleValue, Slots } from 'vue'
|
||||
import TaskRow from './taskRow/TaskRow.vue'
|
||||
import { useI18n } from '../../composables/useI18n'
|
||||
import { useViewMode } from '../../composables/useViewMode'
|
||||
import type { Task } from '../../models/classes/Task'
|
||||
import type { Resource } from '../../models/types/Resource'
|
||||
import type { Resource } from '../../models/classes/Resource'
|
||||
import type { TaskListConfig, TaskListColumnConfig } from '../../models/configs/TaskListConfig'
|
||||
// @ts-expect-error - ResourceListColumnConfig is used in type unions
|
||||
import type { ResourceListConfig, ResourceListColumnConfig } from '../../models/configs/ResourceListConfig'
|
||||
import { DEFAULT_TASK_LIST_COLUMNS } from '../../models/configs/TaskListConfig'
|
||||
import { DEFAULT_RESOURCE_LIST_COLUMNS } from '../../models/configs/ResourceListConfig'
|
||||
import { useTaskRowDrag } from '../../composables/useTaskRowDrag'
|
||||
@@ -53,13 +52,8 @@ const emit = defineEmits<{
|
||||
const slots = useSlots()
|
||||
const hasRowSlot = computed(() => Boolean(slots['custom-task-content']))
|
||||
|
||||
// v1.9.0 从 GanttChart 注入视图模式和数据源
|
||||
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
|
||||
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
|
||||
const listConfig = inject<ComputedRef<TaskListConfig | ResourceListConfig | undefined>>(
|
||||
'gantt-list-config',
|
||||
computed(() => undefined),
|
||||
)
|
||||
// v1.9.9 使用useViewMode统一管理视图模式状态
|
||||
const { viewMode, dataSource, listConfig } = useViewMode()
|
||||
|
||||
// 从 GanttChart 注入列级 slots
|
||||
const columnSlots = inject<Slots>('gantt-column-slots', {})
|
||||
@@ -340,8 +334,8 @@ onUnmounted(() => {
|
||||
|
||||
<TaskRow
|
||||
v-for="{ task, level, rowIndex } in visibleTasks"
|
||||
v-memo="[task.id, task.name, task.collapsed, hoveredTaskId === task.id, task.startDate, task.endDate, task.progress]"
|
||||
:key="task.id"
|
||||
v-memo="[task.id, task.name, task.collapsed, hoveredTaskId === task.id, task.startDate, task.endDate, task.progress]"
|
||||
:task="task"
|
||||
:level="level"
|
||||
:row-index="rowIndex"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, computed, inject, type Ref, type ComputedRef } from 'vue'
|
||||
import type { Task } from '../../../../models/classes/Task'
|
||||
import type { Resource } from '../../../../models/types/Resource'
|
||||
import type { Resource } from '../../../../models/classes/Resource'
|
||||
|
||||
/**
|
||||
* TaskList 布局计算逻辑
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* TaskList 容器尺寸管理
|
||||
@@ -21,6 +21,34 @@ export function useTaskListResize(
|
||||
let containerResizeObserver: ResizeObserver | null = null
|
||||
let bodyResizeObserver: ResizeObserver | null = null
|
||||
|
||||
/**
|
||||
* 手动更新容器宽度(用于 Splitter 拖拽结束后)
|
||||
*/
|
||||
const updateContainerWidth = () => {
|
||||
if (taskListRef.value) {
|
||||
const newWidth = taskListRef.value.offsetWidth
|
||||
if (Math.abs(newWidth - cachedContainerWidth.value) > 1) {
|
||||
cachedContainerWidth.value = newWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1.9.9 监听拖拽结束,手动更新尺寸
|
||||
watch(isSplitterDragging, (dragging) => {
|
||||
if (!dragging) {
|
||||
// 拖拽结束后手动更新容器宽度
|
||||
updateContainerWidth()
|
||||
|
||||
// 同时更新body高度
|
||||
if (taskListBodyRef.value) {
|
||||
const newHeight = taskListBodyRef.value.clientHeight
|
||||
if (Math.abs(newHeight - taskListBodyHeight.value) > 1) {
|
||||
taskListBodyHeight.value = newHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 初始化 ResizeObserver
|
||||
*/
|
||||
@@ -46,6 +74,11 @@ export function useTaskListResize(
|
||||
taskListScrollTop.value = taskListBodyRef.value.scrollTop
|
||||
|
||||
bodyResizeObserver = new ResizeObserver(entries => {
|
||||
// v1.9.9 拖拽期间跳过更新,避免影响拖拽性能
|
||||
if (isSplitterDragging.value) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
taskListBodyHeight.value = entry.contentRect.height
|
||||
}
|
||||
@@ -70,18 +103,6 @@ export function useTaskListResize(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动更新容器宽度(用于 Splitter 拖拽结束后)
|
||||
*/
|
||||
const updateContainerWidth = () => {
|
||||
if (taskListRef.value) {
|
||||
const newWidth = taskListRef.value.offsetWidth
|
||||
if (Math.abs(newWidth - cachedContainerWidth.value) > 1) {
|
||||
cachedContainerWidth.value = newWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
taskListRef,
|
||||
taskListBodyRef,
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import { ref, computed, useSlots, toRef, inject, type ComputedRef } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
import { useI18n } from '../../../composables/useI18n'
|
||||
import { useViewMode } from '../../../composables/useViewMode'
|
||||
import { formatPredecessorDisplay } from '../../../utils/predecessorUtils'
|
||||
import type { Task } from '../../../models/classes/Task'
|
||||
import type { Resource } from '../../../models/classes/Resource'
|
||||
import { isResourceOverloaded } from '../../../utils/resourceUtils'
|
||||
import type { TaskListColumnConfig } from '../../../models/configs/TaskListConfig'
|
||||
import type { DeclarativeColumnConfig } from '../composables/taskList/useTaskListColumns'
|
||||
import TaskContextMenu from '../../TaskContextMenu.vue'
|
||||
@@ -21,7 +24,7 @@ interface TaskRowSlotProps {
|
||||
level: number
|
||||
indent: string
|
||||
isHovered: boolean
|
||||
hoveredTaskId: number | null
|
||||
hoveredTaskId: number | string | null
|
||||
isParent: boolean
|
||||
hasChildren: boolean
|
||||
collapsed: boolean
|
||||
@@ -42,8 +45,8 @@ interface Props {
|
||||
level: number
|
||||
rowIndex?: number
|
||||
isHovered?: boolean
|
||||
hoveredTaskId?: number | null
|
||||
onHover?: (taskId: number | null) => void
|
||||
hoveredTaskId?: number | string | null
|
||||
onHover?: (taskId: number | string | null) => void
|
||||
columns: TaskListColumnConfig[]
|
||||
declarativeColumns?: DeclarativeColumnConfig[]
|
||||
renderMode?: 'default' | 'declarative'
|
||||
@@ -82,6 +85,36 @@ const daysText = computed(() => t.value?.days ?? '')
|
||||
const slots = useSlots()
|
||||
const hasContentSlot = computed(() => Boolean(slots['custom-task-content']))
|
||||
|
||||
// v1.9.9 使用useViewMode统一管理视图模式状态
|
||||
const { viewMode } = useViewMode()
|
||||
|
||||
// 从 GanttChart 注入资源布局信息
|
||||
const resourceTaskLayouts = inject<ComputedRef<Map<string, { taskRowMap: Map<string | number, number>, rowHeights: number[], totalHeight: number }>>>('resourceTaskLayouts', computed(() => new Map()))
|
||||
|
||||
// v1.9.0 Detect if current row is a resource
|
||||
const isResourceRow = computed(() => {
|
||||
return viewMode.value === 'resource' && 'tasks' in props.task
|
||||
})
|
||||
|
||||
// v1.9.0 检测资源是否超载(任务重叠)
|
||||
const isResourceOverloadedComputed = computed(() => {
|
||||
if (!isResourceRow.value) return false
|
||||
|
||||
// 类型断言为Resource
|
||||
const resource = props.task as Resource
|
||||
return isResourceOverloaded(resource)
|
||||
})
|
||||
|
||||
// 计算行高度 - resource视图下使用动态高度
|
||||
const rowHeight = computed(() => {
|
||||
if (isResourceRow.value) {
|
||||
const resourceId = String(props.task.id) // 转换为string
|
||||
const layout = resourceTaskLayouts.value.get(resourceId)
|
||||
return layout?.totalHeight || 56 // v1.9.1 默认56px(51 + 5px底部padding)
|
||||
}
|
||||
return 51 // task视图下使用固定高度
|
||||
})
|
||||
|
||||
// 注入右键菜单配置
|
||||
const enableTaskListContextMenu = inject<ComputedRef<boolean>>('enable-task-list-context-menu', computed(() => true))
|
||||
const hasTaskListContextMenuSlot = inject<ComputedRef<boolean>>('task-list-context-menu-slot', computed(() => false))
|
||||
@@ -227,9 +260,16 @@ const slotPayload = computed(() => ({
|
||||
progressClass: progressClass.value,
|
||||
}))
|
||||
|
||||
// 计算左侧边框颜色 - 支持barColor自定义
|
||||
// 计算左侧边框颜色 - 支持barColor/color自定义
|
||||
const leftBorderColor = computed(() => {
|
||||
// 如果设置了barColor,优先使用
|
||||
// 资源视图:优先使用resource.color
|
||||
if (isResourceRow.value) {
|
||||
const resource = props.task as any
|
||||
if (resource.color) {
|
||||
return resource.color
|
||||
}
|
||||
}
|
||||
// 任务视图:使用barColor
|
||||
if (props.task.barColor) {
|
||||
return props.task.barColor
|
||||
}
|
||||
@@ -238,10 +278,12 @@ const leftBorderColor = computed(() => {
|
||||
})
|
||||
|
||||
// 计算自定义边框样式
|
||||
const customBorderStyle = computed(() => {
|
||||
const customBorderStyle = computed((): StyleValue => {
|
||||
if (leftBorderColor.value) {
|
||||
return {
|
||||
borderLeftColor: leftBorderColor.value
|
||||
borderLeftColor: `${leftBorderColor.value} !important` as any,
|
||||
borderLeftWidth: '3px',
|
||||
borderLeftStyle: 'solid' as const,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
@@ -276,7 +318,7 @@ const assigneeDisplayData = computed(() => {
|
||||
return {
|
||||
avatars: displayAvatars,
|
||||
nameText,
|
||||
hasMultiple: displayAvatars.length > 1
|
||||
hasMultiple: displayAvatars.length > 1,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -289,6 +331,7 @@ const assigneeDisplayData = computed(() => {
|
||||
:data-task-id="props.task.id"
|
||||
:class="{
|
||||
'task-row-hovered': isHovered,
|
||||
'task-type-resource': isResourceRow, /* v1.9.0 资源视图始终显示左边框 */
|
||||
'parent-task': isParentTask,
|
||||
'milestone-group-row': isMilestoneGroup,
|
||||
'task-type-story': isStoryTask,
|
||||
@@ -296,7 +339,7 @@ const assigneeDisplayData = computed(() => {
|
||||
'task-type-milestone': isMilestoneTask,
|
||||
[customRowClass]: customRowClass,
|
||||
}"
|
||||
:style="[customRowStyle, customBorderStyle]"
|
||||
:style="[customRowStyle, customBorderStyle, { height: `${rowHeight}px` }]"
|
||||
@click="handleRowClick"
|
||||
@dblclick="handleTaskRowDoubleClick"
|
||||
@mousedown="handleMouseDown"
|
||||
@@ -358,11 +401,40 @@ const assigneeDisplayData = computed(() => {
|
||||
|
||||
<!-- 叶子节点的占位空间(无折叠按钮且无图标显示时) -->
|
||||
<span
|
||||
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false"
|
||||
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false && !isResourceRow"
|
||||
class="leaf-spacer"
|
||||
></span>
|
||||
|
||||
<!-- v1.9.0 资源视图:直接显示资源名称 -->
|
||||
<div v-if="isResourceRow" class="resource-row-name">
|
||||
<!-- v1.9.0 资源超载警示图标 -->
|
||||
<svg
|
||||
v-if="isResourceOverloadedComputed"
|
||||
class="resource-warning-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 2L2 20h20L12 2z"
|
||||
fill="var(--gantt-danger, #f56c6c)"
|
||||
/>
|
||||
<path
|
||||
d="M12 8v6M12 16h.01"
|
||||
stroke="#fff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div v-if="(props.task as any).avatar" class="resource-avatar">
|
||||
<img :src="(props.task as any).avatar" :alt="(props.task as any).name" />
|
||||
</div>
|
||||
<span class="resource-name-text">{{ (props.task as any).name || '-' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 任务视图:正常渲染 -->
|
||||
<TaskRowNameContent
|
||||
v-else
|
||||
:task="props.task"
|
||||
:is-parent-task="isParentTask"
|
||||
:has-children="hasChildren"
|
||||
@@ -417,7 +489,12 @@ const assigneeDisplayData = computed(() => {
|
||||
{{ column.formatter(props.task, column) }}
|
||||
</template>
|
||||
|
||||
<!-- 优先级3: 内置列类型渲染 -->
|
||||
<!-- v1.9.0 资源视图:使用formatter或直接显示资源属性 -->
|
||||
<template v-else-if="isResourceRow">
|
||||
{{ (props.task as any)[column.key] || '-' }}
|
||||
</template>
|
||||
|
||||
<!-- 优先级3: 内置列类型渲染(任务视图) -->
|
||||
<!-- 前置任务列 -->
|
||||
<template v-else-if="column.key === 'predecessor'">
|
||||
{{ formatPredecessorDisplay(props.task.predecessor) }}
|
||||
@@ -534,30 +611,26 @@ const assigneeDisplayData = computed(() => {
|
||||
.task-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--gantt-border-light);
|
||||
height: 51px; /* 修改为51px,与Timeline中的task-row高度保持一致,包含border-bottom 1px */
|
||||
height: 51px; /* v1.9.1 任务视图51px(包含border-bottom 1px),资源视图动态高度(第一行56px,后续行46px) */
|
||||
box-sizing: border-box; /* 确保border包含在高度计算中 */
|
||||
background: var(--gantt-bg-primary);
|
||||
align-items: center;
|
||||
color: var(--gantt-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
transform: scale(1);
|
||||
transform-origin: 5px center; /* 从左侧偏右5px的位置作为放大中心 */
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-row:hover {
|
||||
background-color: var(--gantt-bg-hover);
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.task-row-hovered {
|
||||
background-color: var(--gantt-bg-hover) !important;
|
||||
transform: scale(1.02) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||||
box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15) !important;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
@@ -568,15 +641,13 @@ const assigneeDisplayData = computed(() => {
|
||||
|
||||
.task-row.parent-task:hover {
|
||||
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover));
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.task-row.parent-task.task-row-hovered {
|
||||
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover)) !important;
|
||||
transform: scale(1.02) !important;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2) !important;
|
||||
box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15) !important;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
@@ -588,13 +659,9 @@ const assigneeDisplayData = computed(() => {
|
||||
|
||||
.milestone-group-row:hover {
|
||||
background: linear-gradient(90deg, var(--gantt-bg-hover-parent) 0%, var(--gantt-bg-hover) 100%);
|
||||
transform: scale(1.02);
|
||||
box-shadow:
|
||||
0 6px 16px rgba(245, 108, 108, 0.3),
|
||||
0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15);
|
||||
z-index: 10;
|
||||
border-left-color: var(--gantt-danger, #f56c6c);
|
||||
border-left-width: 4px; /* 悬停时边框稍微加粗 */
|
||||
}
|
||||
|
||||
/* 任务类型左边框颜色 */
|
||||
@@ -610,30 +677,43 @@ const assigneeDisplayData = computed(() => {
|
||||
border-left: 3px solid var(--gantt-danger, #f56c6c);
|
||||
}
|
||||
|
||||
/* 任务类型悬停时保持并增强左边框 */
|
||||
/* v1.9.0 资源类型左边框颜色 */
|
||||
.task-type-resource {
|
||||
border-left: 3px solid var(--gantt-success, #67c23a);
|
||||
}
|
||||
|
||||
/* 任务类型悬停时保持左边框,无需加粗 */
|
||||
.task-type-story:hover {
|
||||
border-left: 5px solid var(--gantt-primary, #409eff);
|
||||
border-left: 3px solid var(--gantt-primary, #409eff);
|
||||
}
|
||||
|
||||
.task-type-task:hover {
|
||||
border-left: 5px solid var(--gantt-warning, #e6a23c);
|
||||
border-left: 3px solid var(--gantt-warning, #e6a23c);
|
||||
}
|
||||
|
||||
.task-type-milestone:hover {
|
||||
border-left: 5px solid var(--gantt-danger, #f56c6c);
|
||||
border-left: 3px solid var(--gantt-danger, #f56c6c);
|
||||
}
|
||||
|
||||
.task-type-resource:hover {
|
||||
border-left: 3px solid var(--gantt-success, #67c23a);
|
||||
}
|
||||
|
||||
/* 悬停状态下的左边框保持 */
|
||||
.task-row-hovered.task-type-story {
|
||||
border-left: 5px solid var(--gantt-primary, #409eff) !important;
|
||||
border-left: 3px solid var(--gantt-primary, #409eff) !important;
|
||||
}
|
||||
|
||||
.task-row-hovered.task-type-task {
|
||||
border-left: 5px solid var(--gantt-warning, #e6a23c) !important;
|
||||
border-left: 3px solid var(--gantt-warning, #e6a23c) !important;
|
||||
}
|
||||
|
||||
.task-row-hovered.task-type-milestone {
|
||||
border-left: 5px solid var(--gantt-danger, #f56c6c) !important;
|
||||
border-left: 3px solid var(--gantt-danger, #f56c6c) !important;
|
||||
}
|
||||
|
||||
.task-row-hovered.task-type-resource {
|
||||
border-left: 3px solid var(--gantt-success, #67c23a) !important;
|
||||
}
|
||||
|
||||
:global(.gantt-root[data-theme='dark']) .milestone-group-row {
|
||||
@@ -653,6 +733,10 @@ const assigneeDisplayData = computed(() => {
|
||||
border-left-color: var(--gantt-danger, #f67c7c);
|
||||
}
|
||||
|
||||
:global(.gantt-root[data-theme='dark']) .task-type-resource {
|
||||
border-left-color: var(--gantt-success, #85ce61);
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background-color: var(--gantt-primary-light);
|
||||
}
|
||||
@@ -787,35 +871,25 @@ const assigneeDisplayData = computed(() => {
|
||||
|
||||
/* 暗黑模式的悬停效果 */
|
||||
:global(.gantt-root[data-theme='dark']) .task-row:hover {
|
||||
box-shadow:
|
||||
0 4px 12px rgba(255, 255, 255, 0.1),
|
||||
0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
:global(.gantt-root[data-theme='dark']) .task-row.task-row-hovered {
|
||||
background-color: var(--gantt-bg-hover) !important;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(255, 255, 255, 0.1),
|
||||
0 2px 8px rgba(0, 0, 0, 0.3) !important;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
:global(.gantt-root[data-theme='dark']) .task-row.parent-task:hover {
|
||||
box-shadow:
|
||||
0 6px 16px rgba(255, 255, 255, 0.15),
|
||||
0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
:global(.gantt-root[data-theme='dark']) .task-row.parent-task.task-row-hovered {
|
||||
background: var(--gantt-bg-hover-parent) !important;
|
||||
box-shadow:
|
||||
0 6px 16px rgba(255, 255, 255, 0.15),
|
||||
0 2px 8px rgba(0, 0, 0, 0.4) !important;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
:global(.gantt-root[data-theme='dark']) .milestone-group-row:hover {
|
||||
box-shadow:
|
||||
0 6px 16px rgba(246, 124, 124, 0.4),
|
||||
0 2px 8px rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* TaskRow拖拽样式 */
|
||||
@@ -835,6 +909,52 @@ const assigneeDisplayData = computed(() => {
|
||||
background-color: rgba(64, 158, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
/* v1.9.0 资源视图行样式 */
|
||||
.resource-row-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--gantt-text-primary);
|
||||
}
|
||||
|
||||
/* v1.9.0 资源超载警示图标 */
|
||||
.resource-warning-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.resource-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resource-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.resource-name-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:global(.gantt-root[data-theme='dark']) .task-row-drop-target.drop-after {
|
||||
background-color: rgba(125, 180, 240, 0.1) !important;
|
||||
}
|
||||
|
||||
@@ -8,16 +8,16 @@ import LinkDragGuide from './LinkDragGuide.vue'
|
||||
import GanttConflicts from './Timeline/GanttConflicts.vue'
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useI18n } from '../composables/useI18n'
|
||||
import { useViewMode } from '../composables/useViewMode' // v1.9.9 视图模式状态管理
|
||||
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
|
||||
import { getPredecessorIds } from '../utils/predecessorUtils'
|
||||
import { perfMonitor } from '../utils/perfMonitor'
|
||||
import { perfMonitor2 } from '../utils/perfMonitor2' // v1.9.6 性能诊断工具
|
||||
import type { Task } from '../models/classes/Task'
|
||||
import type { Resource } from '../models/types/Resource'
|
||||
import type { Resource } from '../models/classes/Resource'
|
||||
import type { Milestone } from '../models/classes/Milestone'
|
||||
import type { TimelineConfig } from '../models/configs/TimelineConfig'
|
||||
import { TimelineScale } from '../models/types/TimelineScale'
|
||||
import { assignTaskRows } from '../utils/taskLayoutUtils' // v1.9.0 换行布局算法
|
||||
import { positionCache } from '../utils/positionCache' // v1.9.6 Phase1 位置计算缓存
|
||||
|
||||
// 定义Props接口
|
||||
@@ -103,9 +103,8 @@ const t = (key: string): string => {
|
||||
return getTranslation(key)
|
||||
}
|
||||
|
||||
// v1.9.0 从 GanttChart 注入视图模式和数据源
|
||||
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
|
||||
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
|
||||
// v1.9.9 使用useViewMode统一管理视图模式状态
|
||||
const { viewMode, dataSource } = useViewMode()
|
||||
|
||||
// v1.9.0 从 GanttChart 注入资源冲突信息(由 GanttChart 计算并响应 updateTaskTrigger)
|
||||
const resourceConflicts = inject<ComputedRef<Map<string, Set<number>>>>('resourceConflicts', computed(() => new Map()))
|
||||
@@ -171,143 +170,16 @@ const isSplitBarDragging = inject<Ref<boolean>>('isSplitBarDragging', ref(false)
|
||||
// v1.9.5 注入showConflicts配置
|
||||
const showConflicts = inject<ComputedRef<boolean>>('gantt-show-conflicts', computed(() => true))
|
||||
|
||||
// v1.9.6 Sprint1(P0) - 布局缓存(用于resourceTaskLayouts按需计算)
|
||||
const layoutCache = new Map<string, {
|
||||
taskRowMap: Map<string | number, number>,
|
||||
rowHeights: number[],
|
||||
totalHeight: number
|
||||
}>()
|
||||
// 纵向虚拟滚动相关状态(需要在useResourceLayout之前定义)
|
||||
const ROW_HEIGHT = 51 // 每行高度51px (50px + 1px border)
|
||||
const VERTICAL_BUFFER = 5 // 纵向缓冲区行数
|
||||
const timelineBodyScrollTop = ref(0) // 纵向滚动位置
|
||||
const timelineBodyHeight = ref(0) // 容器高度状态管理
|
||||
|
||||
// v1.9.6 Phase2 - 计算资源视图的任务行布局(按需计算+缓存优化)
|
||||
// 策略:只计算可见资源的布局,通过缓存避免重复计算
|
||||
let resourceTaskLayoutsCallCount = 0
|
||||
const resourceTaskLayouts = computed(() => {
|
||||
resourceTaskLayoutsCallCount++
|
||||
|
||||
const layoutMap = new Map<string | number, {
|
||||
taskRowMap: Map<string | number, number>,
|
||||
rowHeights: number[],
|
||||
totalHeight: number
|
||||
}>()
|
||||
|
||||
if (viewMode.value !== 'resource') {
|
||||
return layoutMap
|
||||
}
|
||||
|
||||
const resources = dataSource.value as Resource[]
|
||||
|
||||
// ⚠️ 重要:必须计算所有资源的布局,不能只计算可见资源
|
||||
// 因为 visibleTaskRange 的计算依赖于 resourceRowPositions,而 resourceRowPositions 依赖于这里的布局数据
|
||||
// 如果只计算可见资源,会导致循环依赖和布局错误
|
||||
|
||||
// 性能监控
|
||||
let cacheHits = 0
|
||||
let cacheMisses = 0
|
||||
|
||||
// 为所有资源计算布局(使用缓存优化性能)
|
||||
resources.forEach(resource => {
|
||||
// 先检查缓存命中情况
|
||||
const cacheKey = `${resource.id}-${((resource as any).tasks || []).length}`
|
||||
const isCacheHit = layoutCache.has(cacheKey)
|
||||
|
||||
// 获取布局(如果缓存未命中会自动计算并缓存)
|
||||
const layout = getResourceLayout(resource)
|
||||
layoutMap.set(resource.id, layout)
|
||||
|
||||
// 统计缓存命中率
|
||||
if (isCacheHit) {
|
||||
cacheHits++
|
||||
} else {
|
||||
cacheMisses++
|
||||
}
|
||||
})
|
||||
|
||||
return layoutMap
|
||||
})
|
||||
|
||||
// v1.9.6 Sprint1(P0) - 缓存清理策略(保留最近100个条目,防止内存泄漏)
|
||||
watch(dataSource, () => {
|
||||
if (layoutCache.size > 100) {
|
||||
const keysToDelete = Array.from(layoutCache.keys()).slice(0, layoutCache.size - 100)
|
||||
keysToDelete.forEach(key => layoutCache.delete(key))
|
||||
}
|
||||
})
|
||||
|
||||
// v1.9.6 Sprint1(P0) - 获取单个资源的布局(按需计算+缓存)
|
||||
const getResourceLayout = (resource: Resource) => {
|
||||
const resourceTasks = (resource as any).tasks || []
|
||||
|
||||
// 🎯 修复:缓存key需要包含任务的时间信息,因为时间交汇会影响换行布局
|
||||
// 生成任务时间范围的简单哈希(拼接所有任务的id-start-end)
|
||||
const timeHash = resourceTasks
|
||||
.map((t: Task) => `${t.id}-${t.startDate || ''}-${t.endDate || ''}`)
|
||||
.join('|')
|
||||
const cacheKey = `${resource.id}-${resourceTasks.length}-${timeHash}`
|
||||
|
||||
// 检查缓存
|
||||
if (layoutCache.has(cacheKey)) {
|
||||
return layoutCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
// 未命中缓存,重新计算
|
||||
let result
|
||||
if (resourceTasks.length > 0) {
|
||||
result = assignTaskRows(resourceTasks, 51)
|
||||
} else {
|
||||
result = {
|
||||
taskRowMap: new Map(),
|
||||
rowHeights: [51],
|
||||
totalHeight: 51,
|
||||
}
|
||||
}
|
||||
|
||||
layoutCache.set(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// v1.9.6 Phase2 - 计算资源行的累积位置(懒加载优化:只计算到需要的位置)
|
||||
let resourceRowPositionsCallCount = 0
|
||||
const resourceRowPositions = computed(() => {
|
||||
resourceRowPositionsCallCount++
|
||||
const positions = new Map<string | number, number>()
|
||||
|
||||
if (viewMode.value !== 'resource') {
|
||||
return positions
|
||||
}
|
||||
|
||||
const resources = dataSource.value as Resource[]
|
||||
const scrollTop = timelineBodyScrollTop.value
|
||||
const containerHeight = timelineBodyHeight.value || 600
|
||||
const scrollBottom = scrollTop + containerHeight + ROW_HEIGHT * VERTICAL_BUFFER * 2
|
||||
|
||||
let cumulativeTop = 0
|
||||
let processedCount = 0
|
||||
|
||||
// 🎯 Phase2优化:懒加载计算,只计算到scrollBottom以下一定范围
|
||||
// 而不是一次性计算所有100个资源
|
||||
for (const resource of resources) {
|
||||
positions.set(resource.id, cumulativeTop)
|
||||
|
||||
// 使用辅助函数获取布局(自动缓存)
|
||||
const layout = getResourceLayout(resource)
|
||||
const resourceHeight = layout.totalHeight || 51
|
||||
cumulativeTop += resourceHeight
|
||||
processedCount++
|
||||
|
||||
// 优化:如果已经计算到scrollBottom之下足够远,停止计算
|
||||
// 保留一定余量,避免滚动时需要重新计算
|
||||
if (cumulativeTop > scrollBottom + ROW_HEIGHT * 20) {
|
||||
// 仍需继续计算剩余资源的近似位置(假设都是51px高度)
|
||||
for (let i = processedCount; i < resources.length; i++) {
|
||||
positions.set(resources[i].id, cumulativeTop) // 设置当前资源的位置
|
||||
cumulativeTop += 51 // 累加位置,为下一个资源准备
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return positions
|
||||
})
|
||||
// v1.9.9 优化:注入GanttChart提供的资源布局,避免重复计算
|
||||
// GanttChart已经计算并provide了resourceTaskLayouts和resourceRowPositions
|
||||
const resourceTaskLayouts = inject<ComputedRef<Map<string | number, any>>>('resourceTaskLayouts', computed(() => new Map()))
|
||||
const resourceRowPositions = inject<ComputedRef<Map<string | number, number>>>('resourceRowPositions', computed(() => new Map()))
|
||||
|
||||
// 获取以今天为中心的时间线范围(缓存结果,避免每次计算创建新对象)
|
||||
const cachedTodayCenteredRange = (() => {
|
||||
@@ -356,6 +228,7 @@ watch([timelineStartDate, timelineEndDate], ([newStart, newEnd]) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 优化:使用常量避免每次创建新空数组
|
||||
const EMPTY_TASKS_ARRAY: Task[] = []
|
||||
|
||||
@@ -1482,12 +1355,6 @@ let hideBubblesTimeout: number | null = null // 半圆显示恢复定时器
|
||||
const HOUR_WIDTH = 40 // 每小时40px
|
||||
const VIRTUAL_BUFFER = 10 // 减少缓冲区以提升滑动性能
|
||||
|
||||
// 纵向虚拟滚动相关状态
|
||||
const ROW_HEIGHT = 51 // 每行高度51px (50px + 1px border)
|
||||
const VERTICAL_BUFFER = 5 // 纵向缓冲区行数
|
||||
const timelineBodyScrollTop = ref(0) // 纵向滚动位置
|
||||
const timelineBodyHeight = ref(0) // 容器高度状态管理
|
||||
|
||||
// v1.9.5 P2-3优化 - 智能缓存数据结构
|
||||
interface TimelineCacheEntry {
|
||||
data: unknown
|
||||
@@ -1720,8 +1587,8 @@ const visibleTaskRange = computed(() => {
|
||||
for (let i = 0; i < resources.length; i++) {
|
||||
const resourceId = resources[i].id
|
||||
const rowTop = resourceRowPositions.value?.get(resourceId) || 0
|
||||
// 🎯 使用辅助函数获取布局,避免循环依赖
|
||||
const layout = getResourceLayout(resources[i])
|
||||
// v1.9.9 从 resourceTaskLayouts 直接获取布局
|
||||
const layout = resourceTaskLayouts.value.get(resourceId)
|
||||
const rowHeight = layout?.totalHeight || ROW_HEIGHT
|
||||
const rowBottom = rowTop + rowHeight
|
||||
|
||||
@@ -1968,10 +1835,10 @@ const rebuildResourceTaskQueues = () => {
|
||||
resourceId,
|
||||
rendered: isRendered,
|
||||
timestamp: isRendered ?
|
||||
(existingCache ?
|
||||
existingCache.timestamp
|
||||
: currentTimestamp)
|
||||
: currentTimestamp,
|
||||
(existingCache ?
|
||||
existingCache.timestamp
|
||||
: currentTimestamp)
|
||||
: currentTimestamp,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2412,7 +2279,7 @@ const getOtherMilestonesInfo = (currentId: number) => {
|
||||
// v1.9.5 P2-4优化 - 监听Split Bar拖拽结束,执行清理工作
|
||||
watch(isSplitBarDragging, (dragging) => {
|
||||
if (!dragging) {
|
||||
// 拖拽结束后,手动触发一次容器宽度更新
|
||||
// v1.9.9 拖拽结束后,手动触发一次容器尺寸更新(因为拖拽期间ResizeObserver被暂停)
|
||||
if (timelineContainerElement.value) {
|
||||
const newWidth = timelineContainerElement.value.clientWidth
|
||||
if (Math.abs(newWidth - timelineContainerWidth.value) > 1) {
|
||||
@@ -2420,6 +2287,14 @@ watch(isSplitBarDragging, (dragging) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 同时更新body高度(虽然拖拽SplitterBar通常不会改变高度,但为了完整性)
|
||||
if (timelineBodyElement.value) {
|
||||
const newHeight = timelineBodyElement.value.clientHeight
|
||||
if (Math.abs(newHeight - timelineBodyHeight.value) > 1) {
|
||||
timelineBodyHeight.value = newHeight
|
||||
}
|
||||
}
|
||||
|
||||
// Splitter拖拽结束后,强制重新计算半圆显示状态
|
||||
// 因为Timeline容器宽度可能发生了变化
|
||||
hideBubbles.value = true
|
||||
@@ -2492,8 +2367,8 @@ const contentHeight = computed(() => {
|
||||
let totalHeight = 0
|
||||
|
||||
resources.forEach(resource => {
|
||||
// 🎯 使用辅助函数获取布局,确保所有资源高度都被计算
|
||||
const layout = getResourceLayout(resource)
|
||||
// v1.9.9 从 resourceTaskLayouts 直接获取布局
|
||||
const layout = resourceTaskLayouts.value.get(resource.id)
|
||||
totalHeight += layout?.totalHeight || 51
|
||||
})
|
||||
|
||||
@@ -3658,42 +3533,19 @@ function handleBarMounted(payload: {
|
||||
const handleTaskBarDragEnd = (updatedTask: Task) => {
|
||||
// 如果是资源视图,需要更新dataSource中的资源数据
|
||||
if (viewMode.value === 'resource' && dataSource.value) {
|
||||
let targetResourceId: string | number | null = null
|
||||
let targetResource: any = null
|
||||
|
||||
for (const resource of dataSource.value as any[]) {
|
||||
if (resource.tasks) {
|
||||
const taskIndex = resource.tasks.findIndex((t: Task) => t.id === updatedTask.id)
|
||||
if (taskIndex !== -1) {
|
||||
// 更新资源中的任务数据
|
||||
resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask }
|
||||
targetResourceId = resource.id
|
||||
targetResource = resource
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 关键修复:清除该资源的布局缓存,触发自动换行重新计算
|
||||
if (targetResourceId !== null && targetResource) {
|
||||
// 清除所有该资源的缓存(通配符删除,因为时间可能变化了)
|
||||
const keysToDelete = Array.from(layoutCache.keys()).filter(key => key.startsWith(`${targetResourceId}-`))
|
||||
keysToDelete.forEach(key => layoutCache.delete(key))
|
||||
|
||||
// 获取旧布局信息
|
||||
const oldLayout = resourceTaskLayouts.value.get(targetResourceId)
|
||||
const oldRowCount = oldLayout?.rowHeights.length || 1
|
||||
|
||||
// 重新计算布局
|
||||
const newLayout = getResourceLayout(targetResource)
|
||||
const newRowCount = newLayout.rowHeights.length
|
||||
|
||||
// 只有当行数发生变化时,才需要触发全量重绘
|
||||
if (newRowCount !== oldRowCount) {
|
||||
// 行数变化,需要触发重绘
|
||||
taskBarRenderKey.value++
|
||||
}
|
||||
}
|
||||
// 🎯 关键修复:数据已经更新,GanttChart的watch会自动检测并刷新布局
|
||||
// v1.9.9 不需要手动调用invalidateLayout,props.resources的deep watch会自动触发
|
||||
}
|
||||
// 记录变化的TaskBar ID(用于增量冲突更新)
|
||||
lastChangedTaskId.value = updatedTask.id
|
||||
@@ -3703,43 +3555,19 @@ const handleTaskBarDragEnd = (updatedTask: Task) => {
|
||||
const handleTaskBarResizeEnd = (updatedTask: Task) => {
|
||||
// 如果是资源视图,需要更新dataSource中的资源数据
|
||||
if (viewMode.value === 'resource' && dataSource.value) {
|
||||
let targetResourceId: string | number | null = null
|
||||
let targetResource: any = null
|
||||
|
||||
for (const resource of dataSource.value as any[]) {
|
||||
if (resource.tasks) {
|
||||
const taskIndex = resource.tasks.findIndex((t: Task) => t.id === updatedTask.id)
|
||||
if (taskIndex !== -1) {
|
||||
// 更新资源中的任务数据
|
||||
resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask }
|
||||
targetResourceId = resource.id
|
||||
targetResource = resource
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 关键修复:清除该资源的布局缓存,触发自动换行重新计算
|
||||
if (targetResourceId !== null && targetResource) {
|
||||
// 清除所有该资源的缓存(通配符删除,因为时间可能变化了)
|
||||
const keysToDelete = Array.from(layoutCache.keys()).filter(key => key.startsWith(`${targetResourceId}-`))
|
||||
keysToDelete.forEach(key => layoutCache.delete(key))
|
||||
|
||||
// 获取旧布局信息
|
||||
const oldLayout = resourceTaskLayouts.value.get(targetResourceId)
|
||||
const oldRowCount = oldLayout?.rowHeights.length || 1
|
||||
|
||||
// 重新计算布局
|
||||
const newLayout = getResourceLayout(targetResource)
|
||||
const newRowCount = newLayout.rowHeights.length
|
||||
|
||||
// 🔧 修复:不再触发 taskBarRenderKey 更新,避免整个视图重建导致闪烁
|
||||
// 资源布局的更新会通过 resourceTaskLayouts 的响应式自动触发重新渲染
|
||||
// 注释掉以避免不必要的全量重绘:
|
||||
// if (newRowCount !== oldRowCount) {
|
||||
// taskBarRenderKey.value++
|
||||
// }
|
||||
}
|
||||
// 🎯 关键修复:数据已经更新,GanttChart的watch会自动检测并刷新布局
|
||||
// v1.9.9 不需要手动调用invalidateLayout,props.resources的deep watch会自动触发
|
||||
}
|
||||
// 记录变化的TaskBar ID(用于增量冲突更新)
|
||||
lastChangedTaskId.value = updatedTask.id
|
||||
@@ -3839,6 +3667,11 @@ onMounted(() => {
|
||||
timelineBodyHeight.value = timelineBody.clientHeight
|
||||
|
||||
resizeObserver = new ResizeObserver(entries => {
|
||||
// v1.9.9 优化:拖拽SplitterBar期间不处理高度变化,避免影响拖拽性能
|
||||
if (isSplitBarDragging.value) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
timelineBodyHeight.value = entry.contentRect.height
|
||||
}
|
||||
@@ -3854,6 +3687,11 @@ onMounted(() => {
|
||||
|
||||
// 为容器宽度变化创建独立的ResizeObserver
|
||||
const containerResizeObserver = new ResizeObserver(entries => {
|
||||
// v1.9.9 优化:拖拽SplitterBar期间不处理宽度变化,避免影响拖拽性能
|
||||
if (isSplitBarDragging.value) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const newWidth = entry.contentRect.width
|
||||
// 当容器宽度发生变化时,立即更新宽度并重新计算半圆显示
|
||||
@@ -3992,6 +3830,7 @@ const handleTaskBarHighlighted = () => {
|
||||
// 🔧 修复:检查是否有 TaskBar 正在被拖拽或 resize
|
||||
// 如果有,不应该触发 Timeline 拖拽,避免与 TaskBar 交互冲突
|
||||
if (isDraggingTaskBar.value) {
|
||||
// TaskBar 正在被拖拽,取消 Timeline 拖拽启动
|
||||
document.removeEventListener('mousemove', handleNextMouseMove)
|
||||
return
|
||||
}
|
||||
@@ -4063,8 +3902,8 @@ const handleResourceTaskBarDrop = (event: Event) => {
|
||||
const resource = resources[i]
|
||||
const resourceId = String(resource.id)
|
||||
const rowTop = resourceRowPositions.value.get(resourceId) || 0
|
||||
// v1.9.6 Phase2: 使用getResourceLayout确保获取到布局(自动缓存)
|
||||
const layout = resourceTaskLayouts.value.get(resourceId) || getResourceLayout(resource)
|
||||
// v1.9.9 从resoureTaskLayouts直接获取布局
|
||||
const layout = resourceTaskLayouts.value.get(resourceId)
|
||||
const rowHeight = layout?.totalHeight || 51
|
||||
const rowCenter = rowTop + rowHeight / 2
|
||||
const distance = Math.abs(relativeY - rowCenter)
|
||||
@@ -4395,8 +4234,8 @@ const handleDragBoundaryCheck = (event: CustomEvent) => {
|
||||
const resource = resources[i]
|
||||
const resourceId = String(resource.id)
|
||||
const rowTop = resourceRowPositions.value.get(resourceId) || 0
|
||||
// v1.9.6 Phase2: 使用getResourceLayout确保获取到布局(自动缓存)
|
||||
const layout = resourceTaskLayouts.value.get(resourceId) || getResourceLayout(resource)
|
||||
// v1.9.9 从resoureTaskLayouts直接获取布局
|
||||
const layout = resourceTaskLayouts.value.get(resourceId)
|
||||
const rowHeight = layout?.totalHeight || 51
|
||||
const rowCenter = rowTop + rowHeight / 2
|
||||
const distance = Math.abs(relativeY - rowCenter)
|
||||
@@ -4669,6 +4508,13 @@ const debouncedUpdateTimelineRange = (delay = 50) => {
|
||||
|
||||
// 监听tasks数据变化和容器宽度变化,合并处理以减少重复计算
|
||||
const updateTimelineRange = () => {
|
||||
// 资源视图下不自动调整时间范围,保持Timeline背景层(header + 竖列)稳定
|
||||
// 避免滚动时重新生成timelineData导致header闪烁
|
||||
// 虚拟滚动通过visibleTimeRange过滤TaskBar即可
|
||||
if (viewMode.value === 'resource') {
|
||||
return
|
||||
}
|
||||
|
||||
let newRange: { startDate: Date; endDate: Date } | null = null
|
||||
|
||||
if (currentTimeScale.value === TimelineScale.HOUR) {
|
||||
@@ -5739,6 +5585,7 @@ const handleAddSuccessor = (task: Task) => {
|
||||
<!-- v1.9.5 可通过 show-conflicts prop 控制是否显示 -->
|
||||
<!-- v1.9.6 修复:width使用totalTimelineWidth(用于坐标计算),containerWidth用于Canvas宽度 -->
|
||||
<!-- v1.9.7 Bug修复:使用渲染的tasks而不是allTasks,避免滚动后显示已消失TaskBar的冲突 -->
|
||||
<!-- v1.9.9 优化:传递renderLimit,只计算已渲染TaskBar的冲突 -->
|
||||
<GanttConflicts
|
||||
v-if="showConflicts"
|
||||
:tasks="(resource as any).tasks"
|
||||
@@ -5760,6 +5607,7 @@ const handleAddSuccessor = (task: Task) => {
|
||||
:row-heights="resourceTaskLayouts.get(resource.id)?.rowHeights"
|
||||
:scroll-left="timelineScrollLeft"
|
||||
:container-width="timelineContainerWidth"
|
||||
:render-limit="resourceTaskRenderLimits.get(resource.id)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,8 @@ interface Props {
|
||||
scrollLeft?: number
|
||||
/** 可视区域宽度 */
|
||||
containerWidth?: number
|
||||
/** v1.9.9 当前渲染的任务数量限制(用于渐进式渲染) */
|
||||
renderLimit?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -90,11 +92,17 @@ const canvasStyle = computed(() => ({
|
||||
watch(() => props.tasks, () => {
|
||||
if (isDraggingTaskBar.value) {
|
||||
needsRecalculation.value = true
|
||||
} else {
|
||||
recalculateConflicts()
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// v1.9.9 监听renderLimit变化,TaskBar渐进式渲染时重新计算冲突
|
||||
watch(() => props.renderLimit, () => {
|
||||
// 延迟一帧,确保DOM更新完成
|
||||
nextTick(() => {
|
||||
recalculateConflicts()
|
||||
})
|
||||
})
|
||||
|
||||
// 监听拖拽状态变化
|
||||
watch(isDraggingTaskBar, (dragging) => {
|
||||
if (!dragging && needsRecalculation.value) {
|
||||
@@ -155,7 +163,6 @@ watch([canvasWidth, canvasHeight], () => {
|
||||
|
||||
// 监听timelineData和currentTimeScale变化
|
||||
watch([() => props.timelineData, () => props.currentTimeScale], () => {
|
||||
if (import.meta.env.DEV) {}
|
||||
// v1.9.4 BUG修复 - 视图切换时清空坐标缓存
|
||||
coordsCache.clear()
|
||||
// v1.9.7 Bug修复 - 视图切换时清空纹理缓存并使用nextTick确保立即刷新
|
||||
@@ -166,6 +173,7 @@ watch([() => props.timelineData, () => props.currentTimeScale], () => {
|
||||
}, { deep: true })
|
||||
|
||||
// v1.9.6 监听scrollLeft变化,滚动停止后重新计算可视区域的冲突
|
||||
// v1.9.9 恢复防抖机制:避免滚动时频繁重绘Canvas导致性能问题
|
||||
let scrollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(() => props.scrollLeft, () => {
|
||||
// 清除之前的定时器
|
||||
@@ -173,15 +181,14 @@ watch(() => props.scrollLeft, () => {
|
||||
clearTimeout(scrollTimer)
|
||||
}
|
||||
|
||||
// 滚动停止后100ms重新计算冲突区域(包含视口裁剪)
|
||||
// 滚动时仅清空Canvas,避免闪烁
|
||||
clearCanvas()
|
||||
|
||||
// 50ms防抖:滚动停止后重新计算和绘制冲突区域
|
||||
scrollTimer = setTimeout(() => {
|
||||
nextTick(() => {
|
||||
// 先清除Canvas上的旧内容
|
||||
clearCanvas()
|
||||
// 重新计算conflictZones(会根据新的scrollLeft裁剪可见区域)
|
||||
recalculateConflicts()
|
||||
})
|
||||
}, 100)
|
||||
recalculateConflicts()
|
||||
scrollTimer = null
|
||||
}, 50)
|
||||
})
|
||||
|
||||
// 增量重新计算冲突(仅计算与变化TaskBar相关的冲突)
|
||||
@@ -298,8 +305,14 @@ function recalculateConflictsIncremental(changedTaskId: string | number) {
|
||||
|
||||
// 重新计算冲突
|
||||
function recalculateConflicts() {
|
||||
// v1.9.9 优化:只计算已渲染的TaskBar的冲突
|
||||
// 如果设置了renderLimit,则只取前N个任务进行冲突检测
|
||||
const tasksToCheck = props.renderLimit !== undefined && props.renderLimit > 0
|
||||
? props.tasks.slice(0, props.renderLimit)
|
||||
: props.tasks
|
||||
|
||||
// 调用冲突检测算法
|
||||
const conflicts = detectConflicts(props.tasks, props.resourceId)
|
||||
const conflicts = detectConflicts(tasksToCheck, props.resourceId)
|
||||
|
||||
// v1.9.4 P1优化 - 使用坐标缓存
|
||||
conflictZones.value = conflicts.map((zone) => {
|
||||
|
||||
@@ -205,6 +205,8 @@ const conflictInfoList = computed(() => {
|
||||
const conflictEnd = new Date(conflictTask.endDate).getTime()
|
||||
|
||||
// 计算冲突任务的资源占比
|
||||
// v1.9.10 注释:如果冲突任务没有 resources 字段,默认使用 100%
|
||||
// 这是因为在资源视图中,任务隶属于该资源但未明确指定占比时,视为全职投入
|
||||
let conflictPercent = 100
|
||||
if (conflictTask.resources && Array.isArray(conflictTask.resources)) {
|
||||
const allocation = conflictTask.resources.find(
|
||||
@@ -214,6 +216,7 @@ const conflictInfoList = computed(() => {
|
||||
conflictPercent = Math.max(20, Math.min(100, allocation.capacity))
|
||||
}
|
||||
}
|
||||
// else: 保持默认 100%(资源视图中任务隶属于当前资源)
|
||||
|
||||
// 计算当前任务与该冲突任务的重叠时间段
|
||||
const overlapStart = Math.max(currentStart, conflictStart)
|
||||
@@ -282,6 +285,8 @@ const totalOverloadPercent = computed(() => {
|
||||
|
||||
// 检查任务是否在该重叠区间内(使用 +1 天后的 endDate)
|
||||
if (tStart < overlapEndPlus && tEndPlus > overlapStart) {
|
||||
// v1.9.10 注释:如果任务没有 resources 字段,默认使用 100%
|
||||
// 这确保了资源视图中未明确指定占比的任务被正确计入冲突检测
|
||||
let taskPercent = 100
|
||||
if (task.resources && Array.isArray(task.resources)) {
|
||||
const allocation = task.resources.find(
|
||||
@@ -291,6 +296,7 @@ const totalOverloadPercent = computed(() => {
|
||||
taskPercent = allocation.capacity
|
||||
}
|
||||
}
|
||||
// else: 保持默认 100%(资源视图中任务隶属于当前资源)
|
||||
intervalTotal += taskPercent
|
||||
}
|
||||
})
|
||||
|
||||
211
src/composables/useResourceLayout.ts
Normal file
211
src/composables/useResourceLayout.ts
Normal 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
src/composables/useViewMode.ts
Normal file
76
src/composables/useViewMode.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export { default as MilestonePoint } from './components/MilestonePoint.vue'
|
||||
export { default as MilestoneDialog } from './components/MilestoneDialog.vue'
|
||||
export { default as TaskRow } from './components/TaskList/taskRow/TaskRow.vue'
|
||||
export type { Task } from './models/classes/Task.ts' // 导出Task类型
|
||||
export type { Resource } from './models/types/Resource.ts' // v1.9.0 导出Resource类型
|
||||
export type { Resource } from './models/classes/Resource.ts' // v1.9.0 导出Resource类型
|
||||
export { createResource } from './utils/resourceUtils.ts' // v2.0.0 导出资源创建工厂函数
|
||||
export { useMessage } from './composables/useMessage.ts' // 导出useMessage组合式函数
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Resource } from '../types/Resource'
|
||||
import type { Resource } from './Resource'
|
||||
|
||||
// Task 类型定义
|
||||
export interface Task {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Resource } from '../types/Resource'
|
||||
import type { Resource } from '../classes/Resource'
|
||||
|
||||
/**
|
||||
* 资源列表列类型枚举
|
||||
|
||||
@@ -56,7 +56,8 @@ export function detectConflicts(
|
||||
tasks: Task[],
|
||||
resourceId: string | number,
|
||||
): ConflictZone[] {
|
||||
// 过滤出包含指定资源的任务(没有resources字段时视为100%分配给该资源)
|
||||
// v1.9.10 过滤出包含指定资源的任务
|
||||
// 注意:如果任务没有resources字段或为空,视为100%分配给该资源(资源视图中任务必然属于某个资源)
|
||||
const resourceTasks = tasks.filter((task) => {
|
||||
// 如果没有resources字段或为空,视为100%分配
|
||||
if (!task.resources || task.resources.length === 0) return true
|
||||
@@ -125,7 +126,8 @@ function detectConflictsBruteForce(
|
||||
// 计算总投入比例
|
||||
let totalPercent = 0
|
||||
const taskDetails = overlappingTasks.map((task) => {
|
||||
// 如果没有resources字段,默认100%;否则查找对应资源的percent
|
||||
// v1.9.10 如果没有resources字段,默认100%;否则查找对应资源的capacity
|
||||
// 这确保了资源视图中未明确指定占比的任务被正确计入冲突检测
|
||||
const resource = task.resources?.find((r) => String(r.id) === String(resourceId))
|
||||
const capacity =
|
||||
!task.resources || task.resources.length === 0 ? 100 : (resource?.capacity || 0)
|
||||
|
||||
@@ -81,13 +81,7 @@ export const perfMonitor = {
|
||||
* 测量函数执行时间
|
||||
*/
|
||||
measure<T>(fn: () => T): T {
|
||||
const start = performance.now()
|
||||
const result = fn()
|
||||
const end = performance.now()
|
||||
const duration = end - start
|
||||
|
||||
if (duration > 5) { }
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Task } from '../models/classes/Task'
|
||||
import type { Resource } from '../models/types/Resource'
|
||||
import type { Resource } from '../models/classes/Resource'
|
||||
import { detectConflicts } from './conflictUtils'
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user