v1.9.0 - Add resource view

This commit is contained in:
LINING-PC\lining
2026-01-29 23:27:34 +08:00
parent bd1ce7940e
commit de6ce44000
17 changed files with 2008 additions and 103 deletions
+249 -7
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch, nextTick, shallowRef } from 'vue'
import { ref, onMounted, onUnmounted, computed, watch, nextTick, shallowRef, inject } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import TaskBar from './TaskBar.vue'
import MilestonePoint from './MilestonePoint.vue'
import GanttLinks from './GanttLinks.vue'
@@ -10,9 +11,11 @@ import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
import { getPredecessorIds } from '../utils/predecessorUtils'
import { perfMonitor } from '../utils/perfMonitor'
import type { Task } from '../models/classes/Task'
import type { Resource } from '../models/classes/Resource'
import type { Milestone } from '../models/classes/Milestone'
import type { TimelineConfig } from '../models/configs/TimelineConfig'
import { TimelineScale } from '../models/types/TimelineScale'
import { detectResourceConflicts } from '../utils/resourceUtils'
// 定义Props接口
interface Props {
@@ -86,6 +89,7 @@ const emit = defineEmits<{
'successor-added': [{ targetTask: Task; newTask: Task }] // 后置任务已添加
delete: [task: Task, deleteChildren?: boolean]
'link-deleted': [{ sourceTaskId: number; targetTaskId: number; updatedTask: Task }] // 链接已删除
'resource-drag-end': [{ task: Task; sourceResourceIndex: number; targetResourceIndex: number; targetResource: Resource }] // v1.9.0 资源视图垂直拖拽结束
}>()
// 多语言
@@ -96,6 +100,27 @@ const t = (key: string): string => {
return getTranslation(key)
}
// v1.9.0 从 GanttChart 注入视图模式和数据源
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
// v1.9.0 计算资源冲突
const resourceConflicts = computed(() => {
if (viewMode.value !== 'resource') return new Map()
const resources = dataSource.value as Resource[]
const conflictsMap = new Map<string | number, Set<number>>()
resources.forEach(resource => {
const conflicts = detectResourceConflicts(resource)
if (conflicts.size > 0) {
conflictsMap.set(resource.id, conflicts)
}
})
return conflictsMap
})
// 获取以今天为中心的时间线范围(缓存结果,避免每次计算创建新对象)
const cachedTodayCenteredRange = (() => {
const today = new Date()
@@ -146,8 +171,15 @@ watch([timelineStartDate, timelineEndDate], ([newStart, newEnd]) => {
// 优化:使用常量避免每次创建新空数组
const EMPTY_TASKS_ARRAY: Task[] = []
// 使用props传入的任务和里程碑数据
const tasks = computed(() => props.tasks ?? EMPTY_TASKS_ARRAY)
// v1.9.0 使用视图模式决定数据源:资源视图使用dataSource,任务视图使用props.tasks
const tasks = computed(() => {
if (viewMode.value === 'resource') {
// 资源视图:使用注入的 dataSource (Resource[])
return (dataSource.value || []) as unknown as Task[]
}
// 任务视图:使用 props.tasks
return props.tasks ?? EMPTY_TASKS_ARRAY
})
// DOM 元素缓存,避免重复查询
const timelineContainerElement = ref<HTMLElement | null>(null)
@@ -206,8 +238,20 @@ const computeTasksDateRange = (): TaskDateRange => {
}
}
for (const task of tasks.value) {
collectDatesFromTask(task)
// v1.9.0 资源视图:从Resource.tasks中提取任务日期
if (viewMode.value === 'resource') {
for (const resource of tasks.value as any) {
if (resource.tasks && Array.isArray(resource.tasks)) {
for (const task of resource.tasks) {
collectDatesFromTask(task)
}
}
}
} else {
// 任务视图:直接遍历任务
for (const task of tasks.value) {
collectDatesFromTask(task)
}
}
if (dates.length === 0) {
@@ -2796,10 +2840,36 @@ function handleBarMounted(payload: {
// 向上传递 TaskBar 拖拽/拉伸事件
const handleTaskBarDragEnd = (updatedTask: Task) => {
// 如果是资源视图,需要更新dataSource中的资源数据
if (viewMode.value === 'resource' && dataSource.value) {
for (const resource of dataSource.value as any[]) {
if (resource.tasks) {
const taskIndex = resource.tasks.findIndex((t: Task) => t.id === updatedTask.id)
if (taskIndex !== -1) {
// 更新资源中的任务数据
resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask }
break
}
}
}
}
// 通过全局事件或 emit/props 回调传递给 GanttChart
window.dispatchEvent(new CustomEvent('taskbar-drag-end', { detail: updatedTask }))
}
const handleTaskBarResizeEnd = (updatedTask: Task) => {
// 如果是资源视图,需要更新dataSource中的资源数据
if (viewMode.value === 'resource' && dataSource.value) {
for (const resource of dataSource.value as any[]) {
if (resource.tasks) {
const taskIndex = resource.tasks.findIndex((t: Task) => t.id === updatedTask.id)
if (taskIndex !== -1) {
// 更新资源中的任务数据
resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask }
break
}
}
}
}
window.dispatchEvent(new CustomEvent('taskbar-resize-end', { detail: updatedTask }))
}
@@ -2876,6 +2946,9 @@ onMounted(() => {
// 监听TaskBar高亮事件
window.addEventListener('taskbar-highlighted', handleTaskBarHighlighted as EventListener)
// 监听资源视图垂直拖拽事件
window.addEventListener('resource-taskbar-drop', handleResourceTaskBarDrop as EventListener)
// 设置ResizeObserver监听timeline-body的尺寸变化
nextTick(() => {
// 初始化并缓存 DOM 元素引用
@@ -3071,6 +3144,42 @@ const handleTaskBarHighlighted = () => {
}, 5000)
}
// v1.9.0 资源视图垂直拖拽:处理TaskBar拖放到不同资源行
const handleResourceTaskBarDrop = (event: Event) => {
const customEvent = event as CustomEvent
const { taskId, task, sourceRowIndex, mouseY, mouseX } = customEvent.detail
// 计算目标资源行索引
const timelineBody = timelineBodyElement.value
if (!timelineBody) return
const bodyRect = timelineBody.getBoundingClientRect()
const relativeY = mouseY - bodyRect.top + timelineBody.scrollTop
const targetRowIndex = Math.floor(relativeY / 51) // 51px是每行的高度
// v1.9.0 直接使用TaskBar传递过来的精确日期,避免重复计算导致误差
const newStartDate = customEvent.detail.calculatedStartDate
const newEndDate = customEvent.detail.calculatedEndDate
// 获取资源列表
const resources = dataSource.value as Resource[]
// 如果目标行与源行不同,发送事件给父组件(demo)处理
if (targetRowIndex !== sourceRowIndex && targetRowIndex >= 0 && targetRowIndex < resources.length) {
const targetResource = resources[targetRowIndex]
// 发送事件给父组件,让demo显示确认对话框
emit('resource-drag-end', {
task,
sourceResourceIndex: sourceRowIndex,
targetResourceIndex: targetRowIndex,
targetResource,
newStartDate, // v1.9.0 新的开始日期
newEndDate, // v1.9.0 新的结束日期
})
}
}
// 鼠标按下开始拖拽(在时间轴表头和body区域)
const handleMouseDown = (event: MouseEvent) => {
const target = event.target as HTMLElement
@@ -3322,7 +3431,7 @@ const stopAutoScroll = () => {
// 处理拖拽边界检测事件
const handleDragBoundaryCheck = (event: CustomEvent) => {
const { mouseX, isDragging: dragState } = event.detail
const { mouseX, mouseY, isDragging: dragState, isResourceView, taskId, rowIndex } = event.detail
if (!dragState || !timelineContainer.value) {
stopAutoScroll()
@@ -3332,6 +3441,25 @@ const handleDragBoundaryCheck = (event: CustomEvent) => {
const containerRect = timelineContainer.value.getBoundingClientRect()
const relativeX = mouseX - containerRect.left
// v1.9.0 资源视图垂直拖拽检测
if (isResourceView && mouseY && timelineBodyElement.value) {
const bodyRect = timelineBodyElement.value.getBoundingClientRect()
const relativeY = mouseY - bodyRect.top
const targetRowIndex = Math.floor(relativeY / 51) // 每行高度51px
// 如果移动到不同行,发送事件通知(可以用于显示拖拽指示器等)
if (targetRowIndex !== rowIndex && targetRowIndex >= 0) {
window.dispatchEvent(new CustomEvent('resource-drag-over', {
detail: {
taskId,
sourceRowIndex: rowIndex,
targetRowIndex,
mouseY: relativeY
}
}))
}
}
// 检查是否在左边界滚动区域
if (relativeX <= EDGE_SCROLL_ZONE && timelineContainer.value.scrollLeft > 0) {
startAutoScroll('left')
@@ -3371,6 +3499,7 @@ onUnmounted(() => {
)
window.removeEventListener('milestone-click-locate', handleMilestoneClickLocate as EventListener)
window.removeEventListener('drag-boundary-check', handleDragBoundaryCheck as EventListener)
window.removeEventListener('resource-taskbar-drop', handleResourceTaskBarDrop as EventListener)
// 清理ResizeObserver
if (resizeObserver) {
@@ -3657,6 +3786,16 @@ watch([timelineData, timelineContainerWidth], () => {
}, 100)
})
// 监听viewMode和dataSource变化,刷新缓存和时间线
watch(
[viewMode, dataSource],
() => {
invalidateTaskDateRangeCache()
debouncedUpdateTimelineRange()
},
{ deep: true },
)
// 监听tasks变化,清理不再存在的任务的位置信息
watch(
() => tasks.value,
@@ -4384,8 +4523,9 @@ const handleAddSuccessor = (task: Task) => {
<!-- 同时需要考虑左侧TaskList包含1px的bottom border -->
<div class="task-bar-container" :style="{ height: `${contentHeight}px` }">
<div class="task-rows" :style="{ height: `${contentHeight}px` }">
<!-- 使用虚拟滚动渲染可见任务 -->
<!-- 任务视图:使用虚拟滚动渲染可见任务 -->
<div
v-if="viewMode === 'task'"
v-for="{ task, originalIndex } in visibleTasks"
:key="task.id"
class="task-row"
@@ -4536,6 +4676,96 @@ const handleAddSuccessor = (task: Task) => {
</template>
</TaskBar>
</div>
<!-- 资源视图:一行渲染多个 TaskBar -->
<div
v-else-if="viewMode === 'resource'"
v-for="{ task: resource, originalIndex } in visibleTasks"
:key="resource.id"
class="task-row resource-row"
:class="{ 'task-row-hovered': hoveredTaskId === resource.id }"
:style="{ top: `${originalIndex * 51}px` }"
@mouseenter="handleTaskRowHover(resource.id)"
@mouseleave="handleTaskRowHover(null)"
>
<!-- 为资源下的每个任务渲染 TaskBar -->
<template v-if="(resource as any).tasks && (resource as any).tasks.length > 0">
<TaskBar
v-for="(task, taskIndex) in (resource as any).tasks"
:key="`taskbar-${task.id}-${taskBarRenderKey}`"
:task="task"
:row-index="originalIndex"
:row-height="51"
:day-width="dayWidth"
:start-date="
currentTimeScale === TimelineScale.YEAR
? getYearTimelineRange().startDate
: currentTimeScale === TimelineScale.MONTH
? getMonthTimelineRange().startDate
: timelineConfig.startDate
"
:scroll-left="timelineScrollLeft"
:container-width="timelineContainerWidth"
:hide-bubbles="hideBubbles"
:timeline-data="
currentTimeScale === TimelineScale.HOUR ? optimizedTimelineData : timelineData
"
:current-time-scale="currentTimeScale"
:task-bar-config="props.taskBarConfig"
:allow-drag-and-resize="props.allowDragAndResize && !isInHighlightMode"
:show-actual-taskbar="props.showActualTaskbar"
:enable-task-bar-tooltip="props.enableTaskBarTooltip"
:pending-task-background-color="props.pendingTaskBackgroundColor"
:delay-task-background-color="props.delayTaskBackgroundColor"
:complete-task-background-color="props.completeTaskBackgroundColor"
:ongoing-task-background-color="props.ongoingTaskBackgroundColor"
:is-highlighted="highlightedTaskIds.has(task.id)"
:is-primary-highlight="highlightedTaskId === task.id"
:is-in-highlight-mode="isInHighlightMode"
:drag-link-mode="dragLinkMode"
:is-link-drag-source="linkDragSourceTask?.id === task.id"
:is-valid-link-target="
linkDragTargetTask?.id === task.id && isValidLinkTarget === true
"
:is-invalid-link-target="
linkDragTargetTask?.id === task.id && isValidLinkTarget === false
"
:all-tasks="tasks"
:has-resource-conflict="resourceConflicts.get(resource.id)?.has(task.id) || false"
:style="{
zIndex: taskIndex,
}"
@update:task="updateTask"
@bar-mounted="handleBarMounted"
@click="handleTaskBarClick(task, $event)"
@dblclick="handleTaskBarDoubleClick(task)"
@drag-end="handleTaskBarDragEnd"
@resize-end="handleTaskBarResizeEnd"
@scroll-to-position="handleScrollToPosition"
@context-menu="handleTaskBarContextMenu"
@start-timer="handleStartTimer"
@stop-timer="handleStopTimer"
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@delete="handleTaskDelete"
@delete-link="handleDeleteLink"
@long-press="setHighlightTask"
@link-drag-start="handleLinkDragStart"
@link-drag-move="handleLinkDragMove"
@link-drag-end="handleLinkDragEnd"
>
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
<slot name="custom-task-content" v-bind="barScope" />
</template>
<template
v-if="$slots['task-bar-context-menu']"
#task-bar-context-menu="contextMenuScope"
>
<slot name="task-bar-context-menu" v-bind="contextMenuScope" />
</template>
</TaskBar>
</template>
</div>
</div>
</div>
</div>
@@ -4886,6 +5116,18 @@ const handleAddSuccessor = (task: Task) => {
transition: background-color 0.2s ease;
}
/* 资源视图行样式 */
.resource-row {
/* 不使用flex,保持TaskBar的绝对定位 */
position: absolute !important;
left: 0;
width: 100%;
height: 51px !important;
pointer-events: auto;
z-index: 11;
transition: background-color 0.2s ease;
}
.timeline-body .task-row-hovered {
background-color: var(--gantt-bg-hover); /* 与TaskList保持一致的悬停背景色 */
/* 降低层级,避免覆盖任务条等元素 */