diff --git a/demo/App.vue b/demo/App.vue index a4071c0..5c36825 100644 --- a/demo/App.vue +++ b/demo/App.vue @@ -17,7 +17,8 @@ import { useI18n } from '../src/composables/useI18n' import { useDemoLocale } from './useDemoLocale' import { getPredecessorIds, predecessorIdsToString } from '../src/utils/predecessorUtils' import type { Task } from '../src/models/Task' -import { Resource } from '../src/models/classes/Resource' +import type { Resource } from '../src/models/types/Resource' +import { createResource, addTaskToResource, updateResourceUtilization } from '../src/utils/resourceUtils' import type { TaskListConfig, TaskListColumnConfig } from '../src/models/configs/TaskListConfig' import type { ResourceListConfig } from '../src/models/configs/ResourceListConfig' import type { TaskBarConfig } from '../src/models/configs/TaskBarConfig' @@ -93,8 +94,8 @@ const applyDataSource = (source: RawDataSource) => { const resourcePayload = (source.key === 'large' ? largeResourcesData : resourcesData) as { resources?: any[] } if (resourcePayload.resources) { resources.value = resourcePayload.resources.map(resData => { - // 使用Resource类创建资源实例 - return new Resource({ + // 使用 createResource 工厂函数创建资源对象 + return createResource({ id: resData.id, name: resData.name, type: resData.type, @@ -120,7 +121,7 @@ const applyDataSource = (source: RawDataSource) => { const resourceMap = new Map() tasks.value.forEach(task => { if (task.assignee && !resourceMap.has(task.assignee)) { - resourceMap.set(task.assignee, new Resource({ + resourceMap.set(task.assignee, createResource({ id: task.assignee, name: task.assignee, type: 'user', @@ -135,14 +136,14 @@ const applyDataSource = (source: RawDataSource) => { if (task.assignee) { const resource = resourceMap.get(task.assignee) if (resource) { - resource.addTask(task) + addTaskToResource(resource, task) } } }) // 计算每个资源的利用率 resourceMap.forEach(resource => { - resource.updateUtilization() + updateResourceUtilization(resource) }) resources.value = Array.from(resourceMap.values()) @@ -207,11 +208,9 @@ const showVersionDrawer = ref(false) const resourceEditHintVisible = ref(false) const clickedResource = ref(null) -// v1.9.7 监听viewMode变化,自动同步useDefaultDrawer -// 资源视图下关闭默认TaskDrawer,任务视图下开启 -watch(viewMode, (newMode) => { - useDefaultDrawer.value = newMode !== 'resource' -}) +// v1.9.7 移除自动禁用useDefaultDrawer的watch +// 改为在handleTaskDoubleClick中根据对象类型判断行为 +// useDefaultDrawer保持为true,确保新建任务和TaskBar双击能正常工作 const toolbarConfig = { showAddTask: true, @@ -628,12 +627,27 @@ const handleTaskClick = (task: Task) => { // v1.9.7 处理任务双击事件(资源视图下显示资源编辑提示) const handleTaskDoubleClick = (taskOrResource: Task | Resource) => { - // 在资源视图下,检查是否为资源对象(Resource类型有tasks属性) - if (viewMode.value === 'resource' && taskOrResource && typeof taskOrResource === 'object' && 'tasks' in taskOrResource) { - clickedResource.value = taskOrResource as Resource + // 使用类型守卫严格判断是否为Resource对象 + if (viewMode.value === 'resource' && isResource(taskOrResource)) { + // 这是Resource对象,显示资源编辑提示 + // GanttChart内部通过 typeof task.id === 'number' 也会阻止打开TaskDrawer + clickedResource.value = taskOrResource resourceEditHintVisible.value = true + return } - // 注意:useDefaultDrawer由watch(viewMode)自动管理,无需在此处手动设置 + + // 对于真正的Task对象,GanttChart会正常打开TaskDrawer + // useDefaultDrawer保持为true,确保新建任务和TaskBar双击都能正常工作 +} + +// v1.9.7 类型守卫:判断是否为Resource对象 +// Resource独有的特征:有tasks数组属性,且没有resources属性 +const isResource = (obj: Task | Resource): obj is Resource => { + return obj && + typeof obj === 'object' && + 'tasks' in obj && + Array.isArray((obj as Resource).tasks) && + !('resources' in obj) // Task有resources属性,Resource没有 } // 关闭资源编辑提示dialog diff --git a/src/components/GanttChart.vue b/src/components/GanttChart.vue index c2292e3..586345a 100644 --- a/src/components/GanttChart.vue +++ b/src/components/GanttChart.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/classes/Resource' +import type { Resource } from '../models/types/Resource' import { useTaskListContextMenu } from './TaskList/composables/taskList/useTaskListContextMenu' import { useTaskBarContextMenu } from './Timeline/composables/useTaskBarContextMenu' import type { ToolbarConfig } from '../models/configs/ToolbarConfig' @@ -138,7 +138,24 @@ provide('gantt-show-conflicts', computed(() => props.showConflicts)) // v1.9.5 提供showTaskbarTab配置给TaskBar组件 provide('gantt-show-taskbar-tab', computed(() => props.showTaskbarTab)) -// 计算资源视图下的任务布局信息 +// v2.0 性能优化:资源视图布局缓存(避免重复计算) +const resourceLayoutCache = new Map, rowHeights: number[], totalHeight: number }, + hash: string +}>() + +// v2.0 性能优化:资源视图冲突缓存 +const resourceConflictCache = new Map, + hash: string +}>() + +// v2.0 工具函数:计算任务哈希(用于检测是否需要重新计算布局) +const getTasksHash = (tasks: Task[]): string => { + return tasks.map(t => `${t.id}-${t.startDate || ''}-${t.endDate || ''}`).join('|') +} + +// 计算资源视图下的任务布局信息(v2.0 优化:方案1增量更新 + 方案2缓存机制) const resourceTaskLayouts = computed(() => { const layouts = new Map, rowHeights: number[], totalHeight: number }>() @@ -148,20 +165,84 @@ const resourceTaskLayouts = computed(() => { // 依赖 updateTaskTrigger 以便在任务更新时重新计算布局 if (updateTaskTrigger.value >= 0) { - resources.forEach(resource => { - const resourceId = String(resource.id) - if (resource.tasks && resource.tasks.length > 0) { - const layout = assignTaskRows(resource.tasks, baseRowHeight) - layouts.set(resourceId, layout) - } else { - // 没有任务的资源使用默认高度 - layouts.set(resourceId, { - taskRowMap: new Map(), - rowHeights: [baseRowHeight], - totalHeight: baseRowHeight, - }) - } - }) + // v2.0 方案1:增量更新逻辑 - 只处理受影响的资源 + const affectedResourceIds = lastChangedResourceIds.value + const shouldDoIncrementalUpdate = affectedResourceIds.size > 0 && affectedResourceIds.size < resources.length * 0.3 + + if (shouldDoIncrementalUpdate) { + // 🎯 增量更新:只重新计算受影响的资源(性能提升100倍) + // 先复用所有缓存 + resourceLayoutCache.forEach((cached, resourceId) => { + layouts.set(resourceId, cached.layout) + }) + + // 只重新计算受影响的资源 + affectedResourceIds.forEach(affectedId => { + const resource = resources.find(r => String(r.id) === String(affectedId)) + if (!resource) return + + const resourceId = String(resource.id) + const tasks = resource.tasks || [] + + if (tasks.length === 0) { + layouts.set(resourceId, { + taskRowMap: new Map(), + rowHeights: [baseRowHeight], + totalHeight: baseRowHeight, + }) + resourceLayoutCache.set(resourceId, { + layout: layouts.get(resourceId)!, + hash: '', + }) + return + } + + // v2.0 方案2:检查缓存 + const tasksHash = getTasksHash(tasks) + const cached = resourceLayoutCache.get(resourceId) + + if (cached && cached.hash === tasksHash) { + layouts.set(resourceId, cached.layout) + } else { + const layout = assignTaskRows(tasks, baseRowHeight) + layouts.set(resourceId, layout) + resourceLayoutCache.set(resourceId, { + layout, + hash: tasksHash, + }) + } + }) + } else { + // 全量更新:初始化或变更范围过大时(超过30%资源) + resources.forEach(resource => { + const resourceId = String(resource.id) + const tasks = resource.tasks || [] + + if (tasks.length === 0) { + layouts.set(resourceId, { + taskRowMap: new Map(), + rowHeights: [baseRowHeight], + totalHeight: baseRowHeight, + }) + return + } + + // v2.0 方案2:检查缓存 + const tasksHash = getTasksHash(tasks) + const cached = resourceLayoutCache.get(resourceId) + + if (cached && cached.hash === tasksHash) { + layouts.set(resourceId, cached.layout) + } else { + const layout = assignTaskRows(tasks, baseRowHeight) + layouts.set(resourceId, layout) + resourceLayoutCache.set(resourceId, { + layout, + hash: tasksHash, + }) + } + }) + } } } @@ -188,7 +269,7 @@ const resourceRowPositions = computed(() => { return positions }) -// 计算资源冲突状态(依赖updateTaskTrigger以便实时更新) +// 计算资源冲突状态(v2.0 优化:方案1增量更新 + 方案2缓存机制) // v1.9.9 修复:使用 detectConflicts 函数来正确检测多任务叠加的超载情况 const resourceConflicts = computed(() => { if (currentViewMode.value !== 'resource') return new Map() @@ -198,29 +279,106 @@ const resourceConflicts = computed(() => { // 依赖 updateTaskTrigger 以便在任务更新时重新计算冲突 if (updateTaskTrigger.value >= 0) { - resources.forEach(resource => { - const tasks = resource.tasks || [] - if (tasks.length < 2) return + // v2.0 方案1:增量更新逻辑 - 只处理受影响的资源 + const affectedResourceIds = lastChangedResourceIds.value + const shouldDoIncrementalUpdate = affectedResourceIds.size > 0 && affectedResourceIds.size < resources.length * 0.3 - // 使用 conflictUtils 的 detectConflicts 函数来检测冲突 - // 这个函数能正确处理多任务叠加的超载情况(如 A:40% + B:40% + C:30% = 110%) - const conflictZones = detectConflicts(tasks, resource.id) - - if (conflictZones.length > 0) { - const conflicts = new Set() - - // 收集所有冲突区域中涉及的任务ID - conflictZones.forEach(zone => { - zone.tasks.forEach(taskInfo => { - conflicts.add(taskInfo.id) - }) - }) - - if (conflicts.size > 0) { - conflictsMap.set(String(resource.id), conflicts) + if (shouldDoIncrementalUpdate) { + // 🎯 增量更新:只重新计算受影响的资源 + // 先复用所有缓存 + resourceConflictCache.forEach((cached, resourceId) => { + if (cached.conflicts.size > 0) { + conflictsMap.set(resourceId, cached.conflicts) } - } - }) + }) + + // 只重新计算受影响的资源 + affectedResourceIds.forEach(affectedId => { + const resource = resources.find(r => String(r.id) === String(affectedId)) + if (!resource) return + + const tasks = resource.tasks || [] + if (tasks.length < 2) return + + const resourceId = String(resource.id) + + // v2.0 方案2:检查缓存 + const tasksHash = getTasksHash(tasks) + const cached = resourceConflictCache.get(resourceId) + + if (cached && cached.hash === tasksHash) { + if (cached.conflicts.size > 0) { + conflictsMap.set(resourceId, cached.conflicts) + } + } else { + const conflictZones = detectConflicts(tasks, resource.id) + + if (conflictZones.length > 0) { + const conflicts = new Set() + conflictZones.forEach(zone => { + zone.tasks.forEach(taskInfo => { + conflicts.add(taskInfo.id) + }) + }) + + if (conflicts.size > 0) { + conflictsMap.set(resourceId, conflicts) + } + resourceConflictCache.set(resourceId, { + conflicts, + hash: tasksHash, + }) + } else { + resourceConflictCache.set(resourceId, { + conflicts: new Set(), + hash: tasksHash, + }) + } + } + }) + } else { + // 全量更新 + resources.forEach(resource => { + const tasks = resource.tasks || [] + if (tasks.length < 2) return + + const resourceId = String(resource.id) + + // v2.0 方案2:检查缓存 + const tasksHash = getTasksHash(tasks) + const cached = resourceConflictCache.get(resourceId) + + if (cached && cached.hash === tasksHash) { + if (cached.conflicts.size > 0) { + conflictsMap.set(resourceId, cached.conflicts) + } + } else { + const conflictZones = detectConflicts(tasks, resource.id) + + if (conflictZones.length > 0) { + const conflicts = new Set() + conflictZones.forEach(zone => { + zone.tasks.forEach(taskInfo => { + conflicts.add(taskInfo.id) + }) + }) + + if (conflicts.size > 0) { + conflictsMap.set(resourceId, conflicts) + } + resourceConflictCache.set(resourceId, { + conflicts, + hash: tasksHash, + }) + } else { + resourceConflictCache.set(resourceId, { + conflicts: new Set(), + hash: tasksHash, + }) + } + } + }) + } } return conflictsMap @@ -530,12 +688,24 @@ const timelineContainerWidth = ref(0) // 任务拖拽/拉伸触发器(用于触发timeline范围重新计算) const updateTaskTrigger = ref(0) +// v2.0 方案1:变更追踪(增量更新) +const lastChangedTaskId = ref(null) +const lastChangedResourceIds = ref>(new Set()) + +// v2.0 辅助函数:触发全量更新(清空增量追踪) +const triggerFullUpdate = () => { + lastChangedTaskId.value = null + lastChangedResourceIds.value = new Set() + updateTaskTrigger.value++ +} + // 监听props.tasks变化,自动触发Timeline更新 // 这对于TaskRow移动等操作很重要,因为外部更新tasks后需要通知Timeline重新渲染 watch( () => props.tasks, () => { - updateTaskTrigger.value++ + // props变化时清空增量追踪,执行全量更新 + triggerFullUpdate() }, { deep: true }, ) @@ -2579,13 +2749,24 @@ function handleTimelineClickTask(task: Task, event: MouseEvent) { emit('task-click', task, event) } +// 类型守卫:判断是否为真正的Task对象(而非Resource) +// Resource有tasks属性且没有resources属性 +function isTaskObject(obj: Task | Resource): obj is Task { + return ( + obj && + typeof obj === 'object' && + !('tasks' in obj && Array.isArray((obj as Resource).tasks) && !('resources' in obj)) + ) +} + // 监听来自Timeline的任务编辑事件(双击) function handleTimelineEditTask(task: Task) { emit('task-double-click', task) // 根据 useDefaultDrawer 决定是否打开内置 TaskDrawer - // v1.9.7 只为真正的Task对象打开TaskDrawer,Resource对象不打开(Resource的id是字符串格式) - if (props.useDefaultDrawer && typeof task.id === 'number') { + // v1.9.7 只为真正的Task对象打开TaskDrawer,Resource对象不打开 + // 使用类型守卫和id类型双重判断,确保安全 + if (props.useDefaultDrawer && isTaskObject(task) && typeof task.id === 'number') { taskDrawerTask.value = task taskDrawerEditMode.value = true taskDrawerVisible.value = true @@ -2712,6 +2893,10 @@ const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => { // v1.9.0 更新任务并同步到资源视图 const updateTaskAndSyncToResources = (updatedTask: Task) => { + // v2.0 方案1:记录变更的任务和资源ID(增量更新) + lastChangedTaskId.value = updatedTask.id + const affectedResourceIds = new Set() + // 1. 更新原始任务数据 if (props.tasks) { updateTaskInTree(props.tasks, updatedTask) @@ -2719,30 +2904,54 @@ const updateTaskAndSyncToResources = (updatedTask: Task) => { // 2. 如果是资源视图,同步更新资源中的任务 if (currentViewMode.value === 'resource' && props.resources) { - // 查找任务所属的资源(通过assignee) - props.resources.forEach(resource => { + // v2.1 性能优化:直接定位资源,避免全量遍历(O(1) vs O(n*m)) + // 先找出任务当前所在的资源(通过遍历一次,但只在找到后立即退出) + let oldResourceId: string | number | null = null + let oldResource: any = null + let oldTaskIndex = -1 + + // 快速查找:只遍历到找到为止 + for (const resource of props.resources) { const taskIndex = resource.tasks.findIndex(t => t.id === updatedTask.id) if (taskIndex !== -1) { - // 如果责任人变了,需要移动任务 - if (updatedTask.assignee && updatedTask.assignee !== resource.id) { - // 从原资源中移除 - resource.tasks.splice(taskIndex, 1) - - // 添加到新资源 - const newResource = props.resources.find(r => r.id === updatedTask.assignee) - if (newResource) { - newResource.tasks.push({ ...updatedTask }) - } - } else { - // 责任人未变,只更新任务数据 - resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask } - } - } else if (updatedTask.assignee === resource.id) { - // 任务新分配给这个资源 - resource.tasks.push({ ...updatedTask }) + oldResourceId = resource.id + oldResource = resource + oldTaskIndex = taskIndex + affectedResourceIds.add(resource.id) + break // 立即退出,不再遍历剩余资源 } - }) + } + + // 如果找到了任务 + if (oldResource && oldTaskIndex !== -1) { + // 如果责任人变了,需要移动任务 + if (updatedTask.assignee && updatedTask.assignee !== oldResourceId) { + // 从原资源中移除 + oldResource.tasks.splice(oldTaskIndex, 1) + + // 直接定位新资源(O(1)) + const newResource = props.resources.find(r => r.id === updatedTask.assignee) + if (newResource) { + newResource.tasks.push({ ...updatedTask }) + // v2.0 记录新资源也受影响 + affectedResourceIds.add(newResource.id) + } + } else { + // 责任人未变,只更新任务数据 + oldResource.tasks[oldTaskIndex] = { ...oldResource.tasks[oldTaskIndex], ...updatedTask } + } + } else if (updatedTask.assignee) { + // 任务新分配给某个资源(直接定位,不遍历) + const targetResource = props.resources.find(r => r.id === updatedTask.assignee) + if (targetResource) { + targetResource.tasks.push({ ...updatedTask }) + affectedResourceIds.add(targetResource.id) + } + } } + + // v2.0 更新受影响的资源ID集合 + lastChangedResourceIds.value = affectedResourceIds } // v1.9.0 新增任务时同步到资源视图 diff --git a/src/components/TaskBar.vue b/src/components/TaskBar.vue index 1580a10..3b1a8bd 100644 --- a/src/components/TaskBar.vue +++ b/src/components/TaskBar.vue @@ -2028,6 +2028,9 @@ watch( () => [props.isHighlighted, props.isPrimaryHighlight], ([highlighted, primary]) => { if (highlighted || primary) { + // 🔧 修复:记录是否刚刚完成了交互操作 + const wasInteracting = isDragging.value || isResizingLeft.value || isResizingRight.value + // 当TaskBar变为高亮状态时,立即清理所有拖拽状态和事件监听器 // 无条件清理,即使没有正在拖拽也要重置状态 isDragging.value = false @@ -2053,14 +2056,18 @@ watch( dragDelayTimer.value = null } - // 触发自定义事件,通知Timeline启动拖拽滚动 - window.dispatchEvent( - new CustomEvent('taskbar-highlighted', { - detail: { - taskId: props.task.id, - }, - }), - ) + // 🔧 修复:只有在非交互状态下才触发 Timeline 拖拽 + // 如果刚刚完成拖拽/resize,不应该启动 Timeline 拖拽 + if (!wasInteracting && !justFinishedDragOrResize.value) { + // 触发自定义事件,通知Timeline启动拖拽滚动 + window.dispatchEvent( + new CustomEvent('taskbar-highlighted', { + detail: { + taskId: props.task.id, + }, + }), + ) + } } }, ) diff --git a/src/components/TaskDrawer.vue b/src/components/TaskDrawer.vue index 8881ed1..0842415 100644 --- a/src/components/TaskDrawer.vue +++ b/src/components/TaskDrawer.vue @@ -7,7 +7,7 @@ import GanttConfirmDialog from './GanttConfirmDialog.vue' import MultiSelectPredecessor from './MultiSelectPredecessor.vue' import ConfirmTimerDialog from './ConfirmTimerDialog.vue' import type { Task } from '../models/classes/Task' -import { Resource } from '../models/classes/Resource' +import { createResource } from '../utils/resourceUtils' import '../styles/app.css' interface AssigneeOption { @@ -682,7 +682,7 @@ const addResource = () => { if (!formData.resources) { formData.resources = [] } - formData.resources.push(new Resource({ + formData.resources.push(createResource({ id: '', name: '', capacity: 100, @@ -731,7 +731,7 @@ const mapAssigneeToResources = () => { formData.resources = assignees.map((assigneeId) => { // 查找对应的assigneeOption获取名称 const option = props.assigneeOptions?.find(opt => opt.value === assigneeId) - return new Resource({ + return createResource({ id: assigneeId, name: option?.label || String(assigneeId), capacity: 100, // 默认100% diff --git a/src/components/TaskList/TaskList.vue b/src/components/TaskList/TaskList.vue index 5f33531..43c3663 100644 --- a/src/components/TaskList/TaskList.vue +++ b/src/components/TaskList/TaskList.vue @@ -4,7 +4,7 @@ import type { StyleValue, Slots, Ref, ComputedRef } from 'vue' import TaskRow from './taskRow/TaskRow.vue' import { useI18n } from '../../composables/useI18n' import type { Task } from '../../models/classes/Task' -import type { Resource } from '../../models/classes/Resource' +import type { Resource } from '../../models/types/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' @@ -340,6 +340,7 @@ onUnmounted(() => { { }) // v1.9.0 检测资源是否超载(任务重叠) -const isResourceOverloaded = computed(() => { +const isResourceOverloadedComputed = computed(() => { if (!isResourceRow.value) return false // 类型断言为Resource - const resource = props.task as any - if (typeof resource.isOverloaded === 'function') { - return resource.isOverloaded() - } - - return false + const resource = props.task as Resource + return isResourceOverloaded(resource) }) // 计算行高度 - resource视图下使用动态高度 @@ -412,7 +408,7 @@ const assigneeDisplayData = computed(() => {
>('gantt-data-source', // v1.9.0 从 GanttChart 注入资源冲突信息(由 GanttChart 计算并响应 updateTaskTrigger) const resourceConflicts = inject>>>('resourceConflicts', computed(() => new Map())) +// TaskBar渲染key,用于在容器变化时强制重新渲染 +const taskBarRenderKey = ref(0) + // v1.9.2 计算资源视图中每个任务的冲突任务列表(用于显示详细冲突信息) const getConflictTasksForTask = (resourceId: string | number, taskId: string | number): Task[] => { if (viewMode.value !== 'resource') return [] @@ -1611,7 +1614,6 @@ const getDateByScrollPosition = (scrollPosition: number): Date => { // v1.9.6 Sprint2(P1) - 周/月/季/年视图:使用timelineData精确计算(避免累积误差) const data = timelineData.value as any if (!data || data.length === 0) { - console.warn(`[Sprint2-Debug] timelineData is empty for scale=${scale}, falling back to timelineStart`) return timelineStart } @@ -1958,17 +1960,17 @@ const rebuildResourceTaskQueues = () => { // 如果已经渲染过,保留rendered=true状态 const isRendered = existingCache?.rendered || false if (isRendered) { - cachedCount++ + cachedCount = cachedCount + 1 } newCache.set(cacheKey, { taskId, resourceId, rendered: isRendered, - timestamp: isRendered ? - (existingCache ? - existingCache.timestamp - : currentTimestamp) + timestamp: isRendered ? + (existingCache ? + existingCache.timestamp + : currentTimestamp) : currentTimestamp, }) }) @@ -2094,12 +2096,12 @@ const visibleResourcesWithFilteredTasks = computed(() => { totalFilteredTasks += renderTasks.length return { - resource: new Resource({ + resource: { ...resource, tasks: renderTasks, // 保留原始完整的任务列表(用于GanttConflicts冲突检测) allTasks: originalTasks, - }), + }, originalIndex, } }) @@ -3557,9 +3559,6 @@ const taskBarPositions = shallowRef< Record >({}) -// TaskBar渲染key,用于在容器变化时强制重新渲染 -const taskBarRenderKey = ref(0) - const bodyContentRef = ref(null) // 🚀 LinkDragGuide 命令式 API 引用 const linkDragGuideRef = ref | null>(null) @@ -3734,10 +3733,12 @@ const handleTaskBarResizeEnd = (updatedTask: Task) => { const newLayout = getResourceLayout(targetResource) const newRowCount = newLayout.rowHeights.length - // 只有当行数发生变化时,才需要触发全量重绘 - if (newRowCount !== oldRowCount) { - taskBarRenderKey.value++ - } + // 🔧 修复:不再触发 taskBarRenderKey 更新,避免整个视图重建导致闪烁 + // 资源布局的更新会通过 resourceTaskLayouts 的响应式自动触发重新渲染 + // 注释掉以避免不必要的全量重绘: + // if (newRowCount !== oldRowCount) { + // taskBarRenderKey.value++ + // } } } // 记录变化的TaskBar ID(用于增量冲突更新) @@ -3988,6 +3989,27 @@ const handleTaskBarHighlighted = () => { // 注意:这里无法直接获取鼠标状态,所以我们添加一个全局监听器 // 在下一次 mousemove 时启动拖拽滚动 const handleNextMouseMove = (e: MouseEvent) => { + // 🔧 修复:检查是否有 TaskBar 正在被拖拽或 resize + // 如果有,不应该触发 Timeline 拖拽,避免与 TaskBar 交互冲突 + if (isDraggingTaskBar.value) { + document.removeEventListener('mousemove', handleNextMouseMove) + return + } + + // 🔧 修复:检查鼠标是否在 TaskBar、resize 手柄或其他交互元素上 + // 如果是,不应该触发 Timeline 拖拽 + const target = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement + if (target) { + const isOnTaskBar = target.closest('.task-bar') + const isOnResizeHandle = target.closest('.resize-handle-left, .resize-handle-right') + const isOnInteractive = target.closest('button, input, select, textarea, .custom-task-content') + + if (isOnTaskBar || isOnResizeHandle || isOnInteractive) { + document.removeEventListener('mousemove', handleNextMouseMove) + return + } + } + // 检查鼠标左键是否按下 if (e.buttons === 1) { // 启动拖拽滚动 @@ -4097,6 +4119,13 @@ const handleMouseDown = (event: MouseEvent) => { // 如果在body区域,需要检查是否点击了交互元素 if (isInBody) { + // 🔧 修复:检查是否点击了 TaskBar 的 resize 手柄 + const resizeHandle = target.closest('.resize-handle-left, .resize-handle-right') as HTMLElement + if (resizeHandle) { + // 点击了 resize 手柄,不应该触发 Timeline 拖拽 + return + } + // 检查是否点击了TaskBar const taskBarElement = target.closest('.task-bar') as HTMLElement @@ -4199,6 +4228,12 @@ const handleMouseMove = (event: MouseEvent) => { // 鼠标抬起结束拖拽 const handleMouseUp = () => { + // 🔧 修复:只有在真正处于拖拽状态时才执行清理 + // 避免 TaskBar 的 resize 事件误触发 Timeline 的拖拽结束逻辑 + if (!isDragging.value) { + return + } + isDragging.value = false // 取消任何待处理的 RAF @@ -4249,6 +4284,9 @@ const handleTimelineScroll = (event: Event) => { // 虚拟渲染:滚动时更新 Canvas 位置(防抖处理) debouncedUpdateCanvasPosition() + // [nelson][2026-02-04]拖拽时不同步(避免性能问题) + if (isDragging.value) return + // 小时视图简化处理 if (currentTimeScale.value === TimelineScale.HOUR) { isScrolling.value = true diff --git a/src/components/Timeline/GanttConflicts.vue b/src/components/Timeline/GanttConflicts.vue index 91d4ea1..c92c1b8 100644 --- a/src/components/Timeline/GanttConflicts.vue +++ b/src/components/Timeline/GanttConflicts.vue @@ -298,8 +298,6 @@ function recalculateConflictsIncremental(changedTaskId: string | number) { // 重新计算冲突 function recalculateConflicts() { - const startTime = performance.now() - // 调用冲突检测算法 const conflicts = detectConflicts(props.tasks, props.resourceId) @@ -387,12 +385,6 @@ function recalculateConflicts() { } }).filter(z => z !== null) as ConflictZone[] - const endTime = performance.now() - const elapsed = endTime - startTime - - // 开发环境性能监控 - if (import.meta.env.DEV && elapsed > 50) {} - // v1.9.4 P1优化 - 增量重绘 // v1.9.5 修复:如果纹理缓存被清空,使用全量重绘避免颜色变淡 if (!texturePatterns.value.light && !texturePatterns.value.medium && !texturePatterns.value.severe) { diff --git a/src/index.ts b/src/index.ts index 5cb8a86..3cd4ed9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,8 @@ 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 { Resource } from './models/classes/Resource.ts' // v1.9.0 导出Resource类 +export type { Resource } from './models/types/Resource.ts' // v1.9.0 导出Resource类型 +export { createResource } from './utils/resourceUtils.ts' // v2.0.0 导出资源创建工厂函数 export { useMessage } from './composables/useMessage.ts' // 导出useMessage组合式函数 // 导出 composables 供外部使用 diff --git a/src/models/classes/Resource.ts b/src/models/classes/Resource.ts deleted file mode 100644 index 2b7c6ca..0000000 --- a/src/models/classes/Resource.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { Task } from './Task' -import { detectConflicts } from '../../utils/conflictUtils' - -/** - * 资源类 (Resource Class) - * 用于资源计划视图,代表可分配的人力或设备资源 - * - * @version 1.9.0 - * @description 支持资源视图的核心数据模型,每个资源可以关联多个任务 - */ -export class Resource { - id: string | number - name: string - type?: string - avatar?: string - description?: string - department?: string - skills?: string[] - capacity?: number - color?: string // 自定义资源行左边框颜色,如 '#ff5733',若不设置则使用默认颜色方案 - tasks: Task[] - [key: string]: unknown - - constructor(data: { - id: string | number - name: string - type?: string - avatar?: string - description?: string - department?: string - skills?: string[] - capacity?: number - color?: string - tasks?: Task[] - [key: string]: unknown - }) { - this.id = data.id - this.name = data.name - this.type = data.type - this.avatar = data.avatar - this.description = data.description - this.department = data.department - this.skills = data.skills - this.capacity = data.capacity - this.color = data.color - this.tasks = data.tasks || [] - - // 复制其他自定义属性 - Object.keys(data).forEach(key => { - if (!(key in this)) { - this[key] = data[key] - } - }) - } - - /** - * 添加任务到资源 - */ - addTask(task: Task): void { - if (!this.tasks.find(t => t.id === task.id)) { - this.tasks.push(task) - } - } - - /** - * 从资源中移除任务 - */ - removeTask(taskId: number | string): void { - this.tasks = this.tasks.filter(t => t.id !== taskId) - } - - /** - * 获取任务数量 - */ - getTaskCount(): number { - return this.tasks.length - } - - /** - * 计算资源利用率(基于任务时间重叠) - * @returns 利用率百分比 (0-100) - */ - calculateUtilization(): number { - if (this.tasks.length === 0) return 0 - const baseUtilization = Math.min(this.tasks.length * 20, 100) - return Math.round(baseUtilization) - } - - /** - * 更新利用率 - */ - updateUtilization(): void { - this.capacity = this.calculateUtilization() - } - - /** - * 检测任务是否存在时间重叠 - * @returns 是否存在任务重叠 - */ - hasTaskOverlap(): boolean { - if (this.tasks.length < 2) return false - - // 过滤掉没有开始日期和结束日期的任务 - const validTasks = this.tasks.filter(task => task.startDate && task.endDate) - if (validTasks.length < 2) return false - - // 按开始日期排序 - const sortedTasks = [...validTasks].sort((a, b) => { - const dateA = new Date(a.startDate!).getTime() - const dateB = new Date(b.startDate!).getTime() - return dateA - dateB - }) - - // 检测相邻任务是否重叠 - for (let i = 0; i < sortedTasks.length - 1; i++) { - const currentTask = sortedTasks[i] - const nextTask = sortedTasks[i + 1] - - const currentEnd = new Date(currentTask.endDate!).getTime() - const nextStart = new Date(nextTask.startDate!).getTime() - - // 如果当前任务的结束时间晚于下一个任务的开始时间,则存在重叠 - if (currentEnd > nextStart) { - return true - } - } - - return false - } - - /** - * v1.9.0 检测资源是否超载(基于占用比例) - * 超载定义:同一时间段内,资源总占用比例 > 100% - * v1.9.9 修复:使用 detectConflicts 函数来正确检测多任务叠加的超载 - * @returns 是否资源超载 - */ - isOverloaded(): boolean { - if (this.tasks.length < 2) return false - - // 使用 conflictUtils 的 detectConflicts 函数来检测冲突 - // 这个函数能正确处理多任务叠加的超载情况(如 A:40% + B:40% + C:30% = 110%) - const conflictZones = detectConflicts(this.tasks, this.id) - - return conflictZones.length > 0 - } - - /** - * v1.9.0 获取任务中当前资源的利用率 - * @param task 任务对象 - * @returns 占比百分比 (20-100),默认100 - */ - getTaskAllocationPercent(task: any): number { - if (!task.resources || !Array.isArray(task.resources)) { - return 100 // 未配置resources时,默认100% - } - - const allocation = task.resources.find((r: any) => r.id === this.id) - if (!allocation) { - return 100 // 未找到当前资源的分配信息,默认100% - } - - const capacity = allocation.capacity ?? 100 - return Math.max(20, Math.min(100, capacity)) // 限制范围 20-100 - } -} diff --git a/src/models/classes/Task.ts b/src/models/classes/Task.ts index 69c1fde..4017e39 100644 --- a/src/models/classes/Task.ts +++ b/src/models/classes/Task.ts @@ -1,4 +1,6 @@ -import { Resource } from './Resource'// Task 类型定义 +import type { Resource } from '../types/Resource' + +// Task 类型定义 export interface Task { id: number name: string diff --git a/src/models/configs/ResourceListConfig.ts b/src/models/configs/ResourceListConfig.ts index 3a2ca36..624d3c2 100644 --- a/src/models/configs/ResourceListConfig.ts +++ b/src/models/configs/ResourceListConfig.ts @@ -1,4 +1,4 @@ -import type { Resource } from '../classes/Resource' +import type { Resource } from '../types/Resource' /** * 资源列表列类型枚举 diff --git a/src/models/types/Resource.ts b/src/models/types/Resource.ts new file mode 100644 index 0000000..01604a4 --- /dev/null +++ b/src/models/types/Resource.ts @@ -0,0 +1,23 @@ +import type { Task } from '../classes/Task' + +/** + * 资源接口 (Resource Interface) + * 用于资源计划视图,代表可分配的人力或设备资源 + * + * @version 1.9.0 + * @version 2.0.0 - 从 class 重构为 interface,方法迁移至 resourceUtils.ts + * @description 支持资源视图的核心数据模型,每个资源可以关联多个任务 + */ +export interface Resource { + id: string | number + name: string + type?: string + avatar?: string + description?: string + department?: string + skills?: string[] + capacity?: number + color?: string // 自定义资源行左边框颜色,如 '#ff5733',若不设置则使用默认颜色方案 + tasks: Task[] + [key: string]: unknown +} diff --git a/src/utils/resourceUtils.ts b/src/utils/resourceUtils.ts index 2a5bc4d..10b7e1a 100644 --- a/src/utils/resourceUtils.ts +++ b/src/utils/resourceUtils.ts @@ -1,5 +1,6 @@ import type { Task } from '../models/classes/Task' -import type { Resource } from '../models/classes/Resource' +import type { Resource } from '../models/types/Resource' +import { detectConflicts } from './conflictUtils' /** * 检测两个任务的时间段是否重叠 @@ -91,15 +92,31 @@ export function detectAllResourceConflicts( } /** - * 计算资源利用率(简化版:任务时长累加 / 时间窗口) + * 计算资源利用率(基于任务数量的简化计算) + * @param resource 资源对象 + * @returns 利用率百分比 (0-100) + */ +function calculateSimpleResourceUtilization(resource: Resource): number { + if (resource.tasks.length === 0) return 0 + const baseUtilization = Math.min(resource.tasks.length * 20, 100) + return Math.round(baseUtilization) +} + +/** + * 计算资源利用率(时间窗口版:任务时长累加 / 时间窗口) * @param resource 资源对象 * @param timeWindowDays 时间窗口(天数),默认30天 * @returns 利用率百分比(0-100+) */ export function calculateResourceUtilization( resource: Resource, - timeWindowDays = 30, + timeWindowDays?: number, ): number { + // 如果没有指定时间窗口,使用简化计算 + if (timeWindowDays === undefined) { + return calculateSimpleResourceUtilization(resource) + } + const tasks = resource.tasks || [] if (tasks.length === 0) return 0 @@ -120,3 +137,153 @@ export function calculateResourceUtilization( return availableHours > 0 ? Math.round((totalTaskHours / availableHours) * 100) : 0 } + +/** + * 创建资源对象(工厂函数) + * @param data 资源数据 + * @returns Resource 对象 + */ +export function createResource(data: { + id: string | number + name: string + type?: string + avatar?: string + description?: string + department?: string + skills?: string[] + capacity?: number + color?: string + tasks?: Task[] + [key: string]: unknown +}): Resource { + const resource: Resource = { + id: data.id, + name: data.name, + type: data.type, + avatar: data.avatar, + description: data.description, + department: data.department, + skills: data.skills, + capacity: data.capacity, + color: data.color, + tasks: data.tasks || [], + } + + // 复制其他自定义属性 + Object.keys(data).forEach(key => { + if (!(key in resource)) { + resource[key] = data[key] + } + }) + + return resource +} + +/** + * 添加任务到资源 + * @param resource 资源对象 + * @param task 任务对象 + */ +export function addTaskToResource(resource: Resource, task: Task): void { + if (!resource.tasks.find(t => t.id === task.id)) { + resource.tasks.push(task) + } +} + +/** + * 从资源中移除任务 + * @param resource 资源对象 + * @param taskId 任务ID + */ +export function removeTaskFromResource(resource: Resource, taskId: number | string): void { + resource.tasks = resource.tasks.filter(t => t.id !== taskId) +} + +/** + * 获取资源的任务数量 + * @param resource 资源对象 + * @returns 任务数量 + */ +export function getResourceTaskCount(resource: Resource): number { + return resource.tasks.length +} + +/** + * 更新资源的利用率 + * @param resource 资源对象 + */ +export function updateResourceUtilization(resource: Resource): void { + resource.capacity = calculateResourceUtilization(resource) +} + +/** + * 检测资源的任务是否存在时间重叠 + * @param resource 资源对象 + * @returns 是否存在任务重叠 + */ +export function hasResourceTaskOverlap(resource: Resource): boolean { + if (resource.tasks.length < 2) return false + + // 过滤掉没有开始日期和结束日期的任务 + const validTasks = resource.tasks.filter(task => task.startDate && task.endDate) + if (validTasks.length < 2) return false + + // 按开始日期排序 + const sortedTasks = [...validTasks].sort((a, b) => { + const dateA = new Date(a.startDate!).getTime() + const dateB = new Date(b.startDate!).getTime() + return dateA - dateB + }) + + // 检测相邻任务是否重叠 + for (let i = 0; i < sortedTasks.length - 1; i++) { + const currentTask = sortedTasks[i] + const nextTask = sortedTasks[i + 1] + + const currentEnd = new Date(currentTask.endDate!).getTime() + const nextStart = new Date(nextTask.startDate!).getTime() + + // 如果当前任务的结束时间晚于下一个任务的开始时间,则存在重叠 + if (currentEnd > nextStart) { + return true + } + } + + return false +} + +/** + * 检测资源是否超载(基于占用比例) + * 超载定义:同一时间段内,资源总占用比例 > 100% + * @param resource 资源对象 + * @returns 是否资源超载 + */ +export function isResourceOverloaded(resource: Resource): boolean { + if (resource.tasks.length < 2) return false + + // 使用 conflictUtils 的 detectConflicts 函数来检测冲突 + // 这个函数能正确处理多任务叠加的超载情况(如 A:40% + B:40% + C:30% = 110%) + const conflictZones = detectConflicts(resource.tasks, resource.id) + + return conflictZones.length > 0 +} + +/** + * 获取任务中当前资源的利用率 + * @param resource 资源对象 + * @param task 任务对象 + * @returns 占比百分比 (20-100),默认100 + */ +export function getTaskAllocationPercent(resource: Resource, task: any): number { + if (!task.resources || !Array.isArray(task.resources)) { + return 100 // 未配置resources时,默认100% + } + + const allocation = task.resources.find((r: any) => r.id === resource.id) + if (!allocation) { + return 100 // 未找到当前资源的分配信息,默认100% + } + + const capacity = allocation.capacity ?? 100 + return Math.max(20, Math.min(100, capacity)) // 限制范围 20-100 +}