v1.9.0 - 事件交叉Taskbar展示优化

This commit is contained in:
LINING-PC\lining
2026-01-30 16:53:59 +08:00
parent de6ce44000
commit 867c24186a
14 changed files with 1726 additions and 168 deletions
+211 -58
View File
@@ -9,6 +9,7 @@ import MilestoneDialog from './MilestoneDialog.vue'
import { useI18n, setCustomMessages } from '../composables/useI18n'
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
import { moveTask } from '../utils/taskTreeUtils'
import { assignTaskRows } from '../utils/taskLayoutUtils'
import jsPDF from 'jspdf'
import html2canvas from 'html2canvas'
import type { Task } from '../models/classes/Task'
@@ -129,6 +130,103 @@ provide('gantt-view-mode', currentViewMode)
provide('gantt-data-source', currentDataSource)
provide('gantt-list-config', currentListConfig)
// 计算资源视图下的任务布局信息
const resourceTaskLayouts = computed(() => {
const layouts = new Map<string, { taskRowMap: Map<string | number, number>, rowHeights: number[], totalHeight: number }>()
if (currentViewMode.value === 'resource') {
const resources = currentDataSource.value as Resource[]
const baseRowHeight = 51
// 依赖 updateTaskTrigger 以便在任务更新时重新计算布局
if (updateTaskTrigger.value >= 0) {
resources.forEach(resource => {
const resourceId = String(resource.id)
if (resource.tasks && resource.tasks.length > 0) {
const layout = assignTaskRows(resource.tasks, resourceId, baseRowHeight)
layouts.set(resourceId, layout)
} else {
// 没有任务的资源使用默认高度
layouts.set(resourceId, {
taskRowMap: new Map(),
rowHeights: [baseRowHeight],
totalHeight: baseRowHeight
})
}
})
}
}
return layouts
})
// 计算资源行的累积位置
const resourceRowPositions = computed(() => {
const positions = new Map<string, number>()
if (currentViewMode.value === 'resource') {
const resources = currentDataSource.value as Resource[]
let cumulativeTop = 0
resources.forEach(resource => {
const resourceId = String(resource.id)
positions.set(resourceId, cumulativeTop)
const layout = resourceTaskLayouts.value.get(resourceId)
const resourceHeight = layout?.totalHeight || 51
cumulativeTop += resourceHeight
})
}
return positions
})
// 计算资源冲突状态(依赖updateTaskTrigger以便实时更新)
const resourceConflicts = computed(() => {
if (currentViewMode.value !== 'resource') return new Map()
const resources = currentDataSource.value as Resource[]
const conflictsMap = new Map<string, Set<number>>()
// 依赖 updateTaskTrigger 以便在任务更新时重新计算冲突
if (updateTaskTrigger.value >= 0) {
resources.forEach(resource => {
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 j = i + 1; j < validTasks.length; j++) {
const task1 = validTasks[i]
const task2 = validTasks[j]
// 检查时间重叠
const start1 = new Date(task1.startDate!).getTime()
const end1 = new Date(task1.endDate!).getTime()
const start2 = new Date(task2.startDate!).getTime()
const end2 = new Date(task2.endDate!).getTime()
if (start1 < end2 && start2 < end1) {
conflicts.add(task1.id)
conflicts.add(task2.id)
}
}
}
if (conflicts.size > 0) {
conflictsMap.set(String(resource.id), conflicts)
}
})
}
return conflictsMap
})
// 提供资源布局信息给子组件
provide('resourceTaskLayouts', resourceTaskLayouts)
provide('resourceRowPositions', resourceRowPositions)
provide('resourceConflicts', resourceConflicts)
// 提供 slots 给子组件(TaskList 和 TaskRow
provide('gantt-column-slots', slots)
@@ -641,19 +739,15 @@ const handleToggleTaskList = (event: CustomEvent) => {
// --- 事件链路:监听 Timeline 传递上来的拖拽/拉伸事件,更新数据并通过 emit 暴露 ---
function handleTaskBarDragEnd(event: CustomEvent) {
const updatedTask = event.detail
// 更新 props.tasks 中的任务数据
if (props.tasks) {
updateTaskInTree(props.tasks, updatedTask)
}
// 更新任务数据并同步到资源视图
updateTaskAndSyncToResources(updatedTask)
updateTaskTrigger.value++
emit('taskbar-drag-end', updatedTask)
}
function handleTaskBarResizeEnd(event: CustomEvent) {
const updatedTask = event.detail
// 更新 props.tasks 中的任务数据
if (props.tasks) {
updateTaskInTree(props.tasks, updatedTask)
}
// 更新任务数据并同步到资源视图
updateTaskAndSyncToResources(updatedTask)
updateTaskTrigger.value++
emit('taskbar-resize-end', updatedTask)
}
@@ -2423,59 +2517,39 @@ function handleToolbarAddTask() {
// 监听TaskDrawer、TaskList、Timeline的计时事件,统一处理
const handleStartTimer = (task: Task) => {
// 任务树内状态同步
if (props.tasks) {
const updateTask = (tasks: Task[]): boolean => {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === task.id) {
tasks[i].isTimerRunning = true
tasks[i].timerStartTime = task.timerStartTime || Date.now()
tasks[i].timerEndTime = undefined
tasks[i].timerElapsedTime = 0
return true
}
if (tasks[i].children?.length) {
if (updateTask(tasks[i].children as Task[])) return true
}
}
return false
}
updateTask(props.tasks)
}
// 任务树内状态同步,同时同步到资源视图
updateTaskStateInTree(task.id, (t) => {
t.isTimerRunning = true
t.timerStartTime = task.timerStartTime || Date.now()
t.timerEndTime = undefined
t.timerElapsedTime = 0
})
closeContextMenu()
emit('timer-started', task)
}
const handleStopTimer = (task: Task) => {
// 任务树内状态同步
if (props.tasks) {
const updateTask = (tasks: Task[]): boolean => {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === task.id) {
if (tasks[i].isTimerRunning && tasks[i].timerStartTime !== undefined) {
const elapsed = tasks[i].timerElapsedTime || 0
tasks[i].timerElapsedTime = elapsed + (Date.now() - tasks[i].timerStartTime!)
tasks[i].timerEndTime = Date.now()
}
tasks[i].isTimerRunning = false
if (
taskDrawerVisible.value &&
taskDrawerTask.value &&
taskDrawerTask.value.id === task.id
) {
taskDrawerTask.value.isTimerRunning = false
taskDrawerTask.value.timerEndTime = Date.now()
}
return true
}
if (tasks[i].children?.length) {
if (updateTask(tasks[i].children as Task[])) return true
}
}
return false
// 任务树内状态同步,同时同步到资源视图
updateTaskStateInTree(task.id, (t) => {
if (t.isTimerRunning && t.timerStartTime !== undefined) {
const elapsed = t.timerElapsedTime || 0
t.timerElapsedTime = elapsed + (Date.now() - t.timerStartTime)
t.timerEndTime = Date.now()
}
updateTask(props.tasks)
}
t.isTimerRunning = false
// 同步更新TaskDrawer中的任务
if (
taskDrawerVisible.value &&
taskDrawerTask.value &&
taskDrawerTask.value.id === task.id
) {
taskDrawerTask.value.isTimerRunning = false
taskDrawerTask.value.timerEndTime = Date.now()
}
})
closeContextMenu()
emit('timer-stopped', task)
}
@@ -2613,6 +2687,75 @@ const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => {
return false
}
// v1.9.0 更新任务并同步到资源视图
const updateTaskAndSyncToResources = (updatedTask: Task) => {
// 1. 更新原始任务数据
if (props.tasks) {
updateTaskInTree(props.tasks, updatedTask)
}
// 2. 如果是资源视图,同步更新资源中的任务
if (currentViewMode.value === 'resource' && props.resources) {
// 查找任务所属的资源(通过assignee)
props.resources.forEach(resource => {
const taskIndex = resource.tasks.findIndex(t => t.id === updatedTask.id)
if (taskIndex !== -1) {
// 如果责任人变了,需要移动任务
if (updatedTask.assignee && updatedTask.assignee !== resource.id) {
// 从原资源中移除
resource.tasks.splice(taskIndex, 1)
// 添加到新资源
const newResource = props.resources.find(r => r.id === updatedTask.assignee)
if (newResource) {
newResource.tasks.push({ ...updatedTask })
}
} else {
// 责任人未变,只更新任务数据
resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask }
}
} else if (updatedTask.assignee === resource.id) {
// 任务新分配给这个资源
resource.tasks.push({ ...updatedTask })
}
})
}
}
// v1.9.0 在任务树中更新任务状态(用于timer等部分更新)
const updateTaskStateInTree = (taskId: number, updateFn: (task: Task) => void): boolean => {
const updateInList = (tasks: Task[]): boolean => {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === taskId) {
updateFn(tasks[i])
return true
}
if (tasks[i].children?.length) {
if (updateInList(tasks[i].children as Task[])) return true
}
}
return false
}
// 更新原始任务数据
let updated = false
if (props.tasks) {
updated = updateInList(props.tasks)
}
// 如果是资源视图,同步更新资源中的任务
if (updated && currentViewMode.value === 'resource' && props.resources) {
props.resources.forEach(resource => {
const task = resource.tasks.find(t => t.id === taskId)
if (task) {
updateFn(task)
}
})
}
return updated
}
// 在 handleTaskDrawerSubmit 里补充:如果是添加前置任务,自动将新任务id加入目标任务的 predecessor
function handleTaskDrawerSubmit(task: Task) {
if (!taskDrawerEditMode.value) {
@@ -2636,9 +2779,8 @@ function handleTaskDrawerSubmit(task: Task) {
taskToAddSuccessorTo.value = null
}
} else {
if (props.tasks) {
updateTaskInTree(props.tasks, task)
}
// 更新任务数据并同步到资源视图
updateTaskAndSyncToResources(task)
updateTaskTrigger.value++
// emit 任务更新事件
emit('task-updated', { task })
@@ -2671,6 +2813,17 @@ function handleTaskDelete(task: Task, deleteChildren?: boolean) {
if (props.tasks) {
removeTaskFromTree(props.tasks, task.id, deleteChildren)
}
// v1.9.0 如果是资源视图,同步从资源中删除任务
if (currentViewMode.value === 'resource' && props.resources) {
props.resources.forEach(resource => {
const taskIndex = resource.tasks.findIndex(t => t.id === task.id)
if (taskIndex !== -1) {
resource.tasks.splice(taskIndex, 1)
}
})
}
taskDrawerVisible.value = false
taskDrawerTask.value = null
// emit 删除事件
+157 -17
View File
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
<script setup lang="ts">
import { ref, computed, onUnmounted, onMounted, nextTick, watch, useSlots, inject, type ComputedRef } from 'vue'
import { ref, computed, onUnmounted, onMounted, nextTick, watch, useSlots, inject, type ComputedRef, type Ref } from 'vue'
import type { Task } from '../models/classes/Task'
import { TimelineScale } from '../models/types/TimelineScale'
import TaskContextMenu from './TaskContextMenu.vue'
@@ -18,8 +18,29 @@ defineOptions({
// 从 GanttChart 注入 enableLinkAnchor 配置
const enableLinkAnchor = inject<ComputedRef<boolean>>('enable-link-anchor', computed(() => true))
// v1.9.0 注入视图模式,用于资源视图的垂直拖拽
const viewMode = inject<any>('gantt-view-mode', { value: 'task' })
// v1.9.0 计算当前资源的投入占比
const resourcePercent = computed(() => {
// 如果直接传递了占比,使用传递的值
if (props.resourceAllocationPercent !== undefined) {
return Math.max(20, Math.min(100, props.resourceAllocationPercent))
}
// 在资源视图中,尝试从task.resources中查找当前资源的占比
if (viewMode.value === 'resource' && props.currentResourceId && props.task.resources) {
const allocation = props.task.resources.find((r: any) => r.id === props.currentResourceId)
if (allocation && allocation.percent !== undefined) {
return Math.max(20, Math.min(100, allocation.percent))
}
}
// 默认100%(向后兼容)
return 100
})
// v1.9.0 是否显示占比文字(占比<100%时显示)
const shouldShowPercentText = computed(() => {
return viewMode.value === 'resource' && resourcePercent.value < 100
})
interface Props {
task: Task
@@ -69,6 +90,14 @@ interface Props {
allTasks?: Task[]
// v1.9.0 资源视图:是否存在资源冲突(时间重叠)
hasResourceConflict?: boolean
// v1.9.0 资源视图:当前资源在任务中的投入占比 (20-100)
resourceAllocationPercent?: number
// v1.9.0 资源视图:当前资源ID(用于查找占比信息)
currentResourceId?: string | number
// v1.9.0 资源视图:任务在子行中的索引(换行布局)
taskSubRow?: number
// v1.9.0 资源视图:每个子行的高度数组(换行布局)
rowHeights?: number[]
}
interface TaskStatus {
@@ -119,6 +148,13 @@ defineSlots<{
const slots = useSlots()
// 注入视图模式
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
// 注入资源布局信息(用于判断跨行拖拽边界)
const resourceRowPositions = inject<ComputedRef<Map<string, number>>>('resourceRowPositions', computed(() => new Map()))
const resourceTaskLayouts = inject<ComputedRef<Map<string, { taskRowMap: Map<string | number, number>, rowHeights: number[], totalHeight: number }>>>('resourceTaskLayouts', computed(() => new Map()))
// 注入右键菜单配置
const enableTaskBarContextMenu = inject<ComputedRef<boolean>>('enable-task-bar-context-menu', computed(() => true))
const hasTaskBarContextMenuSlot = inject<ComputedRef<boolean>>('task-bar-context-menu-slot', computed(() => false))
@@ -540,9 +576,42 @@ const taskBarStyle = computed(() => {
}
}
const taskBarHeight = props.rowHeight - 10
// v1.9.0 资源视图中使用固定top值,因为resource-row已经绝对定位
const topOffset = viewMode.value === 'resource' ? 5 : (props.rowHeight - taskBarHeight) / 2
// v1.9.0 计算TaskBar高度:保持基础高度一致,资源视图中应用占比缩放
const baseTaskBarHeight = props.rowHeight - 10 // 基础高度(与任务视图一致)
let taskBarHeight = baseTaskBarHeight
if (viewMode.value === 'resource') {
// 资源视图:在基础高度上应用占比缩放
const percentValue = resourcePercent.value / 100
taskBarHeight = baseTaskBarHeight * percentValue
}
// v1.9.0 计算垂直位置:资源视图中支持换行布局
let topOffset = (props.rowHeight - taskBarHeight) / 2 // 默认:居中对齐
if (viewMode.value === 'resource' && props.taskSubRow !== undefined && props.rowHeights && props.rowHeights.length > 0) {
// 换行布局:根据子行索引和每行高度计算垂直位置
const subRow = props.taskSubRow
const rowHeights = props.rowHeights
const currentRowHeight = rowHeights[subRow] || 51
// 计算当前子行距离顶部的偏移量(累加前面所有行的高度)
let cumulativeOffset = 0
for (let i = 0; i < subRow; i++) {
cumulativeOffset += rowHeights[i] || 51
}
// 在当前子行内底边对齐
// 第一行:padding-top(5px) + 可用空间
// 后续行:可用空间(无padding-top
if (subRow === 0) {
// 第一行:顶部5px padding,底部对齐
topOffset = cumulativeOffset + (currentRowHeight - 5 - taskBarHeight)
} else {
// 后续行:底部对齐(底部5px padding
topOffset = cumulativeOffset + (currentRowHeight - 5 - taskBarHeight)
}
}
return {
left: `${left}px`,
@@ -1068,15 +1137,23 @@ const handleMouseMove = (e: MouseEvent) => {
if (viewMode.value === 'resource') {
;(window as any).lastDragMouseY = e.clientY
// v1.9.0 检测是否跨行拖拽
// v1.9.0 检测是否跨行拖拽(基于资源行的实际高度)
const timelineBody = document.querySelector('.timeline-body')
let isCrossRowDrag = false
if (timelineBody && isDragging.value && isDragThresholdMet.value) {
if (timelineBody && isDragging.value && isDragThresholdMet.value && props.currentResourceId) {
const bodyRect = timelineBody.getBoundingClientRect()
const relativeY = e.clientY - bodyRect.top + (timelineBody as HTMLElement).scrollTop
const currentRowIndex = Math.floor(relativeY / 51)
isCrossRowDrag = currentRowIndex !== props.rowIndex
// 获取当前资源行的位置和高度
const currentResourceId = String(props.currentResourceId)
const currentRowTop = resourceRowPositions.value.get(currentResourceId) || 0
const currentRowLayout = resourceTaskLayouts.value.get(currentResourceId)
const currentRowHeight = currentRowLayout?.totalHeight || 51
const currentRowBottom = currentRowTop + currentRowHeight
// 判断鼠标是否超出当前资源行的边界
isCrossRowDrag = relativeY < currentRowTop || relativeY >= currentRowBottom
// 只有在跨行拖拽时才显示预览
if (isCrossRowDrag) {
@@ -1624,21 +1701,30 @@ const handleMouseUp = () => {
)
// v1.9.0 资源视图垂直拖拽:检测是否移动到不同资源
// @ts-expect-error - targetResourceRowIndex预留变量,未来可能使用
let targetResourceRowIndex: number | undefined
let isCrossRowDrag = false
if (viewMode.value === 'resource' && isDragging.value && isDragThresholdMet.value) {
if (viewMode.value === 'resource' && isDragging.value && isDragThresholdMet.value && props.currentResourceId) {
// 记录松开鼠标时的X位置(用于计算新日期)
dragEndX.value = (window as any).event?.clientX || 0
// 检测是否跨行
// 检测是否跨行(基于资源行的实际高度)
const timelineBody = document.querySelector('.timeline-body')
if (timelineBody) {
const bodyRect = timelineBody.getBoundingClientRect()
const mouseY = (window as any).lastDragMouseY || 0
const relativeY = mouseY - bodyRect.top + (timelineBody as HTMLElement).scrollTop
const targetRowIndex = Math.floor(relativeY / 51)
isCrossRowDrag = targetRowIndex !== props.rowIndex
// 获取当前资源行的位置和高度
const currentResourceId = String(props.currentResourceId)
const currentRowTop = resourceRowPositions.value.get(currentResourceId) || 0
const currentRowLayout = resourceTaskLayouts.value.get(currentResourceId)
const currentRowHeight = currentRowLayout?.totalHeight || 51
const currentRowBottom = currentRowTop + currentRowHeight
// 判断鼠标是否超出当前资源行的边界
isCrossRowDrag = relativeY < currentRowTop || relativeY >= currentRowBottom
}
// 只有跨行拖拽才发送drop事件
@@ -2366,7 +2452,9 @@ const handleTaskBarMouseEnter = (event: MouseEvent) => {
// 保存event.currentTarget的引用,因为在setTimeout回调中它会变成null
const targetElement = event.currentTarget as HTMLElement
// 保存鼠标位置
// @ts-expect-error - 预留变量,未来可能使用
const mouseX = event.clientX
// @ts-expect-error - 预留变量,未来可能使用
const mouseY = event.clientY
// 延迟显示tooltip,避免快速滑过时显示
@@ -2381,6 +2469,7 @@ const handleTaskBarMouseEnter = (event: MouseEvent) => {
// 视口尺寸
const viewportWidth = window.innerWidth
// @ts-expect-error - 预留变量,未来可能使用
const viewportHeight = window.innerHeight
// v1.9.0 改为基于TaskBar边界定位
@@ -3121,12 +3210,16 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
<div
v-if="barConfig.showTitle && !(showActualTaskbar && hasActualProgress)"
ref="taskBarNameRef"
:style="viewMode.value === 'resource' ? {} : getNameStyles()"
:class="{ 'task-name-wrapper': viewMode.value === 'resource' }"
:style="viewMode === 'resource' ? {} : getNameStyles()"
:class="{ 'task-name-wrapper': viewMode === 'resource' }"
>
<slot v-if="hasContentSlot" name="custom-task-content" v-bind="slotPayload" />
<div v-else class="task-name">
{{ task.name }}
<!-- v1.9.0 资源视图:显示占比文字 -->
<span v-if="shouldShowPercentText" class="resource-percent-text">
{{ resourcePercent }}%
</span>
</div>
</div>
@@ -3134,7 +3227,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
<div
v-if="barConfig.showProgress && shouldShowProgress && !(showActualTaskbar && hasActualProgress)"
class="task-progress"
:style="viewMode.value === 'resource' ? {} : getProgressStyles()"
:style="viewMode === 'resource' ? {} : getProgressStyles()"
>
{{ task.progress || 0 }}%
</div>
@@ -3255,6 +3348,11 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
<div class="tooltip-arrow"></div>
<div class="tooltip-title">{{ task.name }}</div>
<div class="tooltip-content">
<!-- v1.9.0 资源视图:显示投入占比 -->
<div v-if="viewMode === 'resource' && resourcePercent < 100" class="tooltip-row">
<span class="tooltip-label">{{ t('investment') || '投入' }}:</span>
<span class="tooltip-value">{{ resourcePercent }}%</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label"> {{ t('startDate') }}:</span>
<span class="tooltip-value">{{ formatDisplayDate(task.startDate) }}</span>
@@ -3275,6 +3373,10 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
<span class="tooltip-label"> {{ t('progress') }}:</span>
<span class="tooltip-value">{{ task.progress || 0 }}%</span>
</div>
<!-- v1.9.0 资源冲突警告 -->
<div v-if="props.hasResourceConflict" class="tooltip-row tooltip-warning">
<span class="tooltip-label">⚠️ {{ t('resourceOverloaded') || '资源超负荷' }}</span>
</div>
</div>
</div>
</Teleport>
@@ -3876,6 +3978,31 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
/* 移除背景样式,保持原始状态 */
}
/* v1.9.0 资源占比文字样式 */
.resource-percent-text {
display: inline-block;
margin-left: 6px;
font-size: 12px;
font-weight: 500;
color: var(--gantt-text-on-primary, inherit);
opacity: 0.9;
}
/* TaskBar宽度<40px时隐藏占比文字 */
.task-bar[style*="width: 4px"],
.task-bar[style*="width: 8px"],
.task-bar[style*="width: 12px"],
.task-bar[style*="width: 16px"],
.task-bar[style*="width: 20px"],
.task-bar[style*="width: 24px"],
.task-bar[style*="width: 28px"],
.task-bar[style*="width: 32px"],
.task-bar[style*="width: 36px"] {
.resource-percent-text {
display: none;
}
}
.task-progress {
opacity: 0.9;
font-size: 11px;
@@ -4190,6 +4317,19 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
color: #ffffff;
}
/* v1.9.0 Tooltip警告行样式 */
.tooltip-warning {
border-top: 1px solid rgba(255, 107, 107, 0.3);
padding-top: 6px;
margin-top: 4px;
}
.tooltip-warning .tooltip-label {
color: #ff6b6b;
font-weight: 600;
opacity: 1;
}
/* === 拖拽实时反馈提示框样式 === */
.drag-tooltip {
position: fixed;
+195
View File
@@ -167,6 +167,7 @@ const formData = reactive<Task>({
timerEndTime: undefined, // 计时器结束时间
timerElapsedTime: 0, // 计时器已用时间
children: undefined, // 子任务列表
resources: [], // v1.9.0 资源分配列表(包含占比)
})
// 任务列表数据
@@ -425,6 +426,7 @@ const resetForm = () => {
bgColor: undefined,
avatar: undefined,
barColor: undefined,
resources: [], // v1.9.0 资源分配
})
// 清除错误信息
@@ -677,6 +679,42 @@ const handleAssigneeChanged = (event: Event) => {
}
}
// v1.9.0 资源占比管理
const addResource = () => {
if (!formData.resources) {
formData.resources = []
}
formData.resources.push({
id: '',
name: '',
percent: 100,
})
}
const removeResource = (index: number) => {
if (formData.resources && formData.resources.length > index) {
formData.resources.splice(index, 1)
}
}
const handleResourceChange = (index: number, field: 'id' | 'percent', value: string | number) => {
if (!formData.resources || !formData.resources[index]) return
if (field === 'id') {
const selected = props.assigneeOptions?.find(option => option.value === value)
if (selected) {
formData.resources[index].id = selected.value
formData.resources[index].name = selected.label
}
} else if (field === 'percent') {
let percent = typeof value === 'string' ? parseInt(value) : value
// 校验范围 20-100
if (percent < 20) percent = 20
if (percent > 100) percent = 100
formData.resources[index].percent = percent
}
}
// 计算任务状态
const taskStatus = computed(() => {
if (!props.task) return { text: '', color: '', bgColor: '', borderColor: '' }
@@ -925,6 +963,71 @@ const taskStatus = computed(() => {
</select>
</div>
<!-- v1.9.0 资源分配含占比配置 -->
<div class="form-group">
<label class="form-label">{{ t.resourceAllocation || '资源分配' }}</label>
<div class="resource-list">
<div
v-for="(resource, index) in formData.resources"
:key="index"
class="resource-item"
>
<select
v-model="resource.id"
class="form-select resource-select"
@change="handleResourceChange(index, 'id', ($event.target as HTMLSelectElement).value)"
>
<option value="">{{ t.selectResource || '选择资源' }}</option>
<option
v-for="assignee in props.assigneeOptions"
:key="assignee.key ?? assignee.value"
:value="assignee.value"
>
{{ assignee.label }}
</option>
</select>
<div class="percent-input-wrapper">
<select
v-model.number="resource.percent"
class="form-select percent-select"
@change="handleResourceChange(index, 'percent', ($event.target as HTMLSelectElement).value)"
>
<option :value="25">25%</option>
<option :value="50">50%</option>
<option :value="75">75%</option>
<option :value="100">100%</option>
</select>
<input
type="number"
v-model.number="resource.percent"
class="form-input percent-input"
min="20"
max="100"
@input="handleResourceChange(index, 'percent', ($event.target as HTMLInputElement).value)"
/>
<span class="percent-unit">%</span>
</div>
<button
type="button"
class="btn-remove-resource"
@click="removeResource(index)"
title="删除资源"
>
×
</button>
</div>
<button
type="button"
class="btn-add-resource"
@click="addResource"
>
+ {{ t.addResource || '添加资源' }}
</button>
</div>
</div>
<!-- 上级任务选择 -->
<div class="form-group">
<label class="form-label" for="task-parent">{{ t.parentTask }}</label>
@@ -1570,4 +1673,96 @@ const taskStatus = computed(() => {
:global(html[data-theme='dark']) .form-textarea::placeholder {
color: var(--gantt-text-muted, #9e9e9e) !important;
}
/* v1.9.0 资源分配样式 */
.resource-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.resource-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: var(--gantt-bg-secondary, #f5f7fa);
border-radius: 4px;
}
.resource-select {
flex: 1;
min-width: 140px;
}
.percent-input-wrapper {
display: flex;
align-items: center;
gap: 4px;
}
.percent-select {
width: 80px;
}
.percent-input {
width: 60px;
padding: 6px 8px;
}
.percent-unit {
font-size: 14px;
color: var(--gantt-text-secondary, #606266);
}
.btn-remove-resource {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--gantt-border-base, #dcdfe6);
border-radius: 4px;
color: var(--gantt-text-secondary, #606266);
cursor: pointer;
font-size: 20px;
line-height: 1;
transition: all 0.2s;
}
.btn-remove-resource:hover {
background: var(--gantt-danger-light, #fef0f0);
border-color: var(--gantt-danger, #f56c6c);
color: var(--gantt-danger, #f56c6c);
}
.btn-add-resource {
width: 100%;
padding: 8px;
background: transparent;
border: 1px dashed var(--gantt-border-base, #dcdfe6);
border-radius: 4px;
color: var(--gantt-primary, #409eff);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-add-resource:hover {
border-color: var(--gantt-primary, #409eff);
background: var(--gantt-primary-light, #ecf5ff);
}
:global(html[data-theme='dark']) .resource-item {
background: var(--gantt-bg-secondary, rgba(255, 255, 255, 0.05)) !important;
}
:global(html[data-theme='dark']) .btn-remove-resource:hover {
background: var(--gantt-danger-dark, rgba(245, 108, 108, 0.2)) !important;
}
:global(html[data-theme='dark']) .btn-add-resource:hover {
background: var(--gantt-primary-dark, rgba(64, 158, 255, 0.2)) !important;
}
</style>
+5 -3
View File
@@ -6,6 +6,7 @@ import { useI18n } from '../../composables/useI18n'
import type { Task } from '../../models/classes/Task'
import type { Resource } from '../../models/classes/Resource'
import type { TaskListConfig, TaskListColumnConfig } from '../../models/configs/TaskListConfig'
// @ts-expect-error - ResourceListColumnConfig is used in type unions
import type { ResourceListConfig, ResourceListColumnConfig } from '../../models/configs/ResourceListConfig'
import { DEFAULT_TASK_LIST_COLUMNS } from '../../models/configs/TaskListConfig'
import { DEFAULT_RESOURCE_LIST_COLUMNS } from '../../models/configs/ResourceListConfig'
@@ -86,7 +87,7 @@ const { declarativeColumns, getColumnWidthStyle: getDeclarativeColumnWidth } =
useTaskListColumns(
computed(() => props.taskListColumnRenderMode || 'default'),
slots,
finalColumnsConfig.value,
finalColumnsConfig.value as TaskListColumnConfig[],
)
// 使
@@ -239,6 +240,7 @@ const handleTaskRowMoved = (payload: {
}
// v1.9.0
// @ts-expect-error - Reserved for future resource click handling
const handleResourceClick = (resource: Resource) => {
emit('resource-click', resource)
}
@@ -308,7 +310,7 @@ onUnmounted(() => {
<component :is="columnSlots['header-name']" />
</template>
<template v-else>
{{ (t as any).taskName || '任务名称' }}
{{ viewMode === 'resource' ? ((t as any).resourceName || '资源名称') : ((t as any).taskName || '任务名称') }}
</template>
</div>
<div
@@ -345,7 +347,7 @@ onUnmounted(() => {
:is-hovered="hoveredTaskId === task.id"
:hovered-task-id="hoveredTaskId"
:on-hover="handleTaskRowHover"
:columns="taskListColumnRenderMode === 'declarative' ? [] : visibleColumns"
:columns="taskListColumnRenderMode === 'declarative' ? [] : (visibleColumns as TaskListColumnConfig[])"
:declarative-columns="taskListColumnRenderMode === 'declarative' ? columnsToUse : undefined"
:render-mode="taskListColumnRenderMode"
:get-column-width-style="getColumnWidthStyle"
@@ -1,5 +1,6 @@
import { ref, computed, type Ref } from 'vue'
import { ref, computed, inject, type Ref, type ComputedRef } from 'vue'
import type { Task } from '../../../../models/classes/Task'
import type { Resource } from '../../../../models/classes/Resource'
/**
* TaskList
@@ -29,6 +30,15 @@ export function useTaskListLayout(tasks: Ref<Task[]>) {
const taskListScrollTop = ref(0)
const taskListBodyHeight = ref(0)
// v1.9.0 注入视图模式和资源布局信息
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
const resourceTaskLayouts = inject<ComputedRef<Map<string | number, {
taskRowMap: Map<string | number, number>,
rowHeights: number[],
totalHeight: number
}>>>('resourceTaskLayouts', computed(() => new Map()))
/**
*
*/
@@ -55,22 +65,81 @@ export function useTaskListLayout(tasks: Ref<Task[]>) {
const flattenedTasks = computed(() => getFlattenedVisibleTasks(tasks.value))
/**
*
* v1.9.0 /
*/
const cumulativeHeights = computed<number[]>(() => {
if (viewMode.value === 'resource') {
// 资源视图:使用每个资源的实际高度
const resources = dataSource.value as Resource[]
const heights: number[] = [0] // 第一个位置是0
let cumulative = 0
resources.forEach(resource => {
const layout = resourceTaskLayouts.value.get(resource.id)
const height = layout?.totalHeight || ROW_HEIGHT
cumulative += height
heights.push(cumulative)
})
return heights
} else {
// 任务视图:使用固定行高
const heights: number[] = [0]
for (let i = 1; i <= flattenedTasks.value.length; i++) {
heights.push(i * ROW_HEIGHT)
}
return heights
}
})
/**
* /
*/
const findIndexByScrollTop = (scrollTop: number): number => {
const heights = cumulativeHeights.value
let left = 0
let right = heights.length - 1
while (left < right) {
const mid = Math.floor((left + right) / 2)
if (heights[mid] <= scrollTop) {
left = mid + 1
} else {
right = mid
}
}
return Math.max(0, left - 1)
}
/**
*
*/
const visibleTaskRange = computed<VisibleTaskRange>(() => {
const scrollTop = taskListScrollTop.value
const containerHeight = taskListBodyHeight.value || 600
const heights = cumulativeHeights.value
const startIndex = Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER
const endIndex = Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER
if (heights.length <= 1) {
return { startIndex: 0, endIndex: 0 }
}
const total = flattenedTasks.value.length
const clampedStart = Math.min(Math.max(0, startIndex), total)
const clampedEnd = Math.min(total, Math.max(clampedStart + 1, endIndex))
// 找到起始索引(考虑缓冲区)
let startIndex = findIndexByScrollTop(scrollTop)
startIndex = Math.max(0, startIndex - VERTICAL_BUFFER)
// 找到结束索引(考虑缓冲区)
const scrollBottom = scrollTop + containerHeight
let endIndex = findIndexByScrollTop(scrollBottom)
endIndex = Math.min(heights.length - 1, endIndex + VERTICAL_BUFFER + 1)
const total = viewMode.value === 'resource'
? (dataSource.value as Resource[]).length
: flattenedTasks.value.length
return {
startIndex: clampedStart,
endIndex: clampedEnd,
startIndex: Math.min(startIndex, total),
endIndex: Math.min(endIndex, total),
}
})
@@ -88,13 +157,22 @@ export function useTaskListLayout(tasks: Ref<Task[]>) {
})
/**
* Spacer
* Spacer
*/
const totalContentHeight = computed(() => flattenedTasks.value.length * ROW_HEIGHT)
const startSpacerHeight = computed(() => visibleTaskRange.value.startIndex * ROW_HEIGHT)
const totalContentHeight = computed(() => {
const heights = cumulativeHeights.value
return heights.length > 0 ? heights[heights.length - 1] : 0
})
const startSpacerHeight = computed(() => {
const startIdx = visibleTaskRange.value.startIndex
return cumulativeHeights.value[startIdx] || 0
})
const endSpacerHeight = computed(() => {
const visibleHeight = visibleTasks.value.length * ROW_HEIGHT
return Math.max(0, totalContentHeight.value - startSpacerHeight.value - visibleHeight)
const endIdx = visibleTaskRange.value.endIndex
const endHeight = cumulativeHeights.value[endIdx] || 0
return Math.max(0, totalContentHeight.value - endHeight)
})
return {
+95 -6
View File
@@ -4,6 +4,7 @@ import type { StyleValue } from 'vue'
import { useI18n } from '../../../composables/useI18n'
import { formatPredecessorDisplay } from '../../../utils/predecessorUtils'
import type { Task } from '../../../models/classes/Task'
// @ts-expect-error - Resource is used in type definitions
import type { Resource } from '../../../models/classes/Resource'
import type { TaskListColumnConfig } from '../../../models/configs/TaskListConfig'
import type { DeclarativeColumnConfig } from '../composables/taskList/useTaskListColumns'
@@ -86,11 +87,37 @@ const hasContentSlot = computed(() => Boolean(slots['custom-task-content']))
// v1.9.0 Inject view mode to detect if we're rendering a resource
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
// GanttChart
const resourceTaskLayouts = inject<ComputedRef<Map<string, { taskRowMap: Map<string | number, number>, rowHeights: number[], totalHeight: number }>>>('resourceTaskLayouts', computed(() => new Map()))
// v1.9.0 Detect if current row is a resource
const isResourceRow = computed(() => {
return viewMode.value === 'resource' && 'tasks' in props.task
})
// v1.9.0
const isResourceOverloaded = computed(() => {
if (!isResourceRow.value) return false
// Resource
const resource = props.task as any
if (typeof resource.isOverloaded === 'function') {
return resource.isOverloaded()
}
return false
})
// - resource使
const rowHeight = computed(() => {
if (isResourceRow.value) {
const resourceId = String(props.task.id) // string
const layout = resourceTaskLayouts.value.get(resourceId)
return layout?.totalHeight || 51
}
return 51 // task使
})
//
const enableTaskListContextMenu = inject<ComputedRef<boolean>>('enable-task-list-context-menu', computed(() => true))
const hasTaskListContextMenuSlot = inject<ComputedRef<boolean>>('task-list-context-menu-slot', computed(() => false))
@@ -236,9 +263,16 @@ const slotPayload = computed(() => ({
progressClass: progressClass.value,
}))
// - barColor
// - barColor/color
const leftBorderColor = computed(() => {
// barColor使
// 使resource.color
if (isResourceRow.value) {
const resource = props.task as any
if (resource.color) {
return resource.color
}
}
// 使barColor
if (props.task.barColor) {
return props.task.barColor
}
@@ -247,10 +281,12 @@ const leftBorderColor = computed(() => {
})
//
const customBorderStyle = computed(() => {
const customBorderStyle = computed((): StyleValue => {
if (leftBorderColor.value) {
return {
borderLeftColor: leftBorderColor.value
borderLeftColor: `${leftBorderColor.value} !important` as any,
borderLeftWidth: '3px',
borderLeftStyle: 'solid' as const,
}
}
return {}
@@ -298,7 +334,7 @@ const assigneeDisplayData = computed(() => {
:data-task-id="props.task.id"
:class="{
'task-row-hovered': isHovered,
'task-type-story': viewMode === 'resource', /* v1.9.0 资源视图始终显示左边框 */
'task-type-resource': isResourceRow, /* v1.9.0 资源视图始终显示左边框 */
'parent-task': isParentTask,
'milestone-group-row': isMilestoneGroup,
'task-type-story': isStoryTask,
@@ -306,7 +342,7 @@ const assigneeDisplayData = computed(() => {
'task-type-milestone': isMilestoneTask,
[customRowClass]: customRowClass,
}"
:style="[customRowStyle, customBorderStyle]"
:style="[customRowStyle, customBorderStyle, { height: `${rowHeight}px` }]"
@click="handleRowClick"
@dblclick="handleTaskRowDoubleClick"
@mousedown="handleMouseDown"
@@ -374,6 +410,25 @@ const assigneeDisplayData = computed(() => {
<!-- v1.9.0 资源视图直接显示资源名称 -->
<div v-if="isResourceRow" class="resource-row-name">
<!-- v1.9.0 资源超载警示图标 -->
<svg
v-if="isResourceOverloaded"
class="resource-warning-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2L2 20h20L12 2z"
fill="var(--gantt-danger, #f56c6c)"
/>
<path
d="M12 8v6M12 16h.01"
stroke="#fff"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<div v-if="(props.task as any).avatar" class="resource-avatar">
<img :src="(props.task as any).avatar" :alt="(props.task as any).name" />
</div>
@@ -626,6 +681,11 @@ const assigneeDisplayData = computed(() => {
border-left: 3px solid var(--gantt-danger, #f56c6c);
}
/* v1.9.0 资源类型左边框颜色 */
.task-type-resource {
border-left: 3px solid var(--gantt-success, #67c23a);
}
/* 任务类型悬停时保持左边框,无需加粗 */
.task-type-story:hover {
border-left: 3px solid var(--gantt-primary, #409eff);
@@ -639,6 +699,10 @@ const assigneeDisplayData = computed(() => {
border-left: 3px solid var(--gantt-danger, #f56c6c);
}
.task-type-resource:hover {
border-left: 3px solid var(--gantt-success, #67c23a);
}
/* 悬停状态下的左边框保持 */
.task-row-hovered.task-type-story {
border-left: 3px solid var(--gantt-primary, #409eff) !important;
@@ -652,6 +716,10 @@ const assigneeDisplayData = computed(() => {
border-left: 3px solid var(--gantt-danger, #f56c6c) !important;
}
.task-row-hovered.task-type-resource {
border-left: 3px solid var(--gantt-success, #67c23a) !important;
}
:global(html[data-theme='dark']) .milestone-group-row {
border-left-color: var(--gantt-danger, #f67c7c);
}
@@ -669,6 +737,10 @@ const assigneeDisplayData = computed(() => {
border-left-color: var(--gantt-danger, #f67c7c);
}
:global(html[data-theme='dark']) .task-type-resource {
border-left-color: var(--gantt-success, #85ce61);
}
.collapse-btn:hover {
background-color: var(--gantt-primary-light);
}
@@ -850,6 +922,23 @@ const assigneeDisplayData = computed(() => {
color: var(--gantt-text-primary);
}
/* v1.9.0 资源超载警示图标 */
.resource-warning-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.resource-avatar {
width: 28px;
height: 28px;
+203 -40
View File
@@ -15,7 +15,7 @@ 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'
import { assignTaskRows } from '../utils/taskLayoutUtils' // v1.9.0
// Props
interface Props {
@@ -89,7 +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
'resource-drag-end': [{ task: Task; sourceResourceIndex: number; targetResourceIndex: number; targetResource: Resource; newStartDate?: string; newEndDate?: string }] // v1.9.0
}>()
//
@@ -104,21 +104,60 @@ const t = (key: string): string => {
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()
// v1.9.0 GanttChart GanttChart updateTaskTrigger
const resourceConflicts = inject<ComputedRef<Map<string, Set<number>>>>('resourceConflicts', computed(() => new Map()))
// v1.9.0
const resourceTaskLayouts = computed(() => {
const layoutMap = new Map<string | number, {
taskRowMap: Map<string | number, number>,
rowHeights: number[],
totalHeight: number
}>()
if (viewMode.value !== 'resource') {
return layoutMap
}
const resources = dataSource.value as Resource[]
const conflictsMap = new Map<string | number, Set<number>>()
resources.forEach(resource => {
const conflicts = detectResourceConflicts(resource)
if (conflicts.size > 0) {
conflictsMap.set(resource.id, conflicts)
const resourceTasks = (resource as any).tasks || []
if (resourceTasks.length > 0) {
const result = assignTaskRows(resourceTasks, resource.id, 51)
layoutMap.set(resource.id, result)
} else {
// 使
layoutMap.set(resource.id, {
taskRowMap: new Map(),
rowHeights: [51],
totalHeight: 51
})
}
})
return conflictsMap
return layoutMap
})
// v1.9.0
const resourceRowPositions = computed(() => {
const positions = new Map<string | number, number>()
if (viewMode.value !== 'resource') {
return positions
}
const resources = dataSource.value as Resource[]
let cumulativeTop = 0
resources.forEach(resource => {
positions.set(resource.id, cumulativeTop)
const layout = resourceTaskLayouts.value.get(resource.id)
const resourceHeight = layout?.totalHeight || 51
cumulativeTop += resourceHeight
})
return positions
})
// 线
@@ -664,7 +703,7 @@ const getYearTimelineRange = () => {
}
//
const hoveredTaskId = ref<number | null>(null)
const hoveredTaskId = ref<number | string | null>(null)
//
const highlightedTaskId = ref<number | null>(null)
@@ -1342,30 +1381,86 @@ const visibleHourRange = computed(() => {
}
})
//
//
const visibleTaskRange = computed(() => {
const scrollTop = timelineBodyScrollTop.value
const containerHeight = timelineBodyHeight.value || 600
//
const startIndex = Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER
const endIndex = Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER
if (viewMode.value === 'resource') {
//
const resources = dataSource.value as Resource[]
return {
startIndex: Math.max(0, startIndex),
endIndex: Math.min(tasks.value.length, Math.max(startIndex + 1, endIndex)),
let startIndex = 0
let endIndex = resources.length
//
for (let i = 0; i < resources.length; i++) {
const resourceId = resources[i].id
const rowTop = resourceRowPositions.value.get(resourceId) || 0
const layout = resourceTaskLayouts.value.get(resourceId)
const rowHeight = layout?.totalHeight || ROW_HEIGHT
const rowBottom = rowTop + rowHeight
if (rowBottom >= scrollTop - ROW_HEIGHT * VERTICAL_BUFFER) {
startIndex = i
break
}
}
//
const scrollBottom = scrollTop + containerHeight
for (let i = startIndex; i < resources.length; i++) {
const resourceId = resources[i].id
const rowTop = resourceRowPositions.value.get(resourceId) || 0
if (rowTop > scrollBottom + ROW_HEIGHT * VERTICAL_BUFFER) {
endIndex = i
break
}
}
return {
startIndex: Math.max(0, startIndex),
endIndex: Math.min(resources.length, endIndex),
}
} else {
// 使
const startIndex = Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER
const endIndex = Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER
return {
startIndex: Math.max(0, startIndex),
endIndex: Math.min(tasks.value.length, Math.max(startIndex + 1, endIndex)),
}
}
})
//
const visibleTasks = computed(() => {
const { startIndex, endIndex } = visibleTaskRange.value
//
return tasks.value.slice(startIndex, endIndex).map((task, index) => ({
task,
originalIndex: startIndex + index,
}))
})
// v1.9.0
const visibleResources = computed(() => {
const { startIndex, endIndex } = visibleTaskRange.value
if (viewMode.value !== 'resource') {
return []
}
const resources = dataSource.value as Resource[]
return resources.slice(startIndex, endIndex).map((resource, index) => ({
resource,
originalIndex: startIndex + index,
}))
})
//
const debounce = <T extends (...args: unknown[]) => void>(func: T, wait: number): T => {
let timeout: number | null = null
@@ -1688,7 +1783,7 @@ const handleTimelineContainerResized = () => {
}
//
const handleTaskRowHover = (taskId: number | null) => {
const handleTaskRowHover = (taskId: number | string | null) => {
// Splitter
if (isSplitterDragging.value || isDragging.value) {
return
@@ -1705,11 +1800,25 @@ const handleTaskRowHover = (taskId: number | null) => {
// Timeline
const contentHeight = computed(() => {
// 51px (50px + 1px border)
const minHeight = 400 //
// v1.9.0 使
if (viewMode.value === 'resource') {
const resources = dataSource.value as Resource[]
let totalHeight = 0
resources.forEach(resource => {
const layout = resourceTaskLayouts.value.get(resource.id)
totalHeight += layout?.totalHeight || 51
})
return Math.max(totalHeight, minHeight, timelineBodyHeight.value)
}
// 51px (50px + 1px border)
const rowHeight = 51
const taskCount = tasks.value.length
const minHeightFromTasks = taskCount * rowHeight
const minHeight = 400 //
//
return Math.max(minHeightFromTasks, minHeight, timelineBodyHeight.value)
@@ -3147,6 +3256,7 @@ const handleTaskBarHighlighted = () => {
// v1.9.0 TaskBar
const handleResourceTaskBarDrop = (event: Event) => {
const customEvent = event as CustomEvent
// @ts-expect-error - taskIdmouseX使
const { taskId, task, sourceRowIndex, mouseY, mouseX } = customEvent.detail
//
@@ -3155,15 +3265,41 @@ const handleResourceTaskBarDrop = (event: Event) => {
const bodyRect = timelineBody.getBoundingClientRect()
const relativeY = mouseY - bodyRect.top + timelineBody.scrollTop
const targetRowIndex = Math.floor(relativeY / 51) // 51px
// v1.9.0 使
const resources = dataSource.value as Resource[]
let targetRowIndex = -1
let minDistance = Infinity
//
for (let i = 0; i < resources.length; i++) {
const resource = resources[i]
const resourceId = String(resource.id)
const rowTop = resourceRowPositions.value.get(resourceId) || 0
const layout = resourceTaskLayouts.value.get(resourceId)
const rowHeight = layout?.totalHeight || 51
const rowCenter = rowTop + rowHeight / 2
const distance = Math.abs(relativeY - rowCenter)
if (distance < minDistance) {
minDistance = distance
targetRowIndex = i
}
}
// 使
if (targetRowIndex === -1 && resources.length > 0) {
if (relativeY < 0) {
targetRowIndex = 0
} else {
targetRowIndex = resources.length - 1
}
}
// 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]
@@ -3444,19 +3580,40 @@ const handleDragBoundaryCheck = (event: CustomEvent) => {
// 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
const relativeY = mouseY - bodyRect.top + timelineBodyElement.value.scrollTop
// 使
const resources = dataSource.value as Resource[]
let targetRowIndex = -1
let minDistance = Infinity
for (let i = 0; i < resources.length; i++) {
const resource = resources[i]
const resourceId = String(resource.id)
const rowTop = resourceRowPositions.value.get(resourceId) || 0
const layout = resourceTaskLayouts.value.get(resourceId)
const rowHeight = layout?.totalHeight || 51
const rowCenter = rowTop + rowHeight / 2
const distance = Math.abs(relativeY - rowCenter)
if (distance < minDistance) {
minDistance = distance
targetRowIndex = i
}
}
//
if (targetRowIndex !== rowIndex && targetRowIndex >= 0) {
window.dispatchEvent(new CustomEvent('resource-drag-over', {
detail: {
taskId,
sourceRowIndex: rowIndex,
targetRowIndex,
mouseY: relativeY
}
}))
if (targetRowIndex !== -1 && targetRowIndex !== rowIndex) {
window.dispatchEvent(
new CustomEvent('resource-drag-over', {
detail: {
taskId,
sourceRowIndex: rowIndex,
targetRowIndex,
mouseY: relativeY,
},
}),
)
}
}
@@ -4680,11 +4837,14 @@ const handleAddSuccessor = (task: Task) => {
<!-- 资源视图一行渲染多个 TaskBar -->
<div
v-else-if="viewMode === 'resource'"
v-for="{ task: resource, originalIndex } in visibleTasks"
v-for="{ resource, originalIndex } in visibleResources"
:key="resource.id"
class="task-row resource-row"
:class="{ 'task-row-hovered': hoveredTaskId === resource.id }"
:style="{ top: `${originalIndex * 51}px` }"
:style="{
top: `${resourceRowPositions.get(resource.id) || 0}px`,
height: `${resourceTaskLayouts.get(resource.id)?.totalHeight || 51}px`
}"
@mouseenter="handleTaskRowHover(resource.id)"
@mouseleave="handleTaskRowHover(null)"
>
@@ -4696,6 +4856,8 @@ const handleAddSuccessor = (task: Task) => {
:task="task"
:row-index="originalIndex"
:row-height="51"
:task-sub-row="resourceTaskLayouts.get(resource.id)?.taskRowMap.get(task.id) || 0"
:row-heights="resourceTaskLayouts.get(resource.id)?.rowHeights || [51]"
:day-width="dayWidth"
:start-date="
currentTimeScale === TimelineScale.YEAR
@@ -4731,7 +4893,8 @@ const handleAddSuccessor = (task: Task) => {
linkDragTargetTask?.id === task.id && isValidLinkTarget === false
"
:all-tasks="tasks"
:has-resource-conflict="resourceConflicts.get(resource.id)?.has(task.id) || false"
:has-resource-conflict="resourceConflicts.get(String(resource.id))?.has(task.id) || false"
:current-resource-id="resource.id"
:style="{
zIndex: taskIndex,
}"
@@ -5122,7 +5285,7 @@ const handleAddSuccessor = (task: Task) => {
position: absolute !important;
left: 0;
width: 100%;
height: 51px !important;
/* height由内联样式动态设置,不使用固定值 */
pointer-events: auto;
z-index: 11;
transition: background-color 0.2s ease;
+14
View File
@@ -9,6 +9,7 @@ const messages = {
dateNotSet: '未设置',
// TaskList Header
taskName: '任务名称',
resourceName: '资源名称',
predecessor: '前置任务',
assignee: '分配人',
startDate: '开始日期',
@@ -205,6 +206,12 @@ const messages = {
taskDeletedSuccess: '已删除任务',
// 选择器占位符
selectAssignee: '请选择负责人',
// v1.9.0 资源分配
resourceAllocation: '资源分配',
selectResource: '选择资源',
addResource: '添加资源',
percentMinError: '最小占比为20%',
percentMaxWarning: '占比超过100%将标记为超负荷',
// 通用消息
customCsvExportCalled: '自定义CSV导出被调用',
languageSwitchedTo: '语言切换到:{language}',
@@ -295,6 +302,7 @@ const messages = {
dateNotSet: 'Not set',
// TaskList Header
taskName: 'Task Name',
resourceName: 'Resource Name',
predecessor: 'Predecessor',
assignee: 'Assignee',
startDate: 'Start Date',
@@ -492,6 +500,12 @@ const messages = {
taskDeletedSuccess: 'Task deleted',
// Selector placeholders
selectAssignee: 'Select assignee',
// v1.9.0 Resource allocation
resourceAllocation: 'Resource Allocation',
selectResource: 'Select resource',
addResource: 'Add Resource',
percentMinError: 'Minimum percentage is 20%',
percentMaxWarning: 'Percentage over 100% will be marked as overloaded',
// Common messages
customCsvExportCalled: 'Custom CSV export called',
languageSwitchedTo: 'Language switched to: {language}',
+97
View File
@@ -16,6 +16,7 @@ export class Resource {
department?: string
skills?: string[]
utilization?: number
color?: string // 自定义资源行左边框颜色,如 '#ff5733',若不设置则使用默认颜色方案
tasks: Task[]
[key: string]: unknown
@@ -28,6 +29,7 @@ export class Resource {
department?: string
skills?: string[]
utilization?: number
color?: string
tasks?: Task[]
[key: string]: unknown
}) {
@@ -39,6 +41,7 @@ export class Resource {
this.department = data.department
this.skills = data.skills
this.utilization = data.utilization
this.color = data.color
this.tasks = data.tasks || []
// 复制其他自定义属性
@@ -88,4 +91,98 @@ export class Resource {
updateUtilization(): void {
this.utilization = this.calculateUtilization()
}
/**
*
* @returns
*/
hasTaskOverlap(): boolean {
if (this.tasks.length < 2) return false
// 过滤掉没有开始日期和结束日期的任务
const validTasks = this.tasks.filter(task => task.startDate && task.endDate)
if (validTasks.length < 2) return false
// 按开始日期排序
const sortedTasks = [...validTasks].sort((a, b) => {
const dateA = new Date(a.startDate!).getTime()
const dateB = new Date(b.startDate!).getTime()
return dateA - dateB
})
// 检测相邻任务是否重叠
for (let i = 0; i < sortedTasks.length - 1; i++) {
const currentTask = sortedTasks[i]
const nextTask = sortedTasks[i + 1]
const currentEnd = new Date(currentTask.endDate!).getTime()
const nextStart = new Date(nextTask.startDate!).getTime()
// 如果当前任务的结束时间晚于下一个任务的开始时间,则存在重叠
if (currentEnd > nextStart) {
return true
}
}
return false
}
/**
* v1.9.0
* > 100%
* @returns
*/
isOverloaded(): boolean {
if (this.tasks.length < 2) return false
// 过滤掉没有开始日期和结束日期的任务
const validTasks = this.tasks.filter(task => task.startDate && task.endDate)
if (validTasks.length < 2) return false
// 检测任意时间点的总占比是否超过100%
for (let i = 0; i < validTasks.length; i++) {
for (let j = i + 1; j < validTasks.length; j++) {
const task1 = validTasks[i]
const task2 = validTasks[j]
// 检查两个任务是否有时间交集
const start1 = new Date(task1.startDate!).getTime()
const end1 = new Date(task1.endDate!).getTime()
const start2 = new Date(task2.startDate!).getTime()
const end2 = new Date(task2.endDate!).getTime()
// 判断时间是否重叠
if (start1 < end2 && start2 < end1) {
// 有重叠,计算总占比
const percent1 = this.getTaskAllocationPercent(task1)
const percent2 = this.getTaskAllocationPercent(task2)
if (percent1 + percent2 > 100) {
return true // 超负荷
}
}
}
}
return false
}
/**
* v1.9.0
* @param task
* @returns (20-100)100
*/
private getTaskAllocationPercent(task: any): number {
if (!task.resources || !Array.isArray(task.resources)) {
return 100 // 未配置resources时,默认100%
}
const allocation = task.resources.find((r: any) => r.id === this.id)
if (!allocation) {
return 100 // 未找到当前资源的分配信息,默认100%
}
const percent = allocation.percent ?? 100
return Math.max(20, Math.min(100, percent)) // 限制范围 20-100
}
}
+12
View File
@@ -1,3 +1,13 @@
/**
* (Resource Allocation)
* @version 1.9.0
*/
export interface ResourceAllocation {
id: string | number // 资源ID
name: string // 资源名称
percent?: number // 投入精力占比 (20-100),默认100
}
// Task 类型定义
export interface Task {
id: number
@@ -31,6 +41,8 @@ export interface Task {
isEditable?: boolean // 是否可编辑(可拖拽、拉伸),默认为true
// 自定义样式
barColor?: string // 自定义TaskBar颜色,如 '#ff5733',若不设置则使用默认颜色方案
// v1.9.0 资源占用比例
resources?: ResourceAllocation[] // 资源分配列表,包含占比信息
// 支持自定义属性 - 使用 unknown 允许任意类型
[key: string]: unknown
}
+176
View File
@@ -0,0 +1,176 @@
import type { Task } from '../models/classes/Task'
/**
* v1.9.0
*
*/
/**
*
*/
export function hasTimeOverlap(task1: Task, task2: Task): boolean {
// 获取任务的开始和结束日期
const start1 = task1.startDate ? new Date(task1.startDate).getTime() : null
const end1 = task1.endDate ? new Date(task1.endDate).getTime() : null
const start2 = task2.startDate ? new Date(task2.startDate).getTime() : null
const end2 = task2.endDate ? new Date(task2.endDate).getTime() : null
// 如果任一任务缺少日期信息,视为无交集
if (!start1 || !end1 || !start2 || !end2) {
return false
}
// 检测时间交集:task1的结束时间 > task2的开始时间 && task1的开始时间 < task2的结束时间
return end1 > start2 && start1 < end2
}
/**
*
*/
function getTaskResourcePercent(task: Task, resourceId?: string | number): number {
if (!resourceId || !task.resources || task.resources.length === 0) {
return 100
}
const allocation = task.resources.find((r: any) => r.id === resourceId)
if (allocation && allocation.percent !== undefined) {
return Math.max(20, Math.min(100, allocation.percent))
}
return 100
}
/**
*
* 使
* @param tasks
* @param resourceId ID
* @param baseRowHeight 51
* @returns { taskRowMap, rowHeights, totalHeight }
*/
export function assignTaskRows(
tasks: Task[],
resourceId?: string | number,
baseRowHeight: number = 51
): {
taskRowMap: Map<string | number, number>
rowHeights: number[]
totalHeight: number
} {
const taskRowMap = new Map<string | number, number>()
const rowHeights: number[] = []
if (!tasks || tasks.length === 0) {
return { taskRowMap, rowHeights, totalHeight: baseRowHeight }
}
// 按开始时间排序任务
const sortedTasks = [...tasks].sort((a, b) => {
const timeA = a.startDate ? new Date(a.startDate).getTime() : 0
const timeB = b.startDate ? new Date(b.startDate).getTime() : 0
return timeA - timeB
})
// 记录每一行的所有任务
const rowTasks: Task[][] = []
for (const task of sortedTasks) {
// 查找第一个不冲突的行
let assignedRow = -1
for (let rowIndex = 0; rowIndex < rowTasks.length; rowIndex++) {
const tasksInRow = rowTasks[rowIndex]
// 检查当前任务是否与该行的所有任务都不冲突
let hasConflict = false
for (const taskInRow of tasksInRow) {
if (hasTimeOverlap(task, taskInRow)) {
hasConflict = true
break
}
}
// 如果与该行所有任务都不冲突,可以放在这一行
if (!hasConflict) {
assignedRow = rowIndex
rowTasks[rowIndex].push(task)
break
}
}
// 如果没有找到合适的行,创建新行
if (assignedRow === -1) {
assignedRow = rowTasks.length
rowTasks.push([task])
}
taskRowMap.set(task.id, assignedRow)
}
// 计算每行的高度
const baseTaskBarHeight = baseRowHeight - 10 // 基础TaskBar高度
for (let i = 0; i < rowTasks.length; i++) {
const tasksInRow = rowTasks[i]
// 找到该行中最大的TaskBar高度
let maxTaskBarHeight = 0
for (const task of tasksInRow) {
const percent = getTaskResourcePercent(task, resourceId)
const taskBarHeight = baseTaskBarHeight * (percent / 100)
maxTaskBarHeight = Math.max(maxTaskBarHeight, taskBarHeight)
}
// 计算该行的容器高度
// 第一行:padding-top(5px) + maxTaskBarHeight + padding-bottom(5px)
// 后续行:maxTaskBarHeight + padding-bottom(5px)
const rowHeight = i === 0
? 5 + maxTaskBarHeight + 5
: maxTaskBarHeight + 5
rowHeights.push(rowHeight)
}
// 计算总高度
const totalHeight = rowHeights.reduce((sum, h) => sum + h, 0)
return { taskRowMap, rowHeights, totalHeight }
}
/**
*
*/
export function calculateMaxRows(tasks: Task[], resourceId?: string | number): number {
const result = assignTaskRows(tasks, resourceId)
if (result.taskRowMap.size === 0) {
return 1
}
return Math.max(...Array.from(result.taskRowMap.values())) + 1
}
/**
*
* @returns ID到其任务行映射的映射 { resourceId: { taskRowMap, rowHeights, totalHeight } }
*/
export function calculateResourceTaskLayout(
tasks: Task[],
currentResourceId?: string | number,
baseRowHeight: number = 51
): Map<string | number, { taskRowMap: Map<string | number, number>, rowHeights: number[], totalHeight: number }> {
const resourceLayoutMap = new Map<string | number, { taskRowMap: Map<string | number, number>, rowHeights: number[], totalHeight: number }>()
if (!currentResourceId) {
return resourceLayoutMap
}
// 筛选属于当前资源的任务
const resourceTasks = tasks.filter(task => {
if (!task.resources || task.resources.length === 0) {
return false
}
return task.resources.some(r => r.id === currentResourceId)
})
// 为当前资源的任务分配行
const result = assignTaskRows(resourceTasks, currentResourceId, baseRowHeight)
resourceLayoutMap.set(currentResourceId, result)
return resourceLayoutMap
}