v1.9.0-rc.8 - SIT finished

This commit is contained in:
LINING-PC\lining
2026-02-07 18:44:23 +08:00
parent cf4d410e95
commit 430015e3fe
28 changed files with 1676 additions and 994 deletions
+47 -6
View File
@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, onUnmounted, onMounted, computed, watch, nextTick, useSlots, provide } from 'vue'
import 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 })
+7 -7
View File
@@ -1423,36 +1423,36 @@ onUnmounted(() => {
}
/* 暗黑模式下的视图模式切换样式 */
:global(html[data-theme='dark']) .gantt-view-mode-control {
:global(.gantt-root[data-theme='dark']) .gantt-view-mode-control {
background: var(--gantt-bg-secondary, #4b4b4b);
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;
}
+23 -7
View File
@@ -72,7 +72,9 @@ const resourcePercent = computed(() => {
}
}
// 默认100%(向后兼容)
// v1.9.10 注释:默认100%(向后兼容)
// 在资源视图中,如果任务没有 resources 字段或未找到匹配的资源分配,
// 说明任务隶属于该资源但未明确指定占比,视为全职投入(100%)
return 100
})
@@ -95,9 +97,9 @@ const currentResourceName = computed(() => {
})
// v1.9.0 是否显示占比文字(占比<100%时显示)
const shouldShowPercentText = computed(() => {
return viewMode.value === 'resource' && resourcePercent.value < 100
})
// const shouldShowPercentText = computed(() => {
// return viewMode.value === 'resource' && resourcePercent.value < 100
// })
interface Props {
task: Task
@@ -356,6 +358,8 @@ const resizeStartLeft = ref(0)
// v1.9.2 注入Timeline的拖拽状态(用于冲突检测优化)
const timelineIsDraggingTaskBar = inject<Ref<boolean>>('isDraggingTaskBar', ref(false))
// v1.9.7 注入Timeline容器的拖拽状态(用于禁止TaskBar在Timeline拖拽时被拖拽)
const isTimelineDragging = inject<Ref<boolean>>('isDraggingTimeline', ref(false))
// v1.9.0 拖拽预览效果(资源视图垂直拖拽)
const dragPreviewVisible = ref(false)
@@ -1095,6 +1099,12 @@ const needsOverflowEffect = computed(() => isWeekView.value && isShortTaskBar.va
// 鼠标事件处理 - 使用相对位置拖拽方案(带防误触机制)
const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-right') => {
// v1.9.7 如果Timeline正在被拖拽,禁止TaskBar的拖拽和拉伸,让事件传播到Timeline
if (isTimelineDragging.value) {
// 不阻止事件传播,让Timeline可以继续滚动
return
}
// 如果处于高亮状态,不阻止事件传播,让Timeline可以滚动
if (props.isHighlighted || props.isPrimaryHighlight) {
// 不调用 e.preventDefault() 和 e.stopPropagation()
@@ -1267,7 +1277,7 @@ const dragTooltipContent = ref({ startDate: '', endDate: '' })
const handleMouseMove = (e: MouseEvent) => {
// 记录最新的鼠标Y位置(用于资源视图垂直拖拽)
if (viewMode.value === 'resource') {
;(window as any).lastDragMouseY = e.clientY
(window as any).lastDragMouseY = e.clientY
// v1.9.0 检测是否跨行拖拽(基于资源行的实际高度)
const timelineBody = document.querySelector('.timeline-body')
@@ -1927,6 +1937,9 @@ const handleMouseUp = () => {
dragType.value = null
tempTaskPixelLeft.value = null // v1.9.0 清除资源视图的像素位置缓存
// v1.9.7 不需要显式设置timelineIsDraggingTaskBar
// 上面的状态重置会自动触发watchwatch会同步timelineIsDraggingTaskBar的值
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
@@ -2732,6 +2745,7 @@ const handleTaskBarMouseLeave = () => {
}
// 监听拖拽/拉伸状态,如果开始拖拽/拉伸,立即隐藏tooltip
// v1.9.7 使用 flush: 'sync' 确保状态变化时立即同步执行,避免在资源视图拖拽时因组件更新导致watch延迟执行
watch([isDragging, isResizingLeft, isResizingRight], ([dragging, resizingL, resizingR]) => {
if (dragging || resizingL || resizingR) {
showHoverTooltip.value = false
@@ -2746,6 +2760,8 @@ watch([isDragging, isResizingLeft, isResizingRight], ([dragging, resizingL, resi
if (timelineIsDraggingTaskBar.value !== isDraggingOrResizing) {
timelineIsDraggingTaskBar.value = isDraggingOrResizing
}
}, {
flush: 'sync', // 同步执行,确保状态重置时立即触发
})
// v1.9.2 监听 Tab 悬停状态,当 Tab 悬停时立即隐藏 TaskBar 的 tooltip
@@ -3505,9 +3521,9 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
<div v-else class="task-name">
{{ task.name }}
<!-- v1.9.0 资源视图:显示占比文字 -->
<span v-if="shouldShowPercentText" class="resource-capacity-text">
<!--<span v-if="shouldShowPercentText" class="resource-capacity-text">
{{ resourcePercent }}%
</span>
</span>-->
</div>
</div>
+5 -5
View File
@@ -1819,24 +1819,24 @@ const taskStatus = computed(() => {
}
/* 暗黑模式 */
:global(html[data-theme='dark']) .resource-header {
:global(.gantt-root[data-theme='dark']) .resource-header {
background: var(--gantt-bg-toolbar, rgba(255, 255, 255, 0.03)) !important;
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>
+6 -12
View File
@@ -1,13 +1,12 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, useSlots, computed, inject } from 'vue'
import type { StyleValue, Slots, Ref, ComputedRef } from 'vue'
import type { StyleValue, Slots } from 'vue'
import TaskRow from './taskRow/TaskRow.vue'
import { useI18n } from '../../composables/useI18n'
import { useViewMode } from '../../composables/useViewMode'
import type { Task } from '../../models/classes/Task'
import type { Resource } from '../../models/types/Resource'
import type { Resource } from '../../models/classes/Resource'
import type { TaskListConfig, TaskListColumnConfig } from '../../models/configs/TaskListConfig'
// @ts-expect-error - ResourceListColumnConfig is used in type unions
import type { ResourceListConfig, ResourceListColumnConfig } from '../../models/configs/ResourceListConfig'
import { DEFAULT_TASK_LIST_COLUMNS } from '../../models/configs/TaskListConfig'
import { DEFAULT_RESOURCE_LIST_COLUMNS } from '../../models/configs/ResourceListConfig'
import { useTaskRowDrag } from '../../composables/useTaskRowDrag'
@@ -53,13 +52,8 @@ const emit = defineEmits<{
const slots = useSlots()
const hasRowSlot = computed(() => Boolean(slots['custom-task-content']))
// v1.9.0 GanttChart
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
const listConfig = inject<ComputedRef<TaskListConfig | ResourceListConfig | undefined>>(
'gantt-list-config',
computed(() => undefined),
)
// v1.9.9 使useViewMode
const { viewMode, dataSource, listConfig } = useViewMode()
// GanttChart slots
const columnSlots = inject<Slots>('gantt-column-slots', {})
@@ -340,8 +334,8 @@ onUnmounted(() => {
<TaskRow
v-for="{ task, level, rowIndex } in visibleTasks"
v-memo="[task.id, task.name, task.collapsed, hoveredTaskId === task.id, task.startDate, task.endDate, task.progress]"
:key="task.id"
v-memo="[task.id, task.name, task.collapsed, hoveredTaskId === task.id, task.startDate, task.endDate, task.progress]"
:task="task"
:level="level"
:row-index="rowIndex"
@@ -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,
+170 -50
View File
@@ -2,8 +2,11 @@
import { ref, computed, useSlots, toRef, inject, type ComputedRef } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from '../../../composables/useI18n'
import { useViewMode } from '../../../composables/useViewMode'
import { formatPredecessorDisplay } from '../../../utils/predecessorUtils'
import type { Task } from '../../../models/classes/Task'
import type { Resource } from '../../../models/classes/Resource'
import { isResourceOverloaded } from '../../../utils/resourceUtils'
import type { TaskListColumnConfig } from '../../../models/configs/TaskListConfig'
import type { DeclarativeColumnConfig } from '../composables/taskList/useTaskListColumns'
import TaskContextMenu from '../../TaskContextMenu.vue'
@@ -21,7 +24,7 @@ interface TaskRowSlotProps {
level: number
indent: string
isHovered: boolean
hoveredTaskId: number | null
hoveredTaskId: number | string | null
isParent: boolean
hasChildren: boolean
collapsed: boolean
@@ -42,8 +45,8 @@ interface Props {
level: number
rowIndex?: number
isHovered?: boolean
hoveredTaskId?: number | null
onHover?: (taskId: number | null) => void
hoveredTaskId?: number | string | null
onHover?: (taskId: number | string | null) => void
columns: TaskListColumnConfig[]
declarativeColumns?: DeclarativeColumnConfig[]
renderMode?: 'default' | 'declarative'
@@ -82,6 +85,36 @@ const daysText = computed(() => t.value?.days ?? '')
const slots = useSlots()
const hasContentSlot = computed(() => Boolean(slots['custom-task-content']))
// v1.9.9 使useViewMode
const { viewMode } = useViewMode()
// GanttChart
const resourceTaskLayouts = inject<ComputedRef<Map<string, { taskRowMap: Map<string | number, number>, rowHeights: number[], totalHeight: number }>>>('resourceTaskLayouts', computed(() => new Map()))
// v1.9.0 Detect if current row is a resource
const isResourceRow = computed(() => {
return viewMode.value === 'resource' && 'tasks' in props.task
})
// v1.9.0
const isResourceOverloadedComputed = computed(() => {
if (!isResourceRow.value) return false
// Resource
const resource = props.task as Resource
return isResourceOverloaded(resource)
})
// - resource使
const rowHeight = computed(() => {
if (isResourceRow.value) {
const resourceId = String(props.task.id) // string
const layout = resourceTaskLayouts.value.get(resourceId)
return layout?.totalHeight || 56 // v1.9.1 56px51 + 5pxpadding
}
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;
}
+59 -211
View File
@@ -8,16 +8,16 @@ import LinkDragGuide from './LinkDragGuide.vue'
import GanttConflicts from './Timeline/GanttConflicts.vue'
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useI18n } from '../composables/useI18n'
import { useViewMode } from '../composables/useViewMode' // v1.9.9
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
import { getPredecessorIds } from '../utils/predecessorUtils'
import { perfMonitor } from '../utils/perfMonitor'
import { perfMonitor2 } from '../utils/perfMonitor2' // v1.9.6
import type { Task } from '../models/classes/Task'
import type { Resource } from '../models/types/Resource'
import type { Resource } from '../models/classes/Resource'
import type { Milestone } from '../models/classes/Milestone'
import type { TimelineConfig } from '../models/configs/TimelineConfig'
import { TimelineScale } from '../models/types/TimelineScale'
import { assignTaskRows } from '../utils/taskLayoutUtils' // v1.9.0
import { positionCache } from '../utils/positionCache' // v1.9.6 Phase1
// Props
@@ -103,9 +103,8 @@ const t = (key: string): string => {
return getTranslation(key)
}
// v1.9.0 GanttChart
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
// v1.9.9 使useViewMode
const { viewMode, dataSource } = useViewMode()
// v1.9.0 GanttChart GanttChart updateTaskTrigger
const resourceConflicts = inject<ComputedRef<Map<string, Set<number>>>>('resourceConflicts', computed(() => new Map()))
@@ -171,143 +170,16 @@ const isSplitBarDragging = inject<Ref<boolean>>('isSplitBarDragging', ref(false)
// v1.9.5 showConflicts
const showConflicts = inject<ComputedRef<boolean>>('gantt-show-conflicts', computed(() => true))
// v1.9.6 Sprint1(P0) - resourceTaskLayouts
const layoutCache = new Map<string, {
taskRowMap: Map<string | number, number>,
rowHeights: number[],
totalHeight: number
}>()
// useResourceLayout
const ROW_HEIGHT = 51 // 51px (50px + 1px border)
const VERTICAL_BUFFER = 5 //
const timelineBodyScrollTop = ref(0) //
const timelineBodyHeight = ref(0) //
// v1.9.6 Phase2 - +
//
let resourceTaskLayoutsCallCount = 0
const resourceTaskLayouts = computed(() => {
resourceTaskLayoutsCallCount++
const layoutMap = new Map<string | number, {
taskRowMap: Map<string | number, number>,
rowHeights: number[],
totalHeight: number
}>()
if (viewMode.value !== 'resource') {
return layoutMap
}
const resources = dataSource.value as Resource[]
//
// visibleTaskRange resourceRowPositions resourceRowPositions
//
//
let cacheHits = 0
let cacheMisses = 0
// 使
resources.forEach(resource => {
//
const cacheKey = `${resource.id}-${((resource as any).tasks || []).length}`
const isCacheHit = layoutCache.has(cacheKey)
//
const layout = getResourceLayout(resource)
layoutMap.set(resource.id, layout)
//
if (isCacheHit) {
cacheHits++
} else {
cacheMisses++
}
})
return layoutMap
})
// v1.9.6 Sprint1(P0) - 100
watch(dataSource, () => {
if (layoutCache.size > 100) {
const keysToDelete = Array.from(layoutCache.keys()).slice(0, layoutCache.size - 100)
keysToDelete.forEach(key => layoutCache.delete(key))
}
})
// v1.9.6 Sprint1(P0) - +
const getResourceLayout = (resource: Resource) => {
const resourceTasks = (resource as any).tasks || []
// 🎯 key
// id-start-end
const timeHash = resourceTasks
.map((t: Task) => `${t.id}-${t.startDate || ''}-${t.endDate || ''}`)
.join('|')
const cacheKey = `${resource.id}-${resourceTasks.length}-${timeHash}`
//
if (layoutCache.has(cacheKey)) {
return layoutCache.get(cacheKey)!
}
//
let result
if (resourceTasks.length > 0) {
result = assignTaskRows(resourceTasks, 51)
} else {
result = {
taskRowMap: new Map(),
rowHeights: [51],
totalHeight: 51,
}
}
layoutCache.set(cacheKey, result)
return result
}
// v1.9.6 Phase2 -
let resourceRowPositionsCallCount = 0
const resourceRowPositions = computed(() => {
resourceRowPositionsCallCount++
const positions = new Map<string | number, number>()
if (viewMode.value !== 'resource') {
return positions
}
const resources = dataSource.value as Resource[]
const scrollTop = timelineBodyScrollTop.value
const containerHeight = timelineBodyHeight.value || 600
const scrollBottom = scrollTop + containerHeight + ROW_HEIGHT * VERTICAL_BUFFER * 2
let cumulativeTop = 0
let processedCount = 0
// 🎯 Phase2scrollBottom
// 100
for (const resource of resources) {
positions.set(resource.id, cumulativeTop)
// 使
const layout = getResourceLayout(resource)
const resourceHeight = layout.totalHeight || 51
cumulativeTop += resourceHeight
processedCount++
// scrollBottom
//
if (cumulativeTop > scrollBottom + ROW_HEIGHT * 20) {
// 51px
for (let i = processedCount; i < resources.length; i++) {
positions.set(resources[i].id, cumulativeTop) //
cumulativeTop += 51 //
}
break
}
}
return positions
})
// v1.9.9 GanttChart
// GanttChartprovideresourceTaskLayoutsresourceRowPositions
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) => {
}
}
// bodySplitterBar
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++
}
}
// 🎯 GanttChartwatch
// v1.9.9 invalidateLayoutprops.resourcesdeep 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++
// }
}
// 🎯 GanttChartwatch
// v1.9.9 invalidateLayoutprops.resourcesdeep 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 = () => {
// Timelineheader +
// timelineDataheader
// visibleTimeRangeTaskBar
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>
+25 -12
View File
@@ -31,6 +31,8 @@ interface Props {
scrollLeft?: number
/** 可视区域宽度 */
containerWidth?: number
/** v1.9.9 当前渲染的任务数量限制(用于渐进式渲染) */
renderLimit?: number
}
const props = defineProps<Props>()
@@ -90,11 +92,17 @@ const canvasStyle = computed(() => ({
watch(() => props.tasks, () => {
if (isDraggingTaskBar.value) {
needsRecalculation.value = true
} else {
recalculateConflicts()
}
}, { deep: true })
// v1.9.9 renderLimitTaskBar
watch(() => props.renderLimit, () => {
// DOM
nextTick(() => {
recalculateConflicts()
})
})
//
watch(isDraggingTaskBar, (dragging) => {
if (!dragging && needsRecalculation.value) {
@@ -155,7 +163,6 @@ watch([canvasWidth, canvasHeight], () => {
// timelineDatacurrentTimeScale
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()
// conflictZonesscrollLeft
recalculateConflicts()
})
}, 100)
recalculateConflicts()
scrollTimer = null
}, 50)
})
// TaskBar
@@ -298,8 +305,14 @@ function recalculateConflictsIncremental(changedTaskId: string | number) {
//
function recalculateConflicts() {
// v1.9.9 TaskBar
// renderLimitN
const tasksToCheck = props.renderLimit !== undefined && props.renderLimit > 0
? props.tasks.slice(0, props.renderLimit)
: props.tasks
//
const conflicts = detectConflicts(props.tasks, props.resourceId)
const conflicts = detectConflicts(tasksToCheck, props.resourceId)
// v1.9.4 P1 - 使
conflictZones.value = conflicts.map((zone) => {
+6
View File
@@ -205,6 +205,8 @@ const conflictInfoList = computed(() => {
const conflictEnd = new Date(conflictTask.endDate).getTime()
//
// v1.9.10 resources 使 100%
//
let conflictPercent = 100
if (conflictTask.resources && Array.isArray(conflictTask.resources)) {
const allocation = conflictTask.resources.find(
@@ -214,6 +216,7 @@ const conflictInfoList = computed(() => {
conflictPercent = Math.max(20, Math.min(100, allocation.capacity))
}
}
// else: 100%
//
const overlapStart = Math.max(currentStart, conflictStart)
@@ -282,6 +285,8 @@ const totalOverloadPercent = computed(() => {
// 使 +1 endDate
if (tStart < overlapEndPlus && tEndPlus > overlapStart) {
// v1.9.10 resources 使 100%
//
let taskPercent = 100
if (task.resources && Array.isArray(task.resources)) {
const allocation = task.resources.find(
@@ -291,6 +296,7 @@ const totalOverloadPercent = computed(() => {
taskPercent = allocation.capacity
}
}
// else: 100%
intervalTotal += taskPercent
}
})
+211
View File
@@ -0,0 +1,211 @@
/**
* useResourceLayout -
* v1.9.9 Timeline.vue抽取
*
*
* - taskRowMap, rowHeights, totalHeight
* -
* -
*/
import { computed, ref, watch, type Ref } from 'vue'
import { assignTaskRows } from '../utils/taskLayoutUtils'
import type { Resource } from '../models/classes/Resource'
import type { Task } from '../models/classes/Task'
// 布局结果接口
export interface ResourceLayout {
taskRowMap: Map<string | number, number>
rowHeights: number[]
totalHeight: number
}
// 布局缓存(全局单例,避免重复创建)
const layoutCache = new Map<string, ResourceLayout>()
// 默认行高常量
const DEFAULT_ROW_HEIGHT = 51
const VERTICAL_BUFFER = 2
/**
* composable
*/
export function useResourceLayout(
viewMode: Ref<'task' | 'resource'>,
dataSource: Ref<Task[] | Resource[]>,
timelineBodyScrollTop: Ref<number>,
timelineBodyHeight: Ref<number>,
) {
// 性能监控计数器
const layoutsCallCount = ref(0)
const positionsCallCount = ref(0)
// v1.9.9 layoutTrigger用于手动触发布局重新计算(当资源tasks变化时)
const layoutTrigger = ref(0)
/**
*
* resourceRowPositions依赖完整数据
*/
const resourceTaskLayouts = computed(() => {
layoutsCallCount.value++
// 读取trigger以建立依赖
void layoutTrigger.value
const layoutMap = new Map<string | number, ResourceLayout>()
if (viewMode.value !== 'resource') {
return layoutMap
}
const resources = dataSource.value as Resource[]
// 为所有资源计算布局(使用缓存优化)
resources.forEach(resource => {
// v1.9.9 通过访问tasks.length建立对tasks数组变化的依赖
// 这样当tasks数组增删元素时,computed会自动重新计算
const tasks = (resource as Resource & { tasks?: Task[] }).tasks || []
void tasks.length // 建立依赖但不使用值
// 获取布局(内部会使用缓存)
const layout = getResourceLayout(resource)
layoutMap.set(resource.id, layout)
})
return layoutMap
})
/**
*
*/
const resourceRowPositions = computed(() => {
positionsCallCount.value++
const positions = new Map<string | number, number>()
if (viewMode.value !== 'resource') {
return positions
}
const resources = dataSource.value as Resource[]
const scrollTop = timelineBodyScrollTop.value
const containerHeight = timelineBodyHeight.value || 600
const scrollBottom = scrollTop + containerHeight + DEFAULT_ROW_HEIGHT * VERTICAL_BUFFER * 2
let cumulativeTop = 0
let processedCount = 0
// 懒加载计算:只计算到scrollBottom以下一定范围
for (const resource of resources) {
positions.set(resource.id, cumulativeTop)
const layout = getResourceLayout(resource)
const resourceHeight = layout.totalHeight || DEFAULT_ROW_HEIGHT
cumulativeTop += resourceHeight
processedCount++
// 优化:已经计算到scrollBottom之下足够远,停止详细计算
if (cumulativeTop > scrollBottom + DEFAULT_ROW_HEIGHT * 20) {
// 剩余资源使用近似位置(假设都是51px高度)
for (let i = processedCount; i < resources.length; i++) {
positions.set(resources[i].id, cumulativeTop)
cumulativeTop += DEFAULT_ROW_HEIGHT
}
break
}
}
return positions
})
/**
* +
*/
function getResourceLayout(resource: Resource): ResourceLayout {
const resourceTasks = (resource as Resource & { tasks?: Task[] }).tasks || []
// 缓存key包含任务时间信息,因为时间交汇会影响换行布局
const timeHash = resourceTasks
.map((t: Task) => `${t.id}-${t.startDate || ''}-${t.endDate || ''}`)
.join('|')
const cacheKey = `${resource.id}-${resourceTasks.length}-${timeHash}`
// 检查缓存
if (layoutCache.has(cacheKey)) {
return layoutCache.get(cacheKey)!
}
// 未命中缓存,重新计算
let result: ResourceLayout
if (resourceTasks.length > 0) {
result = assignTaskRows(resourceTasks, DEFAULT_ROW_HEIGHT)
} else {
result = {
taskRowMap: new Map(),
rowHeights: [DEFAULT_ROW_HEIGHT],
totalHeight: DEFAULT_ROW_HEIGHT,
}
}
layoutCache.set(cacheKey, result)
return result
}
/**
* 100
*/
watch(dataSource, () => {
if (layoutCache.size > 100) {
const keysToDelete = Array.from(layoutCache.keys()).slice(0, layoutCache.size - 100)
keysToDelete.forEach(key => layoutCache.delete(key))
}
})
/**
*
*/
function clearLayoutCache() {
layoutCache.clear()
}
/**
*
* @param resourceId ID
*/
function clearResourceCache(resourceId: string | number) {
const keysToDelete = Array.from(layoutCache.keys()).filter(key =>
key.startsWith(`${resourceId}-`),
)
keysToDelete.forEach(key => layoutCache.delete(key))
}
/**
* v1.9.9 使
* tasks数组被外部修改后调用
* @param resourceIds ID数组
*/
function invalidateLayout(resourceIds?: (string | number)[]) {
if (resourceIds && resourceIds.length > 0) {
// 清除指定资源的缓存
resourceIds.forEach(id => clearResourceCache(id))
} else {
// 清除所有缓存
layoutCache.clear()
}
// 触发computed重新计算
layoutTrigger.value++
}
return {
// 计算结果
resourceTaskLayouts,
resourceRowPositions,
// 辅助函数
getResourceLayout,
clearLayoutCache,
clearResourceCache,
invalidateLayout,
// 性能监控
layoutsCallCount,
positionsCallCount,
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* useViewMode -
* v1.9.9 inject复杂度
*
*
* - inject逻辑
* - 访
* -
*/
import { inject, computed, ref, type Ref, type ComputedRef } from 'vue'
import type { Task } from '../models/classes/Task'
import type { Resource } from '../models/classes/Resource'
import type { TaskListConfig } from '../models/configs/TaskListConfig'
import type { ResourceListConfig } from '../models/configs/ResourceListConfig'
/**
* 使
*
*
* ```ts
* const { viewMode, dataSource, isResourceView, isTaskView } = useViewMode()
* ```
*/
export function useViewMode() {
// 注入视图模式
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
// 注入数据源
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
// 注入列表配置
const listConfig = inject<ComputedRef<TaskListConfig | ResourceListConfig | null>>(
'gantt-list-config',
computed(() => null),
)
// 派生状态:是否资源视图
const isResourceView = computed(() => viewMode.value === 'resource')
// 派生状态:是否任务视图
const isTaskView = computed(() => viewMode.value === 'task')
return {
// 原始状态
viewMode,
dataSource,
listConfig,
// 派生状态
isResourceView,
isTaskView,
}
}
/**
* GanttChart等父组件中使用
*
*
* ```ts
* const { provideViewMode } = useViewMode()
* provideViewMode(currentViewMode, currentDataSource, currentListConfig)
* ```
*/
export function provideViewMode(
viewMode: Ref<'task' | 'resource'>,
dataSource: ComputedRef<Task[] | Resource[]>,
listConfig: ComputedRef<TaskListConfig | ResourceListConfig | null>,
) {
// 注意:实际的provide调用仍在GanttChart中进行
// 这个函数主要用于类型安全和文档目的
return {
viewMode,
dataSource,
listConfig,
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ export { default as MilestonePoint } from './components/MilestonePoint.vue'
export { default as MilestoneDialog } from './components/MilestoneDialog.vue'
export { default as 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 -1
View File
@@ -1,4 +1,4 @@
import type { Resource } from '../types/Resource'
import type { Resource } from './Resource'
// Task 类型定义
export interface Task {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Resource } from '../types/Resource'
import type { Resource } from '../classes/Resource'
/**
*
+4 -2
View File
@@ -56,7 +56,8 @@ export function detectConflicts(
tasks: Task[],
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)
-6
View File
@@ -81,13 +81,7 @@ export const perfMonitor = {
*
*/
measure<T>(fn: () => T): T {
const start = performance.now()
const result = fn()
const end = performance.now()
const duration = end - start
if (duration > 5) { }
return result
},
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Task } from '../models/classes/Task'
import type { Resource } from '../models/types/Resource'
import type { Resource } from '../models/classes/Resource'
import { detectConflicts } from './conflictUtils'
/**