v1.9.0-rc.2 - Add resource durition conflicts and resource reloaded alert
This commit is contained in:
@@ -190,31 +190,42 @@ const resourceConflicts = computed(() => {
|
|||||||
// 依赖 updateTaskTrigger 以便在任务更新时重新计算冲突
|
// 依赖 updateTaskTrigger 以便在任务更新时重新计算冲突
|
||||||
if (updateTaskTrigger.value >= 0) {
|
if (updateTaskTrigger.value >= 0) {
|
||||||
resources.forEach(resource => {
|
resources.forEach(resource => {
|
||||||
const conflicts = new Set<number>()
|
// v1.9.2 使用 Resource.isOverloaded() 方法检测真正的资源超载
|
||||||
const tasks = resource.tasks || []
|
// 超载定义:同一时间段内,资源总占用比例 > 100%
|
||||||
const validTasks = tasks.filter(task => task.startDate && task.endDate)
|
if (typeof resource.isOverloaded === 'function' && resource.isOverloaded()) {
|
||||||
|
const conflicts = new Set<number>()
|
||||||
|
const tasks = resource.tasks || []
|
||||||
|
const validTasks = tasks.filter(task => task.startDate && task.endDate)
|
||||||
|
|
||||||
// 两两比较检测冲突
|
// 找出所有导致超载的任务对
|
||||||
for (let i = 0; i < validTasks.length; i++) {
|
for (let i = 0; i < validTasks.length; i++) {
|
||||||
for (let j = i + 1; j < validTasks.length; j++) {
|
for (let j = i + 1; j < validTasks.length; j++) {
|
||||||
const task1 = validTasks[i]
|
const task1 = validTasks[i]
|
||||||
const task2 = validTasks[j]
|
const task2 = validTasks[j]
|
||||||
|
|
||||||
// 检查时间重叠
|
// 检查时间重叠
|
||||||
const start1 = new Date(task1.startDate!).getTime()
|
const start1 = new Date(task1.startDate!).getTime()
|
||||||
const end1 = new Date(task1.endDate!).getTime()
|
const end1 = new Date(task1.endDate!).getTime()
|
||||||
const start2 = new Date(task2.startDate!).getTime()
|
const start2 = new Date(task2.startDate!).getTime()
|
||||||
const end2 = new Date(task2.endDate!).getTime()
|
const end2 = new Date(task2.endDate!).getTime()
|
||||||
|
|
||||||
if (start1 < end2 && start2 < end1) {
|
if (start1 < end2 && start2 < end1) {
|
||||||
conflicts.add(task1.id)
|
// 有时间重叠,检查总占比是否超过100%
|
||||||
conflicts.add(task2.id)
|
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) {
|
if (conflicts.size > 0) {
|
||||||
conflictsMap.set(String(resource.id), conflicts)
|
conflictsMap.set(String(resource.id), conflicts)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -222,6 +233,21 @@ const resourceConflicts = computed(() => {
|
|||||||
return conflictsMap
|
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('resourceTaskLayouts', resourceTaskLayouts)
|
||||||
provide('resourceRowPositions', resourceRowPositions)
|
provide('resourceRowPositions', resourceRowPositions)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Task } from '../models/classes/Task'
|
|||||||
import { TimelineScale } from '../models/types/TimelineScale'
|
import { TimelineScale } from '../models/types/TimelineScale'
|
||||||
import TaskContextMenu from './TaskContextMenu.vue'
|
import TaskContextMenu from './TaskContextMenu.vue'
|
||||||
import LinkAnchor from './LinkAnchor.vue'
|
import LinkAnchor from './LinkAnchor.vue'
|
||||||
|
import TaskBarTab from './Timeline/TaskBarTab.vue'
|
||||||
|
|
||||||
import { useI18n } from '../composables/useI18n'
|
import { useI18n } from '../composables/useI18n'
|
||||||
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
|
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
|
||||||
@@ -47,6 +48,39 @@ const resourcePercent = computed(() => {
|
|||||||
return 100
|
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%时显示)
|
// v1.9.0 是否显示占比文字(占比<100%时显示)
|
||||||
const shouldShowPercentText = computed(() => {
|
const shouldShowPercentText = computed(() => {
|
||||||
return viewMode.value === 'resource' && resourcePercent.value < 100
|
return viewMode.value === 'resource' && resourcePercent.value < 100
|
||||||
@@ -100,6 +134,8 @@ interface Props {
|
|||||||
allTasks?: Task[]
|
allTasks?: Task[]
|
||||||
// v1.9.0 资源视图:是否存在资源冲突(时间重叠)
|
// v1.9.0 资源视图:是否存在资源冲突(时间重叠)
|
||||||
hasResourceConflict?: boolean
|
hasResourceConflict?: boolean
|
||||||
|
// v1.9.2 资源视图:冲突任务列表(用于显示详细冲突信息)
|
||||||
|
conflictTasks?: Task[]
|
||||||
// v1.9.0 资源视图:当前资源在任务中的投入占比 (20-100)
|
// v1.9.0 资源视图:当前资源在任务中的投入占比 (20-100)
|
||||||
resourceAllocationPercent?: number
|
resourceAllocationPercent?: number
|
||||||
// v1.9.0 资源视图:当前资源ID(用于查找占比信息)
|
// v1.9.0 资源视图:当前资源ID(用于查找占比信息)
|
||||||
@@ -108,6 +144,8 @@ interface Props {
|
|||||||
taskSubRow?: number
|
taskSubRow?: number
|
||||||
// v1.9.0 资源视图:每个子行的高度数组(换行布局)
|
// v1.9.0 资源视图:每个子行的高度数组(换行布局)
|
||||||
rowHeights?: number[]
|
rowHeights?: number[]
|
||||||
|
// v1.9.2 资源列表(用于查找资源名称等信息)
|
||||||
|
resources?: Array<{ id: string | number; name: string; color?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskStatus {
|
interface TaskStatus {
|
||||||
@@ -321,6 +359,9 @@ const resizeStartX = ref(0)
|
|||||||
const resizeStartWidth = ref(0)
|
const resizeStartWidth = ref(0)
|
||||||
const resizeStartLeft = ref(0)
|
const resizeStartLeft = ref(0)
|
||||||
|
|
||||||
|
// v1.9.2 注入Timeline的拖拽状态(用于冲突检测优化)
|
||||||
|
const timelineIsDraggingTaskBar = inject<Ref<boolean>>('isDraggingTaskBar', ref(false))
|
||||||
|
|
||||||
// v1.9.0 拖拽预览效果(资源视图垂直拖拽)
|
// v1.9.0 拖拽预览效果(资源视图垂直拖拽)
|
||||||
const dragPreviewVisible = ref(false)
|
const dragPreviewVisible = ref(false)
|
||||||
const dragPreviewPosition = ref({ x: 0, y: 0 })
|
const dragPreviewPosition = ref({ x: 0, y: 0 })
|
||||||
@@ -336,6 +377,17 @@ const LONG_PRESS_DURATION = 1000 // 1秒(缩短了)
|
|||||||
// TaskBar 悬停状态(用于显示 LinkAnchor)
|
// TaskBar 悬停状态(用于显示 LinkAnchor)
|
||||||
const isTaskBarHovered = ref(false)
|
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 dragThreshold = computed(() => barConfig.value.dragThreshold ?? 5)
|
||||||
const isDragThresholdMet = ref(false) // 是否达到拖拽阈值
|
const isDragThresholdMet = ref(false) // 是否达到拖拽阈值
|
||||||
@@ -656,6 +708,12 @@ const shouldRenderTaskBar = computed(() => {
|
|||||||
return !!(currentStartDate || currentEndDate)
|
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(() => {
|
const taskStatus = computed(() => {
|
||||||
// 优先级最高:如果task设置了barColor自定义颜色
|
// 优先级最高:如果task设置了barColor自定义颜色
|
||||||
@@ -2462,6 +2520,11 @@ const handleBubbleMouseDown = (event: MouseEvent) => {
|
|||||||
const handleTaskBarMouseEnter = (event: MouseEvent) => {
|
const handleTaskBarMouseEnter = (event: MouseEvent) => {
|
||||||
isTaskBarHovered.value = true
|
isTaskBarHovered.value = true
|
||||||
|
|
||||||
|
// v1.9.2 如果 tab 正在悬停,不显示 TaskBar 的 tooltip
|
||||||
|
if (isTabHovered.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 如果启用了TaskBar Tooltip(父级任务也显示tooltip)
|
// 如果启用了TaskBar Tooltip(父级任务也显示tooltip)
|
||||||
// 但在拖拽或拉伸时不显示tooltip
|
// 但在拖拽或拉伸时不显示tooltip
|
||||||
if (props.enableTaskBarTooltip !== false && !isDragging.value && !isResizingLeft.value && !isResizingRight.value) {
|
if (props.enableTaskBarTooltip !== false && !isDragging.value && !isResizingLeft.value && !isResizingRight.value) {
|
||||||
@@ -2537,6 +2600,29 @@ watch([isDragging, isResizingLeft, isResizingRight], ([dragging, resizingL, resi
|
|||||||
hoverTooltipTimer = null
|
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="{
|
:style="{
|
||||||
...taskBarStyle,
|
...taskBarStyle,
|
||||||
backgroundColor: (showActualTaskbar && hasActualProgress && isTaskBarHovered) ? 'transparent' : taskStatus.bgColor,
|
backgroundColor: (showActualTaskbar && hasActualProgress && isTaskBarHovered) ? 'transparent' : taskStatus.bgColor,
|
||||||
borderColor: taskStatus.borderColor,
|
borderColor: dynamicBorderColor,
|
||||||
|
...(viewMode === 'resource' && currentResourceId ? {
|
||||||
|
borderTopWidth: '2px',
|
||||||
|
borderTopStyle: 'solid',
|
||||||
|
borderTopColor: currentResourceColor,
|
||||||
|
} : {}),
|
||||||
color: taskStatus.color,
|
color: taskStatus.color,
|
||||||
cursor: isCompleted || isParent ? 'default' : 'move',
|
cursor: isCompleted || isParent ? 'default' : 'move',
|
||||||
'--row-height': `${rowHeight}px` /* 传递行高给CSS变量 */,
|
'--row-height': `${rowHeight}px` /* 传递行高给CSS变量 */,
|
||||||
@@ -3111,7 +3202,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
|||||||
'--parent-color': taskStatus.color, /* 传递父级TaskBar颜色给伪元素箭头使用 */
|
'--parent-color': taskStatus.color, /* 传递父级TaskBar颜色给伪元素箭头使用 */
|
||||||
'--allocation-percent': (Number.isFinite(resourcePercent) ? resourcePercent / 100 : 1), /* v1.9.1 传递占比给CSS变量 */
|
'--allocation-percent': (Number.isFinite(resourcePercent) ? resourcePercent / 100 : 1), /* v1.9.1 传递占比给CSS变量 */
|
||||||
'--task-bar-bg-color': taskStatus.bgColor, /* v1.9.1 传递背景色给伪元素 */
|
'--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
|
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` /* 父级任务也使用动态颜色阴影 */
|
||||||
: `0 4px 16px ${taskStatus.color}40, 0 2px 8px ${taskStatus.color}26`, /* 使用TaskBar颜色的阴影 - 加强版 */
|
: `0 4px 16px ${taskStatus.color}40, 0 2px 8px ${taskStatus.color}26`, /* 使用TaskBar颜色的阴影 - 加强版 */
|
||||||
@@ -3154,6 +3245,22 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
|||||||
}"
|
}"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
|
<!-- v1.9.2 资源视图Tab标签 -->
|
||||||
|
<TaskBarTab
|
||||||
|
v-if="viewMode === 'resource' && !isParent && currentResourceId"
|
||||||
|
:key="`tab-${task.id}-${currentResourceId}`"
|
||||||
|
:task="task"
|
||||||
|
:current-resource-id="currentResourceId"
|
||||||
|
:resource-color="currentResourceColor"
|
||||||
|
:resource-percent="resourcePercent"
|
||||||
|
:resource-name="currentResourceName"
|
||||||
|
:task-bar-width="taskBarWidth"
|
||||||
|
:has-conflict="hasResourceConflict"
|
||||||
|
:conflict-tasks="conflictTasks"
|
||||||
|
:resources="resources"
|
||||||
|
@hover-change="isTabHovered = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 左侧调整把手 -->
|
<!-- 左侧调整把手 -->
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
@@ -3355,10 +3462,10 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
|||||||
</div>
|
</div>
|
||||||
</div><!-- 关闭task-bar-wrapper -->
|
</div><!-- 关闭task-bar-wrapper -->
|
||||||
|
|
||||||
<!-- Tooltip 弹窗 -->
|
<!-- Tooltip 弹窗(tab悬停时不显示) -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
v-if="showTooltip"
|
v-if="showTooltip && !isTabHovered"
|
||||||
class="task-tooltip"
|
class="task-tooltip"
|
||||||
:style="{
|
:style="{
|
||||||
left: `${tooltipPosition.x}px`,
|
left: `${tooltipPosition.x}px`,
|
||||||
@@ -3607,7 +3714,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: calc(var(--allocation-percent, 1) * 100%);
|
height: calc(var(--allocation-percent, 1) * 100%);
|
||||||
background: var(--task-bar-bg-color, #e3f2fd);
|
background: var(--task-bar-bg-color, #e3f2fd);
|
||||||
border: 1px solid var(--task-bar-border-color, #90caf9);
|
/*border: 1px solid var(--task-bar-border-color, #90caf9);*/
|
||||||
border-radius: 0 0 4px 4px;
|
border-radius: 0 0 4px 4px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||||
@@ -3659,20 +3766,17 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
|||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* v1.9.0 资源冲突样式(根据UI规范)- 调整到最顶层 */
|
/* v1.9.0 资源冲突样式 - 已移除,由GanttConflicts组件独立渲染 */
|
||||||
|
/*
|
||||||
.task-bar.resource-conflict::before {
|
.task-bar.resource-conflict::before {
|
||||||
/* 覆盖伪元素样式,显示警告效果 */
|
|
||||||
border-color: var(--gantt-error-color, #f56c6c) !important;
|
border-color: var(--gantt-error-color, #f56c6c) !important;
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
z-index: 10 !important;
|
z-index: 10 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-bar.resource-conflict::after {
|
.task-bar.resource-conflict::after {
|
||||||
/* 左侧3px红色色带 */
|
|
||||||
border-left: 3px solid var(--gantt-error-color, #f56c6c) !important;
|
border-left: 3px solid var(--gantt-error-color, #f56c6c) !important;
|
||||||
/* 浅红底色 */
|
|
||||||
background-color: rgba(245, 108, 108, 0.12) !important;
|
background-color: rgba(245, 108, 108, 0.12) !important;
|
||||||
/* 斑马纹背景(叠加在底色上)*/
|
|
||||||
background-image:
|
background-image:
|
||||||
repeating-linear-gradient(
|
repeating-linear-gradient(
|
||||||
45deg,
|
45deg,
|
||||||
@@ -3684,21 +3788,19 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
|||||||
z-index: 1 !important;
|
z-index: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 已完成的任务冲突:不显示斜杠蒙版,仅保留左侧红色警示边 */
|
|
||||||
.task-bar.completed.resource-conflict::after {
|
.task-bar.completed.resource-conflict::after {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
background-image: none !important;
|
background-image: none !important;
|
||||||
/* 保留左侧红色边框 */
|
|
||||||
border-left: 3px solid var(--gantt-error-color, #f56c6c) !important;
|
border-left: 3px solid var(--gantt-error-color, #f56c6c) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 冲突状态的文字保持清晰可读 */
|
|
||||||
.task-bar.resource-conflict .task-name,
|
.task-bar.resource-conflict .task-name,
|
||||||
.task-bar.resource-conflict .task-progress {
|
.task-bar.resource-conflict .task-progress {
|
||||||
/*color: var(--gantt-text-primary);*/
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
z-index: 11;
|
z-index: 11;
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
.task-bar.dragging {
|
.task-bar.dragging {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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 type { Ref, ComputedRef } from 'vue'
|
||||||
import TaskBar from './TaskBar.vue'
|
import TaskBar from './TaskBar.vue'
|
||||||
import MilestonePoint from './MilestonePoint.vue'
|
import MilestonePoint from './MilestonePoint.vue'
|
||||||
import GanttLinks from './GanttLinks.vue'
|
import GanttLinks from './GanttLinks.vue'
|
||||||
import LinkDragGuide from './LinkDragGuide.vue'
|
import LinkDragGuide from './LinkDragGuide.vue'
|
||||||
|
import GanttConflicts from './Timeline/GanttConflicts.vue'
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { useI18n } from '../composables/useI18n'
|
import { useI18n } from '../composables/useI18n'
|
||||||
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
|
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
|
||||||
@@ -107,6 +108,56 @@ const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source',
|
|||||||
// v1.9.0 从 GanttChart 注入资源冲突信息(由 GanttChart 计算并响应 updateTaskTrigger)
|
// v1.9.0 从 GanttChart 注入资源冲突信息(由 GanttChart 计算并响应 updateTaskTrigger)
|
||||||
const resourceConflicts = inject<ComputedRef<Map<string, Set<number>>>>('resourceConflicts', computed(() => new Map()))
|
const resourceConflicts = inject<ComputedRef<Map<string, Set<number>>>>('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 计算资源视图的任务行布局(换行)
|
// v1.9.0 计算资源视图的任务行布局(换行)
|
||||||
const resourceTaskLayouts = computed(() => {
|
const resourceTaskLayouts = computed(() => {
|
||||||
const layoutMap = new Map<string | number, {
|
const layoutMap = new Map<string | number, {
|
||||||
@@ -3014,6 +3065,7 @@ const handleScrollToPosition = (targetScrollLeft: number) => {
|
|||||||
}, 1000) // 给滚动动画留1秒时间
|
}, 1000) // 给滚动动画留1秒时间
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 向上传递 MilestonePoint 拖拽事件
|
// 向上传递 MilestonePoint 拖拽事件
|
||||||
const handleMilestoneDragEnd = (updatedMilestone: Milestone) => {
|
const handleMilestoneDragEnd = (updatedMilestone: Milestone) => {
|
||||||
window.dispatchEvent(new CustomEvent('milestone-drag-end', { detail: updatedMilestone }))
|
window.dispatchEvent(new CustomEvent('milestone-drag-end', { detail: updatedMilestone }))
|
||||||
@@ -4894,7 +4946,9 @@ const handleAddSuccessor = (task: Task) => {
|
|||||||
"
|
"
|
||||||
:all-tasks="tasks"
|
:all-tasks="tasks"
|
||||||
:has-resource-conflict="resourceConflicts.get(String(resource.id))?.has(task.id) || false"
|
:has-resource-conflict="resourceConflicts.get(String(resource.id))?.has(task.id) || false"
|
||||||
|
:conflict-tasks="getConflictTasksForTask(resource.id, task.id)"
|
||||||
:current-resource-id="resource.id"
|
:current-resource-id="resource.id"
|
||||||
|
:resources="dataSource"
|
||||||
@update:task="updateTask"
|
@update:task="updateTask"
|
||||||
@bar-mounted="handleBarMounted"
|
@bar-mounted="handleBarMounted"
|
||||||
@click="handleTaskBarClick(task, $event)"
|
@click="handleTaskBarClick(task, $event)"
|
||||||
@@ -4924,6 +4978,25 @@ const handleAddSuccessor = (task: Task) => {
|
|||||||
<slot name="task-bar-context-menu" v-bind="contextMenuScope" />
|
<slot name="task-bar-context-menu" v-bind="contextMenuScope" />
|
||||||
</template>
|
</template>
|
||||||
</TaskBar>
|
</TaskBar>
|
||||||
|
|
||||||
|
<!-- v1.9.2 资源冲突可视化层 -->
|
||||||
|
<GanttConflicts
|
||||||
|
:tasks="(resource as any).tasks"
|
||||||
|
:resource-id="resource.id"
|
||||||
|
:day-width="dayWidth"
|
||||||
|
:start-date="
|
||||||
|
currentTimeScale === TimelineScale.YEAR
|
||||||
|
? getYearTimelineRange().startDate
|
||||||
|
: currentTimeScale === TimelineScale.MONTH
|
||||||
|
? getMonthTimelineRange().startDate
|
||||||
|
: timelineConfig.startDate
|
||||||
|
"
|
||||||
|
:top-offset="7.5"
|
||||||
|
:height="(resourceTaskLayouts.get(resource.id)?.totalHeight || 51) - 10"
|
||||||
|
:width="totalTimelineWidth"
|
||||||
|
:timeline-data="timelineData as any"
|
||||||
|
:current-time-scale="currentTimeScale"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
550
src/components/Timeline/GanttConflicts.vue
Normal file
550
src/components/Timeline/GanttConflicts.vue
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
<template>
|
||||||
|
<canvas
|
||||||
|
ref="canvasRef"
|
||||||
|
class="gantt-conflicts-canvas"
|
||||||
|
:width="canvasWidth"
|
||||||
|
:height="canvasHeight"
|
||||||
|
:style="canvasStyle"
|
||||||
|
></canvas>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick, inject, type Ref } from 'vue'
|
||||||
|
import { detectConflicts, type ConflictZone } from '../../utils/conflictUtils'
|
||||||
|
import type { Task } from '../../models/classes/Task'
|
||||||
|
import { TimelineScale } from '../../models/types/TimelineScale'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 当前资源的所有任务 */
|
||||||
|
tasks: Task[]
|
||||||
|
/** 资源ID */
|
||||||
|
resourceId: string | number
|
||||||
|
/** 每天的宽度(px) */
|
||||||
|
dayWidth: number
|
||||||
|
/** 时间轴起始日期 */
|
||||||
|
startDate: Date
|
||||||
|
/** Canvas高度(对应资源行高度) */
|
||||||
|
height: number
|
||||||
|
/** Canvas宽度(时间轴总宽度) */
|
||||||
|
width: number
|
||||||
|
/** 顶部偏移量(px),用于对齐第一行TaskBar上沿 */
|
||||||
|
topOffset?: number
|
||||||
|
/** 时间轴数据(用于精确定位) */
|
||||||
|
timelineData?: Array<any>
|
||||||
|
/** 当前时间刻度 */
|
||||||
|
currentTimeScale?: TimelineScale
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// 注入拖拽状态
|
||||||
|
const isDraggingTaskBar = inject<Ref<boolean>>('isDraggingTaskBar', ref(false))
|
||||||
|
|
||||||
|
// Canvas引用
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const canvasWidth = computed(() => props.width)
|
||||||
|
const canvasHeight = computed(() => props.height)
|
||||||
|
|
||||||
|
// 冲突区域列表
|
||||||
|
const conflictZones = ref<ConflictZone[]>([])
|
||||||
|
const needsRecalculation = ref(false)
|
||||||
|
|
||||||
|
// 纹理预生成(性能优化)
|
||||||
|
const texturePatterns = ref<{
|
||||||
|
light: CanvasPattern | null
|
||||||
|
medium: CanvasPattern | null
|
||||||
|
severe: CanvasPattern | null
|
||||||
|
}>({
|
||||||
|
light: null,
|
||||||
|
medium: null,
|
||||||
|
severe: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Canvas样式
|
||||||
|
const canvasStyle = computed(() => ({
|
||||||
|
position: 'absolute' as const,
|
||||||
|
top: `${props.topOffset || 0}px`,
|
||||||
|
left: 0,
|
||||||
|
pointerEvents: 'none' as const, // 始终穿透,让TaskBar可以响应鼠标事件
|
||||||
|
opacity: isDraggingTaskBar.value ? 0.3 : 1,
|
||||||
|
transition: 'opacity 0.2s',
|
||||||
|
zIndex: 250, // v1.9.2 高于TaskBar的最高z-index(200),确保冲突层在最上层
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
// 监听任务列表变化
|
||||||
|
watch(() => props.tasks, () => {
|
||||||
|
if (isDraggingTaskBar.value) {
|
||||||
|
needsRecalculation.value = true
|
||||||
|
} else {
|
||||||
|
recalculateConflicts()
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 监听拖拽状态变化
|
||||||
|
watch(isDraggingTaskBar, (dragging) => {
|
||||||
|
if (!dragging && needsRecalculation.value) {
|
||||||
|
nextTick(() => {
|
||||||
|
recalculateConflicts()
|
||||||
|
needsRecalculation.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听Canvas尺寸变化
|
||||||
|
watch([canvasWidth, canvasHeight], () => {
|
||||||
|
nextTick(() => {
|
||||||
|
renderConflicts()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听timelineData和currentTimeScale变化
|
||||||
|
watch([() => props.timelineData, () => props.currentTimeScale], () => {
|
||||||
|
if (import.meta.env.DEV) {}
|
||||||
|
recalculateConflicts()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 重新计算冲突
|
||||||
|
function recalculateConflicts() {
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
|
// 调用冲突检测算法
|
||||||
|
const conflicts = detectConflicts(props.tasks, props.resourceId)
|
||||||
|
|
||||||
|
// 计算Canvas坐标
|
||||||
|
conflictZones.value = conflicts.map((zone) => {
|
||||||
|
const { left, width } = calculatePosition(zone.startDate, zone.endDate)
|
||||||
|
|
||||||
|
// 开发环境调试日志
|
||||||
|
if (import.meta.env.DEV) {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...zone,
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
top: 0,
|
||||||
|
height: props.height,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const endTime = performance.now()
|
||||||
|
const elapsed = endTime - startTime
|
||||||
|
|
||||||
|
// 开发环境性能监控
|
||||||
|
if (import.meta.env.DEV && elapsed > 50) {}
|
||||||
|
|
||||||
|
// 重新渲染
|
||||||
|
renderConflicts()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算冲突区域在Canvas上的位置(与TaskBar使用相同逻辑)
|
||||||
|
function calculatePosition(startDate: Date, endDate: Date): { left: number; width: number } {
|
||||||
|
let left = 0
|
||||||
|
let width = 0
|
||||||
|
|
||||||
|
// 创建只包含日期的Date对象(忽略时分秒)
|
||||||
|
const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
|
||||||
|
const endDateOnly = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.timelineData &&
|
||||||
|
props.currentTimeScale &&
|
||||||
|
(props.currentTimeScale === TimelineScale.WEEK ||
|
||||||
|
props.currentTimeScale === TimelineScale.MONTH ||
|
||||||
|
props.currentTimeScale === TimelineScale.QUARTER ||
|
||||||
|
props.currentTimeScale === TimelineScale.YEAR)
|
||||||
|
) {
|
||||||
|
// 使用timelineData精确定位
|
||||||
|
const startPosition = calculatePositionFromTimelineData(
|
||||||
|
startDateOnly,
|
||||||
|
props.timelineData,
|
||||||
|
props.currentTimeScale,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 计算结束位置:为结束日期添加一天来获取正确的结束位置
|
||||||
|
const nextDay = new Date(endDateOnly)
|
||||||
|
nextDay.setDate(nextDay.getDate() + 1)
|
||||||
|
let endPosition = calculatePositionFromTimelineData(
|
||||||
|
nextDay,
|
||||||
|
props.timelineData,
|
||||||
|
props.currentTimeScale,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 如果结束日期+1天超出范围,使用结束日期的位置+一天的宽度
|
||||||
|
if (endPosition === startPosition) {
|
||||||
|
let dayWidth = 60 / 30 // 默认月视图
|
||||||
|
if (props.currentTimeScale === TimelineScale.WEEK) {
|
||||||
|
dayWidth = 60 / 7
|
||||||
|
} else if (props.currentTimeScale === TimelineScale.QUARTER) {
|
||||||
|
dayWidth = 60 / 90
|
||||||
|
} else if (props.currentTimeScale === TimelineScale.YEAR) {
|
||||||
|
dayWidth = 180 / 182
|
||||||
|
}
|
||||||
|
endPosition =
|
||||||
|
calculatePositionFromTimelineData(
|
||||||
|
endDateOnly,
|
||||||
|
props.timelineData,
|
||||||
|
props.currentTimeScale,
|
||||||
|
) + dayWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
left = startPosition
|
||||||
|
width = Math.max(endPosition - startPosition, 4)
|
||||||
|
} else if (
|
||||||
|
props.timelineData &&
|
||||||
|
props.currentTimeScale === TimelineScale.DAY
|
||||||
|
) {
|
||||||
|
// 日视图:使用timelineData精确定位
|
||||||
|
const startPosition = calculatePositionFromTimelineData(
|
||||||
|
startDateOnly,
|
||||||
|
props.timelineData,
|
||||||
|
props.currentTimeScale,
|
||||||
|
)
|
||||||
|
|
||||||
|
const nextDay = new Date(endDateOnly)
|
||||||
|
nextDay.setDate(nextDay.getDate() + 1)
|
||||||
|
let endPosition = calculatePositionFromTimelineData(
|
||||||
|
nextDay,
|
||||||
|
props.timelineData,
|
||||||
|
props.currentTimeScale,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (endPosition === startPosition) {
|
||||||
|
endPosition = calculatePositionFromTimelineData(
|
||||||
|
endDateOnly,
|
||||||
|
props.timelineData,
|
||||||
|
props.currentTimeScale,
|
||||||
|
) + 30 // 日视图每天30px
|
||||||
|
}
|
||||||
|
|
||||||
|
left = startPosition
|
||||||
|
width = Math.max(endPosition - startPosition, 4)
|
||||||
|
} else {
|
||||||
|
// 其他情况:基于日期的简单计算
|
||||||
|
const startDiff = Math.floor(
|
||||||
|
(startDateOnly.getTime() - props.startDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
)
|
||||||
|
|
||||||
|
const timeDiffMs = endDateOnly.getTime() - startDateOnly.getTime()
|
||||||
|
const daysDiff = Math.round(timeDiffMs / (1000 * 60 * 60 * 24))
|
||||||
|
const duration = daysDiff === 0 ? 1 : daysDiff + 1
|
||||||
|
|
||||||
|
left = startDiff * props.dayWidth
|
||||||
|
width = duration * props.dayWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
return { left: Math.max(0, left), width: Math.max(4, width) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 辅助函数:将日期转换为只包含年月日的本地Date对象
|
||||||
|
function toLocalDateOnly(date: Date | string): Date {
|
||||||
|
if (typeof date === 'string') {
|
||||||
|
// 解析字符串日期
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
const [year, month, day] = date.split('-').map(Number)
|
||||||
|
return new Date(year, month - 1, day)
|
||||||
|
}
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T/.test(date)) {
|
||||||
|
// ISO格式
|
||||||
|
const d = new Date(date)
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date instanceof Date) {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底
|
||||||
|
const d = new Date(date)
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从timelineData计算日期位置(与TaskBar使用相同的函数)
|
||||||
|
function calculatePositionFromTimelineData(
|
||||||
|
targetDate: Date,
|
||||||
|
timelineData: Array<any>,
|
||||||
|
timeScale: TimelineScale,
|
||||||
|
): number {
|
||||||
|
let cumulativePosition = 0
|
||||||
|
|
||||||
|
for (const periodData of timelineData) {
|
||||||
|
if (timeScale === TimelineScale.DAY) {
|
||||||
|
const days = periodData.days || []
|
||||||
|
|
||||||
|
for (let i = 0; i < days.length; i++) {
|
||||||
|
const dayData = days[i]
|
||||||
|
const dayDate = toLocalDateOnly(dayData.date)
|
||||||
|
|
||||||
|
if (
|
||||||
|
dayDate.getFullYear() === targetDate.getFullYear() &&
|
||||||
|
dayDate.getMonth() === targetDate.getMonth() &&
|
||||||
|
dayDate.getDate() === targetDate.getDate()
|
||||||
|
) {
|
||||||
|
return cumulativePosition + i * 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cumulativePosition += days.length * 30
|
||||||
|
} else if (timeScale === TimelineScale.QUARTER) {
|
||||||
|
const quarters = periodData.quarters || []
|
||||||
|
|
||||||
|
for (const quarter of quarters) {
|
||||||
|
const quarterStart = toLocalDateOnly(quarter.startDate)
|
||||||
|
const quarterEnd = toLocalDateOnly(quarter.endDate)
|
||||||
|
|
||||||
|
if (targetDate >= quarterStart && targetDate <= quarterEnd) {
|
||||||
|
const quarterWidth = 60
|
||||||
|
const daysInQuarter = Math.ceil(
|
||||||
|
(quarterEnd.getTime() - quarterStart.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
)
|
||||||
|
const dayWidth = quarterWidth / daysInQuarter
|
||||||
|
const dayInQuarter = Math.ceil(
|
||||||
|
(targetDate.getTime() - quarterStart.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
)
|
||||||
|
return cumulativePosition + dayInQuarter * dayWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
cumulativePosition += 60
|
||||||
|
}
|
||||||
|
} else if (timeScale === TimelineScale.WEEK) {
|
||||||
|
const weeks = periodData.weeks || []
|
||||||
|
|
||||||
|
for (const week of weeks) {
|
||||||
|
const weekStart = toLocalDateOnly(week.weekStart)
|
||||||
|
const weekEnd = toLocalDateOnly(week.weekEnd)
|
||||||
|
|
||||||
|
if (targetDate >= weekStart && targetDate <= weekEnd) {
|
||||||
|
const weekWidth = 60
|
||||||
|
const daysInWeek = Math.ceil(
|
||||||
|
(weekEnd.getTime() - weekStart.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
) + 1
|
||||||
|
const dayWidth = weekWidth / daysInWeek
|
||||||
|
const dayInWeek = Math.ceil(
|
||||||
|
(targetDate.getTime() - weekStart.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
)
|
||||||
|
return cumulativePosition + dayInWeek * dayWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
cumulativePosition += 60
|
||||||
|
}
|
||||||
|
} else if (timeScale === TimelineScale.MONTH) {
|
||||||
|
const monthStart = new Date(periodData.year, periodData.month - 1, 1)
|
||||||
|
const monthEnd = new Date(periodData.year, periodData.month, 0)
|
||||||
|
|
||||||
|
if (targetDate >= monthStart && targetDate <= monthEnd) {
|
||||||
|
const monthWidth = 60
|
||||||
|
const daysInMonth = periodData.monthData?.dayCount || 30
|
||||||
|
const dayWidth = monthWidth / daysInMonth
|
||||||
|
const dayInMonth = targetDate.getDate()
|
||||||
|
return cumulativePosition + (dayInMonth - 1) * dayWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
cumulativePosition += 60
|
||||||
|
} else if (timeScale === TimelineScale.YEAR) {
|
||||||
|
const halfYears = periodData.halfYears || []
|
||||||
|
|
||||||
|
for (const halfYear of halfYears) {
|
||||||
|
const halfYearStart = toLocalDateOnly(halfYear.startDate)
|
||||||
|
const halfYearEnd = toLocalDateOnly(halfYear.endDate)
|
||||||
|
|
||||||
|
if (targetDate >= halfYearStart && targetDate <= halfYearEnd) {
|
||||||
|
const halfYearWidth = 180
|
||||||
|
const daysInHalfYear = Math.ceil(
|
||||||
|
(halfYearEnd.getTime() - halfYearStart.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
) + 1
|
||||||
|
const dayWidth = halfYearWidth / daysInHalfYear
|
||||||
|
const dayInHalfYear = Math.ceil(
|
||||||
|
(targetDate.getTime() - halfYearStart.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
)
|
||||||
|
return cumulativePosition + dayInHalfYear * dayWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
cumulativePosition += 180
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cumulativePosition
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染冲突纹理
|
||||||
|
function renderConflicts() {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// 清空Canvas
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// 绘制所有冲突区域
|
||||||
|
for (const zone of conflictZones.value) {
|
||||||
|
if (!zone.left || !zone.width) continue
|
||||||
|
|
||||||
|
// 视口裁剪优化(仅绘制可见区域)
|
||||||
|
if (zone.left + zone.width < 0 || zone.left > canvas.width) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制纹理背景
|
||||||
|
drawTextureBackground(ctx, zone)
|
||||||
|
|
||||||
|
// 绘制左右边界线
|
||||||
|
drawBorders(ctx, zone)
|
||||||
|
|
||||||
|
// 绘制顶部警告标识
|
||||||
|
drawTopWarning(ctx, zone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制纹理背景
|
||||||
|
function drawTextureBackground(ctx: CanvasRenderingContext2D, zone: ConflictZone) {
|
||||||
|
const { left = 0, width = 0, top = 0, height = props.height } = zone
|
||||||
|
|
||||||
|
// 根据冲突等级选择颜色
|
||||||
|
let color: string
|
||||||
|
let alpha: number
|
||||||
|
|
||||||
|
switch (zone.level) {
|
||||||
|
case 'light':
|
||||||
|
color = 'rgba(255,220,0,' // 浅黄
|
||||||
|
alpha = 0.15
|
||||||
|
break
|
||||||
|
case 'medium':
|
||||||
|
color = 'rgba(255,165,0,' // 橙色
|
||||||
|
alpha = 0.15
|
||||||
|
break
|
||||||
|
case 'severe':
|
||||||
|
color = 'rgba(255,69,0,' // 红色
|
||||||
|
alpha = 0.2
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制斜线纹理
|
||||||
|
ctx.save()
|
||||||
|
ctx.fillStyle = getTexturePattern(ctx, zone.level) || `${color}${alpha})`
|
||||||
|
ctx.fillRect(left, top, width, height)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取或创建纹理pattern(性能优化)
|
||||||
|
function getTexturePattern(ctx: CanvasRenderingContext2D, level: 'light' | 'medium' | 'severe'): CanvasPattern | null {
|
||||||
|
// 如果已有缓存,直接返回
|
||||||
|
if (texturePatterns.value[level]) {
|
||||||
|
return texturePatterns.value[level]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建临时Canvas绘制纹理
|
||||||
|
const patternCanvas = document.createElement('canvas')
|
||||||
|
patternCanvas.width = 10
|
||||||
|
patternCanvas.height = 10
|
||||||
|
const patternCtx = patternCanvas.getContext('2d')
|
||||||
|
if (!patternCtx) return null
|
||||||
|
|
||||||
|
// 根据等级选择颜色
|
||||||
|
let color: string
|
||||||
|
switch (level) {
|
||||||
|
case 'light':
|
||||||
|
color = 'rgba(255,220,0,0.3)'
|
||||||
|
break
|
||||||
|
case 'medium':
|
||||||
|
color = 'rgba(255,165,0,0.3)'
|
||||||
|
break
|
||||||
|
case 'severe':
|
||||||
|
color = 'rgba(255,69,0,0.4)'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制45度斜线
|
||||||
|
patternCtx.strokeStyle = color
|
||||||
|
patternCtx.lineWidth = 1
|
||||||
|
patternCtx.beginPath()
|
||||||
|
patternCtx.moveTo(0, 10)
|
||||||
|
patternCtx.lineTo(10, 0)
|
||||||
|
patternCtx.stroke()
|
||||||
|
|
||||||
|
// 创建pattern并缓存
|
||||||
|
const pattern = ctx.createPattern(patternCanvas, 'repeat')
|
||||||
|
texturePatterns.value[level] = pattern
|
||||||
|
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制边界线
|
||||||
|
function drawBorders(ctx: CanvasRenderingContext2D, zone: ConflictZone) {
|
||||||
|
const { left = 0, width = 0, top = 0, height = props.height } = zone
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.strokeStyle = '#f56c6c' // 红色边界
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
|
||||||
|
// 左边界
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(left, top)
|
||||||
|
ctx.lineTo(left, top + height)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// 右边界
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(left + width, top)
|
||||||
|
ctx.lineTo(left + width, top + height)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制顶部警告标识(只显示三角形icon)
|
||||||
|
function drawTopWarning(ctx: CanvasRenderingContext2D, zone: ConflictZone) {
|
||||||
|
const { left = 0, top = 0 } = zone
|
||||||
|
|
||||||
|
const triangleSize = 16
|
||||||
|
const padding = 4
|
||||||
|
const x = left + padding
|
||||||
|
const y = top + padding
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
|
||||||
|
// 绘制三角形警告图标
|
||||||
|
ctx.fillStyle = '#faad14' // 黄色
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x + triangleSize / 2, y) // 顶点
|
||||||
|
ctx.lineTo(x, y + triangleSize) // 左下角
|
||||||
|
ctx.lineTo(x + triangleSize, y + triangleSize) // 右下角
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
// 绘制白色感叹号
|
||||||
|
ctx.fillStyle = '#fff'
|
||||||
|
ctx.font = 'bold 12px Arial'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
ctx.fillText('!', x + triangleSize / 2, y + triangleSize * 0.6)
|
||||||
|
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
recalculateConflicts()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件卸载
|
||||||
|
onUnmounted(() => {
|
||||||
|
// 清理资源
|
||||||
|
texturePatterns.value = {
|
||||||
|
light: null,
|
||||||
|
medium: null,
|
||||||
|
severe: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gantt-conflicts-canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
573
src/components/Timeline/TaskBarTab.vue
Normal file
573
src/components/Timeline/TaskBarTab.vue
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
|
import type { Task } from '../../models/classes/Task'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
task: Task
|
||||||
|
currentResourceId: string | number
|
||||||
|
resourceColor: string
|
||||||
|
resourcePercent: number
|
||||||
|
resourceName: string
|
||||||
|
taskBarWidth?: number
|
||||||
|
// 冲突相关 - v1.9.2 传递冲突任务列表以显示详细信息
|
||||||
|
hasConflict?: boolean
|
||||||
|
conflictTasks?: Task[] // 与当前任务存在资源超载的任务列表
|
||||||
|
// 资源列表(用于获取avatar等信息)
|
||||||
|
resources?: Array<{ id: string | number; name: string; avatar?: string; color?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
taskBarWidth: undefined,
|
||||||
|
hasConflict: false,
|
||||||
|
conflictTasks: () => [],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'hover-change': [isHovered: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const isExpanded = ref(false)
|
||||||
|
const tabElement = ref<HTMLElement | null>(null)
|
||||||
|
let hideTimer: number | null = null // 延迟隐藏定时器
|
||||||
|
|
||||||
|
// 百分比文字
|
||||||
|
const percentText = computed(() => `${Math.round(props.resourcePercent)}%`)
|
||||||
|
|
||||||
|
// Tab 宽度:基于 taskBarWidth,严格不超过taskbar宽度
|
||||||
|
// 参考截图,对于窄taskbar需要严格保证tab不超出
|
||||||
|
const tabWidth = computed(() => {
|
||||||
|
if (!props.taskBarWidth) return 30
|
||||||
|
const maxWidth = 50
|
||||||
|
// Tab 严格不超过 taskbar 宽度,减去少量边距(最多2px)确保不溢出
|
||||||
|
// 对于很窄的taskbar,不强制最小宽度,完全跟随taskbar宽度
|
||||||
|
const margin = Math.min(2, props.taskBarWidth * 0.1) // 边距最多2px或taskbar宽度的10%
|
||||||
|
return Math.min(props.taskBarWidth - margin, maxWidth)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tab样式(使用资源颜色)
|
||||||
|
const tabStyle = computed(() => {
|
||||||
|
console.log('🏷️ TaskBarTab - 收到的颜色 props:', {
|
||||||
|
resourceColor: props.resourceColor,
|
||||||
|
taskId: props.task.id,
|
||||||
|
taskName: props.task.name,
|
||||||
|
resourceName: props.resourceName
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
backgroundColor: `${props.resourceColor} !important` as any,
|
||||||
|
width: `${tabWidth.value}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前资源对象
|
||||||
|
const currentResource = computed(() => {
|
||||||
|
if (!props.resources || !props.currentResourceId) return null
|
||||||
|
return props.resources.find(r => String(r.id) === String(props.currentResourceId))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 资源名称(优先使用resource对象的name)
|
||||||
|
const resourceDisplayName = computed(() => {
|
||||||
|
return currentResource.value?.name || props.resourceName || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 资源头像URL或首字母
|
||||||
|
const resourceAvatar = computed(() => {
|
||||||
|
return currentResource.value?.avatar || null
|
||||||
|
})
|
||||||
|
|
||||||
|
// 资源名称首字母(用于没有头像时显示)
|
||||||
|
const resourceNameInitial = computed(() => {
|
||||||
|
const name = resourceDisplayName.value
|
||||||
|
return name.charAt(0).toUpperCase() || 'R'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 资源头像样式
|
||||||
|
const avatarStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
backgroundColor: props.resourceColor,
|
||||||
|
color: getContrastColor(props.resourceColor),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 展开区域样式(智能定位:根据位置向上或向下展开)
|
||||||
|
const expandedStyle = computed(() => {
|
||||||
|
if (!tabElement.value) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = tabElement.value.getBoundingClientRect()
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
|
||||||
|
// 估算弹窗高度(根据是否有冲突信息动态计算)
|
||||||
|
const baseHeight = 120 // 基础信息高度
|
||||||
|
const conflictHeight = conflictInfoList.value.length * 80 // 每个冲突项约80px
|
||||||
|
const estimatedPanelHeight = baseHeight + conflictHeight
|
||||||
|
|
||||||
|
// 判断是否有足够空间向上展开
|
||||||
|
const spaceAbove = rect.top
|
||||||
|
const spaceBelow = viewportHeight - rect.bottom
|
||||||
|
const shouldExpandUpward = spaceAbove >= estimatedPanelHeight || spaceAbove > spaceBelow
|
||||||
|
|
||||||
|
if (shouldExpandUpward) {
|
||||||
|
// 向上展开(默认行为)
|
||||||
|
return {
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: `${viewportHeight - rect.top + 2}px`,
|
||||||
|
left: `${rect.left}px`,
|
||||||
|
maxHeight: `${Math.min(spaceAbove - 10, 400)}px`, // 限制最大高度,留10px边距
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
backgroundColor: props.resourceColor as any,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 向下展开(当顶部空间不足时)
|
||||||
|
return {
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${rect.bottom + 2}px`,
|
||||||
|
left: `${rect.left}px`,
|
||||||
|
maxHeight: `${Math.min(spaceBelow - 10, 400)}px`, // 限制最大高度,留10px边距
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
backgroundColor: props.resourceColor as any,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化日期范围
|
||||||
|
const formattedDateRange = computed(() => {
|
||||||
|
if (!props.task.startDate || !props.task.endDate) return '-'
|
||||||
|
|
||||||
|
const start = new Date(props.task.startDate)
|
||||||
|
const end = new Date(props.task.endDate)
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatDate(start)} ~ ${formatDate(end)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 冲突信息列表(显示多个冲突任务的详细信息)
|
||||||
|
const conflictInfoList = computed(() => {
|
||||||
|
if (!props.hasConflict || !props.conflictTasks || props.conflictTasks.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTask = props.task
|
||||||
|
if (!currentTask.startDate || !currentTask.endDate) return []
|
||||||
|
|
||||||
|
const currentStart = new Date(currentTask.startDate).getTime()
|
||||||
|
const currentEnd = new Date(currentTask.endDate).getTime()
|
||||||
|
|
||||||
|
// 计算当前任务的资源占比
|
||||||
|
const currentPercent = props.resourcePercent || 100
|
||||||
|
|
||||||
|
return props.conflictTasks.map(conflictTask => {
|
||||||
|
if (!conflictTask.startDate || !conflictTask.endDate) return null
|
||||||
|
|
||||||
|
const conflictStart = new Date(conflictTask.startDate).getTime()
|
||||||
|
const conflictEnd = new Date(conflictTask.endDate).getTime()
|
||||||
|
|
||||||
|
// 计算冲突任务的资源占比
|
||||||
|
let conflictPercent = 100
|
||||||
|
if (conflictTask.resources && Array.isArray(conflictTask.resources)) {
|
||||||
|
const allocation = conflictTask.resources.find(
|
||||||
|
(r: any) => String(r.id) === String(props.currentResourceId)
|
||||||
|
)
|
||||||
|
if (allocation && allocation.percent !== undefined) {
|
||||||
|
conflictPercent = Math.max(20, Math.min(100, allocation.percent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算重叠时间段
|
||||||
|
const overlapStart = Math.max(currentStart, conflictStart)
|
||||||
|
const overlapEnd = Math.min(currentEnd, conflictEnd)
|
||||||
|
|
||||||
|
// 计算超载百分比
|
||||||
|
const totalPercent = currentPercent + conflictPercent
|
||||||
|
const overloadPercent = totalPercent - 100
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskName: conflictTask.name,
|
||||||
|
overlapStart: formatDate(overlapStart),
|
||||||
|
overlapEnd: formatDate(overlapEnd),
|
||||||
|
currentPercent,
|
||||||
|
conflictPercent,
|
||||||
|
totalPercent,
|
||||||
|
overloadPercent,
|
||||||
|
}
|
||||||
|
}).filter(Boolean)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 鼠标进入
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
// 清除之前的隐藏定时器
|
||||||
|
if (hideTimer !== null) {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
hideTimer = null
|
||||||
|
}
|
||||||
|
isExpanded.value = true
|
||||||
|
emit('hover-change', true) // 通知父组件:禁止taskbar的tooltip,启用边框动画
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标离开
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
// 延迟隐藏,给用户时间移动到面板上
|
||||||
|
hideTimer = window.setTimeout(() => {
|
||||||
|
isExpanded.value = false
|
||||||
|
emit('hover-change', false) // 通知父组件:恢复正常
|
||||||
|
hideTimer = null
|
||||||
|
}, 100) // 100ms 延迟
|
||||||
|
}
|
||||||
|
|
||||||
|
// 面板鼠标进入(保持展开状态)
|
||||||
|
const handlePanelMouseEnter = () => {
|
||||||
|
// 清除隐藏定时器
|
||||||
|
if (hideTimer !== null) {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
hideTimer = null
|
||||||
|
}
|
||||||
|
isExpanded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 面板鼠标离开(延迟隐藏)
|
||||||
|
const handlePanelMouseLeave = () => {
|
||||||
|
// 延迟隐藏
|
||||||
|
hideTimer = window.setTimeout(() => {
|
||||||
|
isExpanded.value = false
|
||||||
|
emit('hover-change', false)
|
||||||
|
hideTimer = null
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具函数:获取对比色(黑或白)
|
||||||
|
const getContrastColor = (bgColor: string): string => {
|
||||||
|
const hex = bgColor.replace('#', '')
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16)
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16)
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16)
|
||||||
|
|
||||||
|
// 计算亮度
|
||||||
|
const brightness = (r * 299 + g * 587 + b * 114) / 1000
|
||||||
|
|
||||||
|
return brightness > 128 ? '#333' : '#fff'
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// 清理定时器
|
||||||
|
if (hideTimer !== null) {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
hideTimer = null
|
||||||
|
}
|
||||||
|
// 清理所有状态,避免内存泄漏
|
||||||
|
isExpanded.value = false
|
||||||
|
// 通知父组件
|
||||||
|
emit('hover-change', false)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="tabElement"
|
||||||
|
class="task-bar-tab"
|
||||||
|
:style="tabStyle"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
|
@mouseleave="handleMouseLeave"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<!-- 悬停展开面板(使用 Teleport 避免滚动问题) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="isExpanded"
|
||||||
|
class="tab-expanded"
|
||||||
|
:style="expandedStyle"
|
||||||
|
@mouseenter="handlePanelMouseEnter"
|
||||||
|
@mouseleave="handlePanelMouseLeave"
|
||||||
|
>
|
||||||
|
<div class="expanded-content">
|
||||||
|
<!-- 标题:资源名称 + 头像 -->
|
||||||
|
<div class="expanded-title">
|
||||||
|
<div v-if="resourceAvatar" class="resource-avatar" :style="avatarStyle">
|
||||||
|
<img :src="resourceAvatar" alt="avatar" class="avatar-img" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="resource-avatar" :style="avatarStyle">
|
||||||
|
{{ resourceNameInitial }}
|
||||||
|
</div>
|
||||||
|
<span class="resource-name">{{ resourceDisplayName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<div class="expanded-body">
|
||||||
|
<!-- 投入占比 -->
|
||||||
|
<div class="expanded-row">
|
||||||
|
<span class="info-label">投入占比</span>
|
||||||
|
<span class="info-value">{{ percentText }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 日期范围 -->
|
||||||
|
<div class="expanded-row">
|
||||||
|
<span class="info-label">时间范围</span>
|
||||||
|
<span class="info-value">{{ formattedDateRange }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 冲突预警(有冲突时才显示) -->
|
||||||
|
<div v-if="hasConflict && conflictInfoList.length > 0" class="conflict-section">
|
||||||
|
<div class="conflict-header">
|
||||||
|
<svg class="warning-icon" viewBox="0 0 24 24" width="14" height="14">
|
||||||
|
<path fill="currentColor" d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="conflict-title">资源超载警告</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="(info, index) in conflictInfoList" :key="index" class="conflict-item">
|
||||||
|
<div class="conflict-task-name">与《{{ info.taskName }}》冲突</div>
|
||||||
|
<div class="conflict-detail">
|
||||||
|
<span class="conflict-label">冲突时段:</span>
|
||||||
|
<span class="conflict-value">{{ info.overlapStart }} ~ {{ info.overlapEnd }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="conflict-detail">
|
||||||
|
<span class="conflict-label">资源占用:</span>
|
||||||
|
<span class="conflict-value">{{ info.currentPercent }}% + {{ info.conflictPercent }}% = {{ info.totalPercent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="conflict-detail overload-highlight">
|
||||||
|
<span class="conflict-label">超载:</span>
|
||||||
|
<span class="conflict-value">+{{ info.overloadPercent }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Tab 基础样式 - 梯形标签,上窄下宽,位于 TaskBar 左上角外部 */
|
||||||
|
.task-bar-tab {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px; /* 在 TaskBar 上边缘外部,略微向上以消除缝隙 */
|
||||||
|
left: 0;
|
||||||
|
height: 7px; /* 增加高度以实现梯形效果 */
|
||||||
|
/* width 由 style 动态设置 */
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 11; /* 高于 TaskBar */
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
/* 使用 clip-path 创建梯形:上窄下宽 */
|
||||||
|
clip-path: polygon(
|
||||||
|
10% 0%, /* 左上角,向内收窄 */
|
||||||
|
90% 0%, /* 右上角,向内收窄 */
|
||||||
|
100% 100%, /* 右下角 */
|
||||||
|
0% 100% /* 左下角 */
|
||||||
|
);
|
||||||
|
/* 下方圆角通过与taskbar融合实现 */
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 悬停展开面板 - 智能定位,自动向上或向下展开 */
|
||||||
|
.tab-expanded {
|
||||||
|
min-width: 150px;
|
||||||
|
max-width: 250px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.85);
|
||||||
|
color: white;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 10000;
|
||||||
|
pointer-events: auto;
|
||||||
|
animation: expandFromTabUpRight 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transform-origin: bottom left;
|
||||||
|
overflow-y: auto; /* 支持滚动,防止内容过多 */
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 向右+向上展开动画 */
|
||||||
|
@keyframes expandFromTabUpRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8) translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题区域 */
|
||||||
|
.expanded-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区域 */
|
||||||
|
.expanded-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 冲突警告区域 - v1.9.2 详细冲突信息 */
|
||||||
|
.conflict-section {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #FFC107;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-title {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-item {
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
border-left: 3px solid #FFC107;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-task-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #FFC107;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-detail {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-label {
|
||||||
|
opacity: 0.9;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-value {
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overload-highlight {
|
||||||
|
color: #ff5252;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overload-highlight .conflict-value {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #FFC107;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 资源头像 */
|
||||||
|
.resource-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 资源名称 */
|
||||||
|
.resource-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 信息行 */
|
||||||
|
.expanded-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 信息标签和值 */
|
||||||
|
.info-label {
|
||||||
|
opacity: 0.9;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 冲突警告行 */
|
||||||
|
.conflict-warning {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #FFC107;
|
||||||
|
font-size: 11px;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #FFC107;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-text {
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
351
src/utils/conflictUtils.ts
Normal file
351
src/utils/conflictUtils.ts
Normal file
@@ -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<string>() // 用于去重
|
||||||
|
|
||||||
|
// 遍历所有任务对
|
||||||
|
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<string | number, ConflictZone['tasks'][0]>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
401
tests/unit/utils/conflictUtils.spec.ts
Normal file
401
tests/unit/utils/conflictUtils.spec.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user