v1.9.0-rc.8 - SIT finished
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+59
-211
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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