diff --git a/src/components/GanttChart.vue b/src/components/GanttChart.vue index 66f8e25..58d53c8 100644 --- a/src/components/GanttChart.vue +++ b/src/components/GanttChart.vue @@ -190,31 +190,42 @@ const resourceConflicts = computed(() => { // 依赖 updateTaskTrigger 以便在任务更新时重新计算冲突 if (updateTaskTrigger.value >= 0) { resources.forEach(resource => { - const conflicts = new Set() - const tasks = resource.tasks || [] - const validTasks = tasks.filter(task => task.startDate && task.endDate) + // v1.9.2 使用 Resource.isOverloaded() 方法检测真正的资源超载 + // 超载定义:同一时间段内,资源总占用比例 > 100% + if (typeof resource.isOverloaded === 'function' && resource.isOverloaded()) { + const conflicts = new Set() + const tasks = resource.tasks || [] + const validTasks = tasks.filter(task => task.startDate && task.endDate) - // 两两比较检测冲突 - for (let i = 0; i < validTasks.length; i++) { - for (let j = i + 1; j < validTasks.length; j++) { - const task1 = validTasks[i] - const task2 = validTasks[j] + // 找出所有导致超载的任务对 + for (let i = 0; i < validTasks.length; i++) { + for (let j = i + 1; j < validTasks.length; j++) { + const task1 = validTasks[i] + const task2 = validTasks[j] - // 检查时间重叠 - const start1 = new Date(task1.startDate!).getTime() - const end1 = new Date(task1.endDate!).getTime() - const start2 = new Date(task2.startDate!).getTime() - const end2 = new Date(task2.endDate!).getTime() + // 检查时间重叠 + const start1 = new Date(task1.startDate!).getTime() + const end1 = new Date(task1.endDate!).getTime() + const start2 = new Date(task2.startDate!).getTime() + const end2 = new Date(task2.endDate!).getTime() - if (start1 < end2 && start2 < end1) { - conflicts.add(task1.id) - conflicts.add(task2.id) + if (start1 < end2 && start2 < end1) { + // 有时间重叠,检查总占比是否超过100% + const percent1 = getTaskAllocationPercent(resource, task1) + const percent2 = getTaskAllocationPercent(resource, task2) + + if (percent1 + percent2 > 100) { + // 超载,标记这两个任务 + conflicts.add(task1.id) + conflicts.add(task2.id) + } + } } } - } - if (conflicts.size > 0) { - conflictsMap.set(String(resource.id), conflicts) + if (conflicts.size > 0) { + conflictsMap.set(String(resource.id), conflicts) + } } }) } @@ -222,6 +233,21 @@ const resourceConflicts = computed(() => { return conflictsMap }) +// 辅助函数:获取任务中当前资源的投入占比 +function getTaskAllocationPercent(resource: Resource, task: Task): number { + if (!task.resources || !Array.isArray(task.resources)) { + return 100 // 未配置resources时,默认100% + } + + const allocation = task.resources.find((r: any) => String(r.id) === String(resource.id)) + if (!allocation) { + return 100 // 未找到当前资源的分配信息,默认100% + } + + const percent = allocation.percent ?? 100 + return Math.max(20, Math.min(100, percent)) // 限制范围 20-100 +} + // 提供资源布局信息给子组件 provide('resourceTaskLayouts', resourceTaskLayouts) provide('resourceRowPositions', resourceRowPositions) diff --git a/src/components/TaskBar.vue b/src/components/TaskBar.vue index 58da7bb..b5c851c 100644 --- a/src/components/TaskBar.vue +++ b/src/components/TaskBar.vue @@ -5,6 +5,7 @@ import type { Task } from '../models/classes/Task' import { TimelineScale } from '../models/types/TimelineScale' import TaskContextMenu from './TaskContextMenu.vue' import LinkAnchor from './LinkAnchor.vue' +import TaskBarTab from './Timeline/TaskBarTab.vue' import { useI18n } from '../composables/useI18n' import type { TaskBarConfig } from '../models/configs/TaskBarConfig' @@ -47,6 +48,39 @@ const resourcePercent = computed(() => { return 100 }) +// v1.9.2 获取当前资源颜色 +const currentResourceColor = computed(() => { + if (viewMode.value === 'resource' && props.currentResourceId && props.resources) { + // 从外部传入的 resources 列表中查找资源颜色(与 TaskRow 逻辑一致) + console.log('🎨 TaskBar - 查找资源颜色:', { + currentResourceId: props.currentResourceId, + resources: props.resources, + taskId: props.task.id, + taskName: props.task.name + }) + const resource = props.resources.find(r => String(r.id) === String(props.currentResourceId)) + console.log('🎨 TaskBar - 找到的资源:', resource) + const finalColor = resource?.color || '#85ce61' + console.log('🎨 TaskBar - 最终颜色:', finalColor) + return finalColor + } + return '#85ce61' +}) + +// v1.9.2 当前资源总负载(用于超载警告) +const currentResourceTotalLoad = computed(() => { + // 这个值应该从外部传入,这里暂时返回undefined + // 实际应该在Timeline层计算好并通过props传递 + return undefined +}) + +// v1.9.2 当前资源名称 +const currentResourceName = computed(() => { + if (!props.currentResourceId) return '' + const resource = props.resources?.find(r => r.id === props.currentResourceId) + return resource?.name || '' +}) + // v1.9.0 是否显示占比文字(占比<100%时显示) const shouldShowPercentText = computed(() => { return viewMode.value === 'resource' && resourcePercent.value < 100 @@ -100,6 +134,8 @@ interface Props { allTasks?: Task[] // v1.9.0 资源视图:是否存在资源冲突(时间重叠) hasResourceConflict?: boolean + // v1.9.2 资源视图:冲突任务列表(用于显示详细冲突信息) + conflictTasks?: Task[] // v1.9.0 资源视图:当前资源在任务中的投入占比 (20-100) resourceAllocationPercent?: number // v1.9.0 资源视图:当前资源ID(用于查找占比信息) @@ -108,6 +144,8 @@ interface Props { taskSubRow?: number // v1.9.0 资源视图:每个子行的高度数组(换行布局) rowHeights?: number[] + // v1.9.2 资源列表(用于查找资源名称等信息) + resources?: Array<{ id: string | number; name: string; color?: string }> } interface TaskStatus { @@ -321,6 +359,9 @@ const resizeStartX = ref(0) const resizeStartWidth = ref(0) const resizeStartLeft = ref(0) +// v1.9.2 注入Timeline的拖拽状态(用于冲突检测优化) +const timelineIsDraggingTaskBar = inject>('isDraggingTaskBar', ref(false)) + // v1.9.0 拖拽预览效果(资源视图垂直拖拽) const dragPreviewVisible = ref(false) const dragPreviewPosition = ref({ x: 0, y: 0 }) @@ -336,6 +377,17 @@ const LONG_PRESS_DURATION = 1000 // 1秒(缩短了) // TaskBar 悬停状态(用于显示 LinkAnchor) const isTaskBarHovered = ref(false) +// v1.9.2 Tab悬停状态(用于阻止tooltip) +const isTabHovered = ref(false) + +// v1.9.2 动态边框颜色(tab悬停时使用资源颜色) +const dynamicBorderColor = computed(() => { + if (isTabHovered.value && viewMode.value === 'resource') { + return currentResourceColor.value + } + return taskStatus.value.borderColor +}) + // 防误触配置 - 使用配置项或默认值 const dragThreshold = computed(() => barConfig.value.dragThreshold ?? 5) const isDragThresholdMet = ref(false) // 是否达到拖拽阈值 @@ -656,6 +708,12 @@ const shouldRenderTaskBar = computed(() => { return !!(currentStartDate || currentEndDate) }) +// v1.9.2 TaskBar宽度(用于Tab组件判断是否紧凑模式) +const taskBarWidth = computed(() => { + const width = parseFloat(taskBarStyle.value.width) + return isNaN(width) ? 0 : width +}) + // 计算任务状态和颜色 const taskStatus = computed(() => { // 优先级最高:如果task设置了barColor自定义颜色 @@ -2462,6 +2520,11 @@ const handleBubbleMouseDown = (event: MouseEvent) => { const handleTaskBarMouseEnter = (event: MouseEvent) => { isTaskBarHovered.value = true + // v1.9.2 如果 tab 正在悬停,不显示 TaskBar 的 tooltip + if (isTabHovered.value) { + return + } + // 如果启用了TaskBar Tooltip(父级任务也显示tooltip) // 但在拖拽或拉伸时不显示tooltip if (props.enableTaskBarTooltip !== false && !isDragging.value && !isResizingLeft.value && !isResizingRight.value) { @@ -2537,6 +2600,29 @@ watch([isDragging, isResizingLeft, isResizingRight], ([dragging, resizingL, resi hoverTooltipTimer = null } } + + // v1.9.2 同步拖拽状态到Timeline(用于冲突检测优化) + const isDraggingOrResizing = dragging || resizingL || resizingR + if (timelineIsDraggingTaskBar.value !== isDraggingOrResizing) { + timelineIsDraggingTaskBar.value = isDraggingOrResizing + } +}) + +// v1.9.2 监听 Tab 悬停状态,当 Tab 悬停时立即隐藏 TaskBar 的 tooltip +watch(isTabHovered, (tabHovered) => { + if (tabHovered) { + // Tab 悬停:隐藏 TaskBar 的 tooltip + showHoverTooltip.value = false + if (hoverTooltipTimer) { + clearTimeout(hoverTooltipTimer) + hoverTooltipTimer = null + } + } else { + // Tab 离开:如果鼠标还在 TaskBar 上,重新显示 tooltip + if (isTaskBarHovered.value && props.enableTaskBarTooltip !== false) { + showHoverTooltip.value = true + } + } }) // 格式化日期显示 @@ -3103,7 +3189,12 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor' :style="{ ...taskBarStyle, backgroundColor: (showActualTaskbar && hasActualProgress && isTaskBarHovered) ? 'transparent' : taskStatus.bgColor, - borderColor: taskStatus.borderColor, + borderColor: dynamicBorderColor, + ...(viewMode === 'resource' && currentResourceId ? { + borderTopWidth: '2px', + borderTopStyle: 'solid', + borderTopColor: currentResourceColor, + } : {}), color: taskStatus.color, cursor: isCompleted || isParent ? 'default' : 'move', '--row-height': `${rowHeight}px` /* 传递行高给CSS变量 */, @@ -3111,7 +3202,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor' '--parent-color': taskStatus.color, /* 传递父级TaskBar颜色给伪元素箭头使用 */ '--allocation-percent': (Number.isFinite(resourcePercent) ? resourcePercent / 100 : 1), /* v1.9.1 传递占比给CSS变量 */ '--task-bar-bg-color': taskStatus.bgColor, /* v1.9.1 传递背景色给伪元素 */ - '--task-bar-border-color': taskStatus.borderColor, /* v1.9.1 传递边框色给伪元素 */ + '--task-bar-border-color': dynamicBorderColor, /* v1.9.2 使用动态边框颜色 */ boxShadow: isParent ? `0 4px 16px ${taskStatus.color}40, 0 2px 8px ${taskStatus.color}26` /* 父级任务也使用动态颜色阴影 */ : `0 4px 16px ${taskStatus.color}40, 0 2px 8px ${taskStatus.color}26`, /* 使用TaskBar颜色的阴影 - 加强版 */ @@ -3154,6 +3245,22 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor' }" > + + +
-import { ref, onMounted, onUnmounted, computed, watch, nextTick, shallowRef, inject } from 'vue' +import { ref, onMounted, onUnmounted, computed, watch, nextTick, shallowRef, inject, provide } from 'vue' import type { Ref, ComputedRef } from 'vue' import TaskBar from './TaskBar.vue' import MilestonePoint from './MilestonePoint.vue' import GanttLinks from './GanttLinks.vue' import LinkDragGuide from './LinkDragGuide.vue' +import GanttConflicts from './Timeline/GanttConflicts.vue' /* eslint-disable @typescript-eslint/no-explicit-any */ import { useI18n } from '../composables/useI18n' import type { TaskBarConfig } from '../models/configs/TaskBarConfig' @@ -107,6 +108,56 @@ const dataSource = inject>('gantt-data-source', // v1.9.0 从 GanttChart 注入资源冲突信息(由 GanttChart 计算并响应 updateTaskTrigger) const resourceConflicts = inject>>>('resourceConflicts', computed(() => new Map())) +// v1.9.2 计算资源视图中每个任务的冲突任务列表(用于显示详细冲突信息) +const getConflictTasksForTask = (resourceId: string | number, taskId: string | number): Task[] => { + if (viewMode.value !== 'resource') return [] + + const conflictTaskIds = resourceConflicts.value.get(String(resourceId)) + if (!conflictTaskIds || !conflictTaskIds.has(taskId)) return [] + + const resources = dataSource.value as Resource[] + const resource = resources.find(r => String(r.id) === String(resourceId)) + if (!resource || !resource.tasks) return [] + + const currentTask = resource.tasks.find(t => t.id === taskId) + if (!currentTask || !currentTask.startDate || !currentTask.endDate) return [] + + const currentStart = new Date(currentTask.startDate).getTime() + const currentEnd = new Date(currentTask.endDate).getTime() + + // 获取当前资源在当前任务中的占比 + const getCurrentPercent = (task: Task): number => { + if (!task.resources || !Array.isArray(task.resources)) return 100 + const allocation = task.resources.find((r: any) => String(r.id) === String(resourceId)) + return allocation?.percent ?? 100 + } + + const currentPercent = getCurrentPercent(currentTask) + + // 找出所有与当前任务时间重叠且导致超载的任务 + const conflictTasks = resource.tasks.filter(task => { + if (task.id === taskId) return false + if (!task.startDate || !task.endDate) return false + if (!conflictTaskIds.has(task.id)) return false + + const taskStart = new Date(task.startDate).getTime() + const taskEnd = new Date(task.endDate).getTime() + + // 检查时间重叠 + if (!(currentStart < taskEnd && taskStart < currentEnd)) return false + + // 检查占比相加是否超过100% + const taskPercent = getCurrentPercent(task) + return currentPercent + taskPercent > 100 + }) + + return conflictTasks +} + +// v1.9.2 拖拽状态管理(用于冲突检测优化) +const isDraggingTaskBar = ref(false) +provide('isDraggingTaskBar', isDraggingTaskBar) + // v1.9.0 计算资源视图的任务行布局(换行) const resourceTaskLayouts = computed(() => { const layoutMap = new Map { }, 1000) // 给滚动动画留1秒时间 } } + // 向上传递 MilestonePoint 拖拽事件 const handleMilestoneDragEnd = (updatedMilestone: Milestone) => { window.dispatchEvent(new CustomEvent('milestone-drag-end', { detail: updatedMilestone })) @@ -4894,7 +4946,9 @@ const handleAddSuccessor = (task: Task) => { " :all-tasks="tasks" :has-resource-conflict="resourceConflicts.get(String(resource.id))?.has(task.id) || false" + :conflict-tasks="getConflictTasksForTask(resource.id, task.id)" :current-resource-id="resource.id" + :resources="dataSource" @update:task="updateTask" @bar-mounted="handleBarMounted" @click="handleTaskBarClick(task, $event)" @@ -4924,6 +4978,25 @@ const handleAddSuccessor = (task: Task) => { + + +
diff --git a/src/components/Timeline/GanttConflicts.vue b/src/components/Timeline/GanttConflicts.vue new file mode 100644 index 0000000..471aede --- /dev/null +++ b/src/components/Timeline/GanttConflicts.vue @@ -0,0 +1,550 @@ + + + + + diff --git a/src/components/Timeline/TaskBarTab.vue b/src/components/Timeline/TaskBarTab.vue new file mode 100644 index 0000000..dac5148 --- /dev/null +++ b/src/components/Timeline/TaskBarTab.vue @@ -0,0 +1,573 @@ + + + + + diff --git a/src/utils/conflictUtils.ts b/src/utils/conflictUtils.ts new file mode 100644 index 0000000..216501d --- /dev/null +++ b/src/utils/conflictUtils.ts @@ -0,0 +1,351 @@ +/** + * conflictUtils.ts - 资源冲突检测工具函数 + * + * 用于检测同一资源在同一时间段的任务冲突(总投入比例超载) + * v1.9.2 + */ + +import type { Task } from '../models/classes/Task' + +/** + * 冲突区域数据结构 + */ +export interface ConflictZone { + /** 冲突开始时间 */ + startDate: Date + /** 冲突结束时间 */ + endDate: Date + /** 总投入比例(百分比) */ + totalPercent: number + /** 冲突等级 */ + level: 'light' | 'medium' | 'severe' + /** 涉及的任务列表 */ + tasks: Array<{ + id: number | string + name: string + percent: number + }> + /** Canvas渲染坐标(由GanttConflicts组件计算填充) */ + left?: number + width?: number + top?: number + height?: number +} + +/** + * 时间交集结果 + */ +export interface TimeIntersection { + start: Date + end: Date +} + +/** + * 检测资源时间冲突 + * + * @param tasks 任务列表 + * @param resourceId 资源ID + * @returns 冲突区域列表 + * + * @example + * const conflicts = detectConflicts(tasks, 'resource-1') + */ +export function detectConflicts( + tasks: Task[], + resourceId: string | number +): ConflictZone[] { + // 过滤出包含指定资源的任务 + const resourceTasks = tasks.filter((task) => { + if (!task.resources || task.resources.length === 0) return false + return task.resources.some((r) => String(r.id) === String(resourceId)) + }) + + if (resourceTasks.length < 2) { + // 少于2个任务不会冲突 + return [] + } + + // 根据任务数量选择算法 + if (resourceTasks.length > 100) { + // TODO: 使用区间树算法(O(n log n)) + // 当前暂用暴力遍历,后续优化 + return detectConflictsBruteForce(resourceTasks, resourceId) + } else { + // 使用暴力遍历(O(n²)) + return detectConflictsBruteForce(resourceTasks, resourceId) + } +} + +/** + * 暴力遍历检测冲突(O(n²)算法) + */ +function detectConflictsBruteForce( + tasks: Task[], + resourceId: string | number +): ConflictZone[] { + const conflictZones: ConflictZone[] = [] + const processedIntervals = new Set() // 用于去重 + + // 遍历所有任务对 + for (let i = 0; i < tasks.length; i++) { + for (let j = i + 1; j < tasks.length; j++) { + const task1 = tasks[i] + const task2 = tasks[j] + + // 检查时间是否重叠 + const intersection = getTimeIntersection(task1, task2) + if (!intersection) continue + + // 收集该时间段内的所有任务 + const overlappingTasks = tasks.filter((task) => { + const taskIntersection = getTimeIntersection( + { + startDate: intersection.start.toISOString().split('T')[0], + endDate: intersection.end.toISOString().split('T')[0], + } as Task, + task + ) + return taskIntersection !== null + }) + + // 计算总投入比例 + let totalPercent = 0 + const taskDetails = overlappingTasks.map((task) => { + const resource = task.resources?.find((r) => String(r.id) === String(resourceId)) + const percent = resource?.percent || 0 + totalPercent += percent + return { + id: task.id!, + name: task.name || '未命名任务', + percent, + } + }) + + // 只有超载(>100%)才算冲突 + if (totalPercent <= 100) continue + + // 计算冲突范围:所有参与冲突的任务在intersection范围内的并集 + // 过滤出有资源分配的任务 + const tasksWithResource = overlappingTasks.filter((task) => { + const resource = task.resources?.find((r) => String(r.id) === String(resourceId)) + return resource && resource.percent > 0 + }) + + // 计算每个任务与intersection的交集,然后取并集 + let realStart = intersection.end // 初始化为最大值 + let realEnd = intersection.start // 初始化为最小值 + + for (const task of tasksWithResource) { + const taskStart = parseDate(task.startDate) + const taskEnd = parseDate(task.endDate) + if (taskStart && taskEnd) { + // 计算task与intersection的交集 + const overlapStart = new Date(Math.max(taskStart.getTime(), intersection.start.getTime())) + const overlapEnd = new Date(Math.min(taskEnd.getTime(), intersection.end.getTime())) + + // 取所有交集的并集(最小开始 ~ 最大结束) + realStart = new Date(Math.min(realStart.getTime(), overlapStart.getTime())) + realEnd = new Date(Math.max(realEnd.getTime(), overlapEnd.getTime())) + } + } + + // 创建区间标识符用于去重(避免多个任务对产生相同的冲突区间) + const intervalKey = `${realStart.getTime()}-${realEnd.getTime()}` + + // 避免重复添加相同的冲突区间 + if (processedIntervals.has(intervalKey)) continue + processedIntervals.add(intervalKey) + + // 创建冲突区域(使用真实交集范围) + conflictZones.push({ + startDate: realStart, + endDate: realEnd, + totalPercent, + level: getConflictLevel(totalPercent), + tasks: taskDetails, + }) + } + } + + // 合并重叠的冲突区域 + return mergeConflictZones(conflictZones) +} + +/** + * 合并重叠的冲突区域 + */ +function mergeConflictZones(zones: ConflictZone[]): ConflictZone[] { + if (zones.length === 0) return [] + + // 按开始时间排序 + const sorted = [...zones].sort((a, b) => a.startDate.getTime() - b.startDate.getTime()) + + const merged: ConflictZone[] = [] + let current = sorted[0] + + for (let i = 1; i < sorted.length; i++) { + const next = sorted[i] + + // 检查是否重叠 + if (current.endDate >= next.startDate) { + // 合并区域 + current = { + startDate: current.startDate, + endDate: new Date(Math.max(current.endDate.getTime(), next.endDate.getTime())), + totalPercent: Math.max(current.totalPercent, next.totalPercent), // 取最大负载 + level: getConflictLevel(Math.max(current.totalPercent, next.totalPercent)), + tasks: mergeTasks(current.tasks, next.tasks), + } + } else { + // 不重叠,保存当前区域并开始新区域 + merged.push(current) + current = next + } + } + + // 添加最后一个区域 + merged.push(current) + + return merged +} + +/** + * 合并任务列表(去重) + */ +function mergeTasks( + tasks1: ConflictZone['tasks'], + tasks2: ConflictZone['tasks'] +): ConflictZone['tasks'] { + const taskMap = new Map() + + for (const task of tasks1) { + taskMap.set(task.id, task) + } + + for (const task of tasks2) { + if (!taskMap.has(task.id)) { + taskMap.set(task.id, task) + } else { + // 已存在,更新为更高的投入比例 + const existing = taskMap.get(task.id)! + if (task.percent > existing.percent) { + taskMap.set(task.id, task) + } + } + } + + return Array.from(taskMap.values()) +} + +/** + * 计算两个任务的时间交集 + * + * @param task1 任务1 + * @param task2 任务2 + * @returns 时间交集,如果没有交集返回null + * + * @example + * const intersection = getTimeIntersection(task1, task2) + * if (intersection) { + * console.log('冲突时间段:', intersection.start, '~', intersection.end) + * } + */ +export function getTimeIntersection( + task1: Task | { startDate?: string; endDate?: string }, + task2: Task | { startDate?: string; endDate?: string } +): TimeIntersection | null { + // 解析日期 + const start1 = parseDate(task1.startDate) + const end1 = parseDate(task1.endDate) + const start2 = parseDate(task2.startDate) + const end2 = parseDate(task2.endDate) + + // 任意一个任务没有有效日期,返回null + if (!start1 || !end1 || !start2 || !end2) { + return null + } + + // endDate包含当天,需要+1天来判断交集 + const end1Plus = new Date(end1.getTime() + 24 * 60 * 60 * 1000) + const end2Plus = new Date(end2.getTime() + 24 * 60 * 60 * 1000) + + // 判断是否有交集:task1.start < task2.end+1 && task2.start < task1.end+1 + if (start1 >= end2Plus || start2 >= end1Plus) { + return null + } + + // 计算交集(返回的end是包含当天的,不需要+1) + const intersectionStart = new Date(Math.max(start1.getTime(), start2.getTime())) + const intersectionEnd = new Date(Math.min(end1.getTime(), end2.getTime())) + + return { + start: intersectionStart, + end: intersectionEnd, + } +} + +/** + * 判断冲突等级 + * + * @param totalPercent 总投入比例(百分比) + * @returns 冲突等级 + * + * @example + * getConflictLevel(110) // 'light' + * getConflictLevel(130) // 'medium' + * getConflictLevel(160) // 'severe' + */ +export function getConflictLevel( + totalPercent: number +): 'light' | 'medium' | 'severe' { + if (totalPercent > 150) { + return 'severe' // 严重冲突 + } else if (totalPercent > 120) { + return 'medium' // 中度冲突 + } else { + return 'light' // 轻度冲突 + } +} + +/** + * 解析日期字符串为Date对象 + */ +function parseDate(dateString: string | undefined): Date | null { + if (!dateString) return null + + // 处理ISO格式 (YYYY-MM-DD 或 YYYY-MM-DD HH:mm) + if (typeof dateString === 'string') { + // 只有日期部分 + if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + const [year, month, day] = dateString.split('-').map(Number) + return new Date(year, month - 1, day) + } + + // 包含时间部分 + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(dateString)) { + const [datePart, timePart] = dateString.split(' ') + const [year, month, day] = datePart.split('-').map(Number) + const [hour, minute] = timePart.split(':').map(Number) + return new Date(year, month - 1, day, hour, minute) + } + } + + // 尝试直接解析 + const date = new Date(dateString) + return isNaN(date.getTime()) ? null : date +} + +/** + * 计算两个日期之间的天数差 + */ +export function getDaysDiff(start: Date, end: Date): number { + const diffMs = end.getTime() - start.getTime() + return Math.floor(diffMs / (1000 * 60 * 60 * 24)) +} + +/** + * 检查日期是否在指定范围内 + */ +export function isDateInRange(date: Date, start: Date, end: Date): boolean { + return date >= start && date <= end +} diff --git a/tests/unit/utils/conflictUtils.spec.ts b/tests/unit/utils/conflictUtils.spec.ts new file mode 100644 index 0000000..3f89078 --- /dev/null +++ b/tests/unit/utils/conflictUtils.spec.ts @@ -0,0 +1,401 @@ +/** + * conflictUtils.spec.ts - 资源冲突检测工具单元测试 + */ + +import { describe, it, expect } from 'vitest' +import { + detectConflicts, + getTimeIntersection, + getConflictLevel, + type ConflictZone, +} from '../../../src/utils/conflictUtils' +import type { Task } from '../../../src/models/classes/Task' + +describe('conflictUtils', () => { + describe('getConflictLevel', () => { + it('应该返回 light 等级(100%-120%)', () => { + expect(getConflictLevel(100)).toBe('light') + expect(getConflictLevel(110)).toBe('light') + expect(getConflictLevel(120)).toBe('light') + }) + + it('应该返回 medium 等级(120%-150%)', () => { + expect(getConflictLevel(121)).toBe('medium') + expect(getConflictLevel(130)).toBe('medium') + expect(getConflictLevel(150)).toBe('medium') + }) + + it('应该返回 severe 等级(>150%)', () => { + expect(getConflictLevel(151)).toBe('severe') + expect(getConflictLevel(200)).toBe('severe') + expect(getConflictLevel(300)).toBe('severe') + }) + }) + + describe('getTimeIntersection', () => { + it('应该正确计算两个任务的时间交集', () => { + const task1: Task = { + id: 1, + name: '任务1', + startDate: '2026-01-10', + endDate: '2026-01-15', + } as Task + + const task2: Task = { + id: 2, + name: '任务2', + startDate: '2026-01-12', + endDate: '2026-01-20', + } as Task + + const intersection = getTimeIntersection(task1, task2) + + expect(intersection).not.toBeNull() + expect(intersection?.start).toEqual(new Date(2026, 0, 12)) + expect(intersection?.end).toEqual(new Date(2026, 0, 15)) + }) + + it('应该在无交集时返回null', () => { + const task1: Task = { + id: 1, + name: '任务1', + startDate: '2026-01-10', + endDate: '2026-01-15', + } as Task + + const task2: Task = { + id: 2, + name: '任务2', + startDate: '2026-01-20', + endDate: '2026-01-25', + } as Task + + const intersection = getTimeIntersection(task1, task2) + + expect(intersection).toBeNull() + }) + + it('应该处理边界相接的情况(不算交集)', () => { + const task1: Task = { + id: 1, + name: '任务1', + startDate: '2026-01-10', + endDate: '2026-01-15', + } as Task + + const task2: Task = { + id: 2, + name: '任务2', + startDate: '2026-01-15', + endDate: '2026-01-20', + } as Task + + const intersection = getTimeIntersection(task1, task2) + + // 边界相接不算交集 + expect(intersection).toBeNull() + }) + + it('应该处理完全包含的情况', () => { + const task1: Task = { + id: 1, + name: '任务1', + startDate: '2026-01-10', + endDate: '2026-01-25', + } as Task + + const task2: Task = { + id: 2, + name: '任务2', + startDate: '2026-01-15', + endDate: '2026-01-20', + } as Task + + const intersection = getTimeIntersection(task1, task2) + + expect(intersection).not.toBeNull() + expect(intersection?.start).toEqual(new Date(2026, 0, 15)) + expect(intersection?.end).toEqual(new Date(2026, 0, 20)) + }) + + it('应该处理缺少日期的情况', () => { + const task1: Task = { + id: 1, + name: '任务1', + startDate: '2026-01-10', + } as Task + + const task2: Task = { + id: 2, + name: '任务2', + startDate: '2026-01-15', + endDate: '2026-01-20', + } as Task + + const intersection = getTimeIntersection(task1, task2) + + expect(intersection).toBeNull() + }) + }) + + describe('detectConflicts', () => { + it('应该检测不出无冲突场景', () => { + const tasks: Task[] = [ + { + id: 1, + name: '任务1', + startDate: '2026-01-10', + endDate: '2026-01-15', + resources: [{ id: 'r1', percent: 50 }], + } as Task, + { + id: 2, + name: '任务2', + startDate: '2026-01-20', + endDate: '2026-01-25', + resources: [{ id: 'r1', percent: 60 }], + } as Task, + ] + + const conflicts = detectConflicts(tasks, 'r1') + + expect(conflicts).toHaveLength(0) + }) + + it('应该检测出轻度冲突(总占比100%-120%)', () => { + const tasks: Task[] = [ + { + id: 1, + name: '任务1', + startDate: '2026-01-10', + endDate: '2026-01-15', + resources: [{ id: 'r1', percent: 60 }], + } as Task, + { + id: 2, + name: '任务2', + startDate: '2026-01-12', + endDate: '2026-01-20', + resources: [{ id: 'r1', percent: 50 }], + } as Task, + ] + + const conflicts = detectConflicts(tasks, 'r1') + + expect(conflicts).toHaveLength(1) + expect(conflicts[0].level).toBe('light') + expect(conflicts[0].totalPercent).toBe(110) + expect(conflicts[0].tasks).toHaveLength(2) + }) + + it('应该检测出中度冲突(总占比120%-150%)', () => { + const tasks: Task[] = [ + { + id: 1, + name: '任务1', + startDate: '2026-01-10', + endDate: '2026-01-15', + resources: [{ id: 'r1', percent: 70 }], + } as Task, + { + id: 2, + name: '任务2', + startDate: '2026-01-12', + endDate: '2026-01-20', + resources: [{ id: 'r1', percent: 65 }], + } as Task, + ] + + const conflicts = detectConflicts(tasks, 'r1') + + expect(conflicts).toHaveLength(1) + expect(conflicts[0].level).toBe('medium') + expect(conflicts[0].totalPercent).toBe(135) + }) + + it('应该检测出严重冲突(总占比>150%)', () => { + const tasks: Task[] = [ + { + id: 1, + name: '任务1', + startDate: '2026-01-10', + endDate: '2026-01-15', + resources: [{ id: 'r1', percent: 80 }], + } as Task, + { + id: 2, + name: '任务2', + startDate: '2026-01-12', + endDate: '2026-01-20', + resources: [{ id: 'r1', percent: 90 }], + } as Task, + ] + + const conflicts = detectConflicts(tasks, 'r1') + + expect(conflicts).toHaveLength(1) + expect(conflicts[0].level).toBe('severe') + expect(conflicts[0].totalPercent).toBe(170) + }) + + it('应该检测出多任务冲突', () => { + const tasks: Task[] = [ + { + id: 1, + name: '任务1', + startDate: '2026-01-10', + endDate: '2026-01-20', + resources: [{ id: 'r1', percent: 40 }], + } as Task, + { + id: 2, + name: '任务2', + startDate: '2026-01-12', + endDate: '2026-01-18', + resources: [{ id: 'r1', percent: 50 }], + } as Task, + { + id: 3, + name: '任务3', + startDate: '2026-01-15', + endDate: '2026-01-25', + resources: [{ id: 'r1', percent: 30 }], + } as Task, + ] + + const conflicts = detectConflicts(tasks, 'r1') + + expect(conflicts.length).toBeGreaterThan(0) + expect(conflicts[0].tasks.length).toBeGreaterThanOrEqual(2) + expect(conflicts[0].totalPercent).toBeGreaterThan(100) + }) + + it('应该正确过滤指定资源的任务', () => { + const tasks: Task[] = [ + { + id: 1, + name: '任务1', + startDate: '2026-01-10', + endDate: '2026-01-15', + resources: [{ id: 'r1', percent: 60 }], + } as Task, + { + id: 2, + name: '任务2', + startDate: '2026-01-12', + endDate: '2026-01-20', + resources: [{ id: 'r2', percent: 80 }], // 不同资源 + } as Task, + { + id: 3, + name: '任务3', + startDate: '2026-01-12', + endDate: '2026-01-20', + resources: [{ id: 'r1', percent: 50 }], + } as Task, + ] + + const conflicts = detectConflicts(tasks, 'r1') + + // 应该只检测r1的冲突,不包含r2的任务 + expect(conflicts).toHaveLength(1) + expect(conflicts[0].tasks.every(t => + tasks.find(task => task.id === t.id)?.resources?.some(r => r.id === 'r1') + )).toBe(true) + }) + + it('应该处理没有资源的任务', () => { + const tasks: Task[] = [ + { + id: 1, + name: '任务1', + startDate: '2026-01-10', + endDate: '2026-01-15', + } as Task, + { + id: 2, + name: '任务2', + startDate: '2026-01-12', + endDate: '2026-01-20', + resources: [{ id: 'r1', percent: 50 }], + } as Task, + ] + + const conflicts = detectConflicts(tasks, 'r1') + + expect(conflicts).toHaveLength(0) + }) + + it('应该处理占比未超载的情况(<=100%)', () => { + const tasks: Task[] = [ + { + id: 1, + name: '任务1', + startDate: '2026-01-10', + endDate: '2026-01-15', + resources: [{ id: 'r1', percent: 40 }], + } as Task, + { + id: 2, + name: '任务2', + startDate: '2026-01-12', + endDate: '2026-01-20', + resources: [{ id: 'r1', percent: 50 }], + } as Task, + ] + + const conflicts = detectConflicts(tasks, 'r1') + + // 总占比90%,未超载,不应检测出冲突 + expect(conflicts).toHaveLength(0) + }) + + it('应该处理单个任务的情况', () => { + const tasks: Task[] = [ + { + id: 1, + name: '任务1', + startDate: '2026-01-10', + endDate: '2026-01-15', + resources: [{ id: 'r1', percent: 120 }], // 单个任务占比>100%也不算冲突 + } as Task, + ] + + const conflicts = detectConflicts(tasks, 'r1') + + expect(conflicts).toHaveLength(0) + }) + + it('应该合并重叠的冲突区域', () => { + const tasks: Task[] = [ + { + id: 1, + name: '任务1', + startDate: '2026-01-10', + endDate: '2026-01-20', + resources: [{ id: 'r1', percent: 60 }], + } as Task, + { + id: 2, + name: '任务2', + startDate: '2026-01-12', + endDate: '2026-01-18', + resources: [{ id: 'r1', percent: 50 }], + } as Task, + { + id: 3, + name: '任务3', + startDate: '2026-01-15', + endDate: '2026-01-25', + resources: [{ id: 'r1', percent: 55 }], + } as Task, + ] + + const conflicts = detectConflicts(tasks, 'r1') + + // 应该将连续重叠的区域合并 + expect(conflicts.length).toBeGreaterThan(0) + expect(conflicts.length).toBeLessThanOrEqual(2) + }) + }) +})