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
+61
View File
@@ -13,10 +13,12 @@ import jsPDF from 'jspdf'
import html2canvas from 'html2canvas'
import type { Task } from '../models/classes/Task'
import type { Milestone } from '../models/classes/Milestone'
import type { Resource } from '../models/classes/Resource'
import { useTaskListContextMenu } from './TaskList/composables/taskList/useTaskListContextMenu'
import { useTaskBarContextMenu } from './Timeline/composables/useTaskBarContextMenu'
import type { ToolbarConfig } from '../models/configs/ToolbarConfig'
import type { TaskListConfig } from '../models/configs/TaskListConfig'
import type { ResourceListConfig } from '../models/configs/ResourceListConfig'
import {
DEFAULT_TASK_LIST_WIDTH,
DEFAULT_TASK_LIST_MIN_WIDTH,
@@ -30,6 +32,8 @@ import { useMessage } from '../composables/useMessage'
const props = withDefaults(defineProps<Props>(), {
tasks: () => [],
milestones: () => [],
resources: () => [],
viewMode: 'task',
useDefaultDrawer: true,
useDefaultMilestoneDialog: true,
toolbarConfig: () => ({}),
@@ -48,6 +52,7 @@ const props = withDefaults(defineProps<Props>(), {
afternoon: { start: 13, end: 17 },
}),
taskListConfig: undefined,
resourceListConfig: undefined,
taskListColumnRenderMode: 'default',
taskBarConfig: undefined,
autoSortByStartDate: false,
@@ -96,11 +101,34 @@ const emit = defineEmits([
'add-successor',
// TaskRow拖拽事件
'task-row-moved',
// v1.9.0 资源视图事件
'view-mode-change', // 视图模式切换事件
'resource-click', // 资源行点击事件
'taskbar-resource-change', // 任务跨资源移动事件
'resource-drag-end', // v1.9.0 资源视图垂直拖拽结束事件
])
const { showMessage } = useMessage()
const slots = useSlots()
// v1.9.0 视图模式状态管理
const currentViewMode = ref<'task' | 'resource'>(props.viewMode || 'task')
// 根据视图模式计算当前使用的数据源
const currentDataSource = computed(() => {
return currentViewMode.value === 'resource' ? props.resources : props.tasks
})
// 根据视图模式计算当前使用的列表配置
const currentListConfig = computed(() => {
return currentViewMode.value === 'resource' ? props.resourceListConfig : props.taskListConfig
})
// 提供视图模式和数据源给子组件
provide('gantt-view-mode', currentViewMode)
provide('gantt-data-source', currentDataSource)
provide('gantt-list-config', currentListConfig)
// 提供 slots 给子组件(TaskList 和 TaskRow
provide('gantt-column-slots', slots)
@@ -132,6 +160,10 @@ interface Props {
tasks?: Task[]
// 里程碑数据
milestones?: Task[]
// v1.9.0 资源数据(资源计划视图使用)
resources?: Resource[]
// v1.9.0 视图模式:'task' 任务计划视图 | 'resource' 资源计划视图
viewMode?: 'task' | 'resource'
// 是否使用默认的TaskDrawer
useDefaultDrawer?: boolean
// 是否使用默认的MilestoneDialog
@@ -170,6 +202,8 @@ interface Props {
}
// 任务列表配置
taskListConfig?: TaskListConfig
// v1.9.0 资源列表配置(资源计划视图使用)
resourceListConfig?: ResourceListConfig
// 任务列表列渲染模式:'default' 使用 taskListConfig.columns 配置,'declarative' 使用声明式 <task-list-column> 标签
taskListColumnRenderMode?: 'default' | 'declarative'
// TaskBar 配置
@@ -398,6 +432,24 @@ watch(
// 时间刻度状态
const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
// v1.9.0 视图模式切换处理函数
const handleViewModeChange = (newMode: 'task' | 'resource') => {
if (currentViewMode.value !== newMode) {
currentViewMode.value = newMode
emit('view-mode-change', newMode)
}
}
// v1.9.0 响应式监听viewMode prop变化
watch(
() => props.viewMode,
newMode => {
if (newMode && currentViewMode.value !== newMode) {
currentViewMode.value = newMode
}
},
)
// 计算是否显示关闭按钮
const showCloseButton = computed(() => {
const taskId = timelineRef.value?.highlightedTaskId
@@ -2658,6 +2710,11 @@ function handleLinkDeleted(event: {
emit('link-deleted', event)
}
// v1.9.0 资源视图垂直拖拽结束事件
function handleResourceDragEnd(event: { task: Task; sourceResourceIndex: number; targetResourceIndex: number; targetResource: Resource }) {
emit('resource-drag-end', event)
}
// 处理里程碑双击事件
function handleMilestoneDoubleClick(milestone: Milestone) {
// 先触发外部事件,让外部可以自定义处理
@@ -2742,6 +2799,7 @@ defineExpose({
:theme="currentThemeMode"
:fullscreen="isFullscreen"
:expand-all="getIsExpandAll()"
:view-mode="currentViewMode"
:on-today-locate="todayLocateHandler"
:on-export-csv="csvExportHandler"
:on-export-pdf="pdfExportHandler"
@@ -2751,10 +2809,12 @@ defineExpose({
:on-time-scale-change="handleTimeScaleChange"
:on-expand-all="handleExpandAll"
:on-collapse-all="handleCollapseAll"
:on-view-mode-change="handleViewModeChange"
@add-task="handleToolbarAddTask"
@add-milestone="milestoneAddHandler"
@expand-all="handleExpandAll"
@collapse-all="handleCollapseAll"
@view-mode-change="handleViewModeChange"
/>
<!-- 甘特图主体 -->
@@ -2848,6 +2908,7 @@ defineExpose({
@successor-added="handleSuccessorAdded"
@delete="handleTaskDelete"
@link-deleted="handleLinkDeleted"
@resource-drag-end="handleResourceDragEnd"
>
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
<slot name="custom-task-content" v-bind="barScope" />
+1 -1
View File
@@ -28,7 +28,7 @@ interface Props {
offsetTop?: number // Canvas 在垂直方向的偏移量(用于虚拟渲染)
highlightedTaskId: number | null
highlightedTaskIds: Set<number>
hoveredTaskId: number | null
hoveredTaskId: number | string | null // v1.9.0 支持资源视图中的字符串ID
// 月份分隔线配置
verticalLines?: VerticalLine[]
showVerticalLines?: boolean
+199
View File
@@ -14,6 +14,7 @@ const props = withDefaults(defineProps<Props>(), {
theme: undefined,
fullscreen: undefined,
expandAll: undefined,
viewMode: 'task', // v1.9.0
onAddTask: undefined,
onAddMilestone: undefined,
onTodayLocate: undefined,
@@ -26,6 +27,7 @@ const props = withDefaults(defineProps<Props>(), {
onTimeScaleChange: undefined,
onExpandAll: undefined,
onCollapseAll: undefined,
onViewModeChange: undefined, // v1.9.0
})
const emit = defineEmits<{
@@ -40,6 +42,7 @@ const emit = defineEmits<{
'time-scale-change': [scale: TimelineScale]
'expand-all': []
'collapse-all': []
'view-mode-change': [mode: 'task' | 'resource'] // v1.9.0
}>()
// LocalStorage keys
@@ -58,6 +61,7 @@ interface Props {
theme?: 'light' | 'dark'
fullscreen?: boolean
expandAll?: boolean
viewMode?: 'task' | 'resource' // v1.9.0
//
onAddTask?: () => void
onAddMilestone?: () => void
@@ -70,6 +74,7 @@ interface Props {
onTimeScaleChange?: (scale: TimelineScale) => void
onExpandAll?: () => void
onCollapseAll?: () => void
onViewModeChange?: (mode: 'task' | 'resource') => void // v1.9.0
//
onSettingsConfirm?: (
type: 'theme' | 'language',
@@ -97,6 +102,18 @@ const isDarkMode = ref(getInitialTheme())
const isFullscreen = ref(false)
const showLanguageDropdown = ref(false)
const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
const currentViewMode = ref<'task' | 'resource'>('task') // v1.9.0
// v1.9.0 viewMode prop
watch(
() => props.viewMode,
(newMode) => {
if (newMode && newMode !== currentViewMode.value) {
currentViewMode.value = newMode
}
},
{ immediate: true },
)
// Prop timeScale
watch(
@@ -210,6 +227,18 @@ const handleCollapseAll = () => {
}
}
// v1.9.0
const handleViewModeChange = (mode: 'task' | 'resource') => {
if (currentViewMode.value !== mode) {
currentViewMode.value = mode
if (props.onViewModeChange && typeof props.onViewModeChange === 'function') {
props.onViewModeChange(mode)
} else {
emit('view-mode-change', mode)
}
}
}
//
const toggleLanguageDropdown = () => {
showLanguageDropdown.value = !showLanguageDropdown.value
@@ -531,6 +560,58 @@ onUnmounted(() => {
</button>
</div>
<!-- v1.9.0 视图模式切换按钮组 - 使用 Segmented Control 样式 -->
<div class="gantt-view-mode-control">
<div class="view-mode-track">
<div
class="view-mode-thumb"
:style="{
transform: `translateX(${currentViewMode === 'task' ? '0%' : '100%'})`
}"
></div>
</div>
<button
class="view-mode-item"
:class="{ active: currentViewMode === 'task' }"
:title="t('taskView') || '任务视图'"
@click="handleViewModeChange('task')"
>
<svg
class="gantt-btn-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="7" height="7" rx="1"></rect>
<rect x="14" y="3" width="7" height="7" rx="1"></rect>
<rect x="3" y="14" width="7" height="7" rx="1"></rect>
<rect x="14" y="14" width="7" height="7" rx="1"></rect>
</svg>
{{ t('taskView') || '任务视图' }}
</button>
<button
class="view-mode-item"
:class="{ active: currentViewMode === 'resource' }"
:title="t('resourceView') || '资源视图'"
@click="handleViewModeChange('resource')"
>
<svg
class="gantt-btn-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
{{ t('resourceView') || '资源视图' }}
</button>
</div>
<!-- 展开/折叠按钮组 -->
<div
v-if="config.showExpandCollapse !== false"
@@ -1269,6 +1350,124 @@ onUnmounted(() => {
stroke-width: 2;
}
/* v1.9.0 视图模式切换 Segmented Control 样式 */
.gantt-view-mode-control {
position: relative;
display: inline-flex;
background: var(--gantt-bg-primary, #ffffff);
border: 1px solid var(--gantt-border-color, #dcdfe6);
border-radius: 6px;
padding: 1px;
margin-right: 12px;
overflow: hidden;
transition: border-color 0.2s ease;
height: 36px;
}
.gantt-view-mode-control:hover {
border-color: var(--gantt-primary-light, #79bbff);
}
.view-mode-track {
position: absolute;
top: 1px;
left: 1px;
right: 1px;
bottom: 1px;
pointer-events: none;
}
.view-mode-thumb {
position: absolute;
top: 0;
left: 0;
width: 50%;
height: 100%;
background: var(--gantt-primary, #409eff);
border-radius: 5px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.1),
0 1px 6px -1px rgba(0, 0, 0, 0.1);
}
.view-mode-item {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1;
height: 34px;
padding: 0 12px;
border: none;
background: transparent;
font-size: 14px;
font-weight: 500;
cursor: pointer;
outline: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
min-width: 100px;
z-index: 1;
border-radius: 5px;
user-select: none;
color: var(--gantt-text-primary, #303133);
}
.view-mode-item:hover:not(.active) {
color: var(--gantt-primary, #409eff);
background: var(--gantt-bg-hover, rgba(64, 158, 255, 0.06));
}
.view-mode-item:active:not(.active) {
background: var(--gantt-bg-active, rgba(64, 158, 255, 0.12));
}
.view-mode-item.active {
color: #ffffff;
font-weight: 600;
}
.view-mode-item .gantt-btn-icon {
width: 16px;
height: 16px;
margin-right: 6px;
stroke-width: 2;
}
/* 暗黑模式下的视图模式切换样式 */
:global(html[data-theme='dark']) .gantt-view-mode-control {
background: var(--gantt-bg-secondary, #4b4b4b);
border-color: var(--gantt-border-color, #808080);
}
:global(html[data-theme='dark']) .gantt-view-mode-control:hover {
border-color: var(--gantt-primary, #3399ff);
}
:global(html[data-theme='dark']) .view-mode-thumb {
background: var(--gantt-primary, #3399ff);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.3),
0 1px 6px -1px rgba(0, 0, 0, 0.3);
}
:global(html[data-theme='dark']) .view-mode-item {
color: #ffffff !important;
}
:global(html[data-theme='dark']) .view-mode-item:hover:not(.active) {
color: var(--gantt-primary, #3399ff);
background: rgba(51, 153, 255, 0.12);
}
:global(html[data-theme='dark']) .view-mode-item:active:not(.active) {
background: rgba(51, 153, 255, 0.2);
}
:global(html[data-theme='dark']) .view-mode-item.active {
color: #ffffff;
}
/* 暗黑模式下的按钮组样式 */
:global(html[data-theme='dark']) .gantt-btn-group {
box-shadow:
+9 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
import { computed, ref, onUnmounted, inject } from 'vue'
interface Props {
//
@@ -26,6 +26,9 @@ const props = withDefaults(defineProps<Props>(), {
globalDragging: false,
})
// LinkAnchor
const viewMode = inject<any>('gantt-view-mode', { value: 'task' })
const emit = defineEmits<{
'drag-start': [{ taskId: number; type: 'predecessor' | 'successor'; x: number; y: number }]
'drag-move': [{ x: number; y: number }]
@@ -37,6 +40,11 @@ const isHoveredAnchor = ref(false)
//
const shouldShow = computed(() => {
// LinkAnchor
if (viewMode.value === 'resource') {
return false
}
//
// 1. TaskBar
// 2. 线
+409 -39
View File
@@ -10,9 +10,17 @@ import { useI18n } from '../composables/useI18n'
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
import { DEFAULT_TASK_BAR_CONFIG } from '../models/configs/TaskBarConfig'
// attributeswrapper
defineOptions({
inheritAttrs: false
})
// GanttChart enableLinkAnchor
const enableLinkAnchor = inject<ComputedRef<boolean>>('enable-link-anchor', computed(() => true))
// v1.9.0
const viewMode = inject<any>('gantt-view-mode', { value: 'task' })
interface Props {
task: Task
rowHeight: number
@@ -59,6 +67,8 @@ interface Props {
isInvalidLinkTarget?: boolean
//
allTasks?: Task[]
// v1.9.0
hasResourceConflict?: boolean
}
interface TaskStatus {
@@ -258,12 +268,20 @@ const isResizingLeft = ref(false)
const isResizingRight = ref(false)
const justFinishedDragOrResize = ref(false) //
const dragStartX = ref(0)
const dragStartY = ref(0) // v1.9.0
const dragStartLeft = ref(0)
const dragStartWidth = ref(0)
const resizeStartX = ref(0)
const resizeStartWidth = ref(0)
const resizeStartLeft = ref(0)
// v1.9.0
const dragPreviewVisible = ref(false)
const dragPreviewPosition = ref({ x: 0, y: 0 })
const dragPreviewOffsetX = ref(0) // TaskBarX
const dragEndX = ref(0) // X
const tempTaskPixelLeft = ref<number | null>(null) //
//
const longPressTimer = ref<number | null>(null)
const longPressTriggered = ref(false)
@@ -303,11 +321,13 @@ const nameTextWidth = ref(0)
const taskBarStyle = computed(() => {
// 使
if (quarterDragOverride.value && props.currentTimeScale === TimelineScale.QUARTER) {
const taskBarHeight = props.rowHeight - 10
const topOffset = (props.rowHeight - taskBarHeight) / 2
return {
left: `${quarterDragOverride.value.left ?? 0}px`,
width: `${quarterDragOverride.value.width ?? 100}px`,
height: `${props.rowHeight - 10}px`,
top: '4px',
height: `${taskBarHeight}px`,
top: `${topOffset}px`,
}
}
@@ -320,11 +340,13 @@ const taskBarStyle = computed(() => {
// startDateendDate0shouldRenderTaskBar
if (!startDate && !endDate) {
const taskBarHeight = props.rowHeight - 10
const topOffset = (props.rowHeight - taskBarHeight) / 2
return {
left: '0px',
width: '0px',
height: `${props.rowHeight - 10}px`,
top: '4px',
height: `${taskBarHeight}px`,
top: `${topOffset}px`,
}
}
@@ -337,19 +359,37 @@ const taskBarStyle = computed(() => {
// renderStartDaterenderEndDate
// baseStart
if (!renderStartDate || !renderEndDate || !renderBaseStart) {
const taskBarHeight = props.rowHeight - 10
const topOffset = (props.rowHeight - taskBarHeight) / 2
return {
left: '0px',
width: '0px',
height: `${props.rowHeight - 10}px`,
top: '4px',
height: `${taskBarHeight}px`,
top: `${topOffset}px`,
}
}
let left = 0
let width = 0
//
if (props.currentTimeScale === TimelineScale.HOUR) {
// v1.9.0 使
if (viewMode.value === 'resource' && tempTaskPixelLeft.value !== null) {
left = tempTaskPixelLeft.value
//
const startDate = createLocalDate(currentStartDate)
const endDate = createLocalDate(currentEndDate)
if (startDate && endDate) {
const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
const endDateOnly = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
const timeDiffMs = endDateOnly.getTime() - startDateOnly.getTime()
const daysDiff = Math.round(timeDiffMs / (1000 * 60 * 60 * 24))
const duration = daysDiff === 0 ? 1 : daysDiff + 1
width = duration * props.dayWidth
} else {
width = props.dayWidth //
}
} else if (props.currentTimeScale === TimelineScale.HOUR) {
//
// baseStart 00:00:00
const baseStartOfDay = new Date(renderBaseStart)
baseStartOfDay.setHours(0, 0, 0, 0)
@@ -500,11 +540,15 @@ const taskBarStyle = computed(() => {
}
}
const taskBarHeight = props.rowHeight - 10
// v1.9.0 使topresource-row
const topOffset = viewMode.value === 'resource' ? 5 : (props.rowHeight - taskBarHeight) / 2
return {
left: `${left}px`,
width: `${width}px`,
height: `${props.rowHeight - 10}px`,
top: '4px',
height: `${taskBarHeight}px`,
top: `${topOffset}px`,
}
})
@@ -923,9 +967,12 @@ const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-r
// TaskBar
mouseOffsetX.value = e.clientX - barRect.left
// v1.9.0 TaskBar
dragPreviewOffsetX.value = e.clientX - barRect.left
//
dragStartX.value = e.clientX
dragStartY.value = e.clientY // v1.9.0 Y
dragStartLeft.value = parseInt(taskBarStyle.value.left)
dragStartWidth.value = parseInt(taskBarStyle.value.width)
@@ -1017,6 +1064,34 @@ const dragTooltipPosition = ref({ x: 0, y: 0 })
const dragTooltipContent = ref({ startDate: '', endDate: '' })
const handleMouseMove = (e: MouseEvent) => {
// Y
if (viewMode.value === 'resource') {
;(window as any).lastDragMouseY = e.clientY
// v1.9.0
const timelineBody = document.querySelector('.timeline-body')
let isCrossRowDrag = false
if (timelineBody && isDragging.value && isDragThresholdMet.value) {
const bodyRect = timelineBody.getBoundingClientRect()
const relativeY = e.clientY - bodyRect.top + (timelineBody as HTMLElement).scrollTop
const currentRowIndex = Math.floor(relativeY / 51)
isCrossRowDrag = currentRowIndex !== props.rowIndex
//
if (isCrossRowDrag) {
dragPreviewVisible.value = true
//
dragPreviewPosition.value = {
x: e.clientX - dragPreviewOffsetX.value,
y: e.clientY - (props.rowHeight / 2)
}
} else {
dragPreviewVisible.value = false
}
}
}
// /
if (props.isHighlighted || props.isPrimaryHighlight) {
return
@@ -1030,9 +1105,15 @@ const handleMouseMove = (e: MouseEvent) => {
//
if (!isDragThresholdMet.value) {
const deltaX = Math.abs(e.clientX - dragStartX.value)
const deltaY = Math.abs(e.clientY - dragStartY.value)
// v1.9.0 Y
const threshold = viewMode.value === 'resource' && dragType.value === 'drag'
? Math.max(deltaX, deltaY) // XY
: deltaX // X
//
if (deltaX < dragThreshold.value) {
if (threshold < dragThreshold.value) {
return
}
@@ -1057,7 +1138,11 @@ const handleMouseMove = (e: MouseEvent) => {
new CustomEvent('drag-boundary-check', {
detail: {
mouseX: e.clientX,
mouseY: e.clientY, // v1.9.0 Y
taskId: props.task.id, // v1.9.0 taskId
rowIndex: props.rowIndex, // v1.9.0
isDragging: isDragging.value || isResizingLeft.value || isResizingRight.value,
isResourceView: viewMode.value === 'resource', // v1.9.0
},
}),
)
@@ -1074,6 +1159,69 @@ const handleMouseMove = (e: MouseEvent) => {
if (isDragging.value) {
const deltaX = e.clientX - dragStartX.value
// v1.9.0 使
if (viewMode.value === 'resource') {
// TaskBar -
const taskBarNewLeft = e.clientX - dragPreviewOffsetX.value
// timeline body
const timelineBody = document.querySelector('.timeline-body')
if (timelineBody) {
const bodyRect = timelineBody.getBoundingClientRect()
const scrollLeft = (timelineBody as HTMLElement).scrollLeft
// timeline body
const relativeX = taskBarNewLeft - bodyRect.left + scrollLeft
//
tempTaskPixelLeft.value = relativeX
// 使
let newStartDate: Date | null = null
if (props.timelineData &&
(props.currentTimeScale === TimelineScale.DAY ||
props.currentTimeScale === TimelineScale.MONTH ||
props.currentTimeScale === TimelineScale.QUARTER ||
props.currentTimeScale === TimelineScale.YEAR)) {
// timelineData使
newStartDate = calculateDateFromPosition(
relativeX,
props.timelineData,
props.currentTimeScale,
)
}
if (!newStartDate) {
// timelineData使
newStartDate = addDaysToLocalDate(props.startDate, relativeX / props.dayWidth)
}
//
const originalStartDate = createLocalDate(props.task.startDate) || props.startDate
const originalEndDate = createLocalDate(props.task.endDate) || props.startDate
const durationMs = originalEndDate.getTime() - originalStartDate.getTime()
const duration = Math.ceil(durationMs / (1000 * 60 * 60 * 24))
//
const newEndDate = new Date(newStartDate)
newEndDate.setDate(newEndDate.getDate() + Math.max(0, duration))
//
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
// v1.9.0 tempTaskDataTaskBar
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
}
//
return
}
if (props.currentTimeScale === TimelineScale.HOUR) {
// 15
const pixelPerMinute = 40 / 60 //
@@ -1475,8 +1623,49 @@ const handleMouseUp = () => {
}),
)
// v1.9.0
let targetResourceRowIndex: number | undefined
let isCrossRowDrag = false
if (viewMode.value === 'resource' && isDragging.value && isDragThresholdMet.value) {
// 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
}
// drop
if (isCrossRowDrag) {
// Timeline
window.dispatchEvent(
new CustomEvent('resource-taskbar-drop', {
detail: {
taskId: props.task.id,
task: props.task,
sourceRowIndex: props.rowIndex,
mouseY: (window as any).lastDragMouseY || 0,
// v1.9.0 TaskBarTimeline
calculatedStartDate: tempTaskData.value?.startDate,
calculatedEndDate: tempTaskData.value?.endDate,
},
}),
)
}
//
dragPreviewVisible.value = false
}
//
if (isDragThresholdMet.value && tempTaskData.value) {
// v1.9.0
if (isDragThresholdMet.value && tempTaskData.value && !(viewMode.value === 'resource' && isCrossRowDrag)) {
const updatedTask = {
...props.task,
...tempTaskData.value,
@@ -1518,6 +1707,7 @@ const handleMouseUp = () => {
isDragThresholdMet.value = false
isDelayPassed.value = false
dragType.value = null
tempTaskPixelLeft.value = null // v1.9.0
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
@@ -1541,6 +1731,17 @@ onMounted(() => {
}
})
// v1.9.0
const handleResourceDragCancel = (event: Event) => {
const customEvent = event as CustomEvent
if (customEvent.detail.taskId === props.task.id) {
// TaskBar
tempTaskData.value = null
tempTaskPixelLeft.value = null
}
}
window.addEventListener('resource-drag-cancel', handleResourceDragCancel as EventListener)
//
const handleTimelineScaleUpdate = () => {
nextTick(() => {
@@ -1576,6 +1777,7 @@ onMounted(() => {
window.removeEventListener('timeline-scale-updated', handleTimelineScaleUpdate)
window.removeEventListener('timeline-force-recalculate', handleForceRecalculate)
window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
window.removeEventListener('resource-drag-cancel', handleResourceDragCancel as EventListener)
document.removeEventListener('click', handleDocumentClick)
//
@@ -1959,6 +2161,7 @@ const tooltipPosition = ref({ x: 0, y: 0 })
// TaskBar tooltip
const showHoverTooltip = ref(false)
const hoverTooltipPosition = ref({ x: 0, y: 0 })
const isTooltipBelow = ref(false) // v1.9.0 tooltipTaskBar
let hoverTooltipTimer: number | null = null
//
@@ -2158,18 +2361,53 @@ const handleTaskBarMouseEnter = (event: MouseEvent) => {
isTaskBarHovered.value = true
// TaskBar Tooltiptooltip
if (props.enableTaskBarTooltip !== false) {
// tooltip
if (props.enableTaskBarTooltip !== false && !isDragging.value && !isResizingLeft.value && !isResizingRight.value) {
// event.currentTargetsetTimeoutnull
const targetElement = event.currentTarget as HTMLElement
//
const mouseX = event.clientX
const mouseY = event.clientY
// tooltip
hoverTooltipTimer = window.setTimeout(() => {
showHoverTooltip.value = true
const rect = targetElement.getBoundingClientRect()
hoverTooltipPosition.value = {
x: rect.left + rect.width / 2,
y: rect.top - 10,
// tooltipCSS
const tooltipWidth = 250 //
const tooltipHeight = 120 //
const margin = 10 //
//
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// v1.9.0 TaskBar
// TaskBar
let x = rect.left + rect.width / 2
// TaskBarCSS transform: translateY(-100%)tooltip
let y = rect.top - 10
//
if (x - tooltipWidth / 2 < margin) {
x = margin + tooltipWidth / 2
}
//
if (x + tooltipWidth / 2 > viewportWidth - margin) {
x = viewportWidth - margin - tooltipWidth / 2
}
//
if (rect.top - 10 - tooltipHeight < margin) {
// TaskBar
y = rect.bottom + 10
isTooltipBelow.value = true
} else {
isTooltipBelow.value = false
}
hoverTooltipPosition.value = { x, y }
}, 300) // 300ms
}
}
@@ -2185,6 +2423,17 @@ const handleTaskBarMouseLeave = () => {
showHoverTooltip.value = false
}
// //tooltip
watch([isDragging, isResizingLeft, isResizingRight], ([dragging, resizingL, resizingR]) => {
if (dragging || resizingL || resizingR) {
showHoverTooltip.value = false
if (hoverTooltipTimer) {
clearTimeout(hoverTooltipTimer)
hoverTooltipTimer = null
}
}
})
//
const formatDisplayDate = (dateStr: string | undefined): string => {
if (!dateStr) return t('dateNotSet')
@@ -2674,23 +2923,25 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
</script>
<template>
<!-- 实际进度条独立渲染在下层 -->
<div
v-if="actualBarStyle && shouldRenderTaskBar && !isParent"
class="actual-bar"
:data-task-id="`actual-${task.id}`"
:class="{
'highlighted': isHighlighted,
'primary-highlight': isPrimaryHighlight,
'dimmed': isDimmed,
}"
:style="{
...actualBarStyle,
backgroundColor: taskStatus.color,
filter: 'brightness(1.15) saturate(0.9)', /* 加白并降低饱和度,与计划TaskBar色系一致 */
boxShadow: `0 6px 20px ${taskStatus.color}60, 0 3px 10px ${taskStatus.color}40`, /* 使用TaskBar颜色的阴影,移除白边 */
}"
>
<!-- 根容器包裹所有非Teleport的元素接收传递的style属性 -->
<div class="task-bar-wrapper" v-bind="$attrs">
<!-- 实际进度条独立渲染在下层 -->
<div
v-if="actualBarStyle && shouldRenderTaskBar && !isParent"
class="actual-bar"
:data-task-id="`actual-${task.id}`"
:class="{
'highlighted': isHighlighted,
'primary-highlight': isPrimaryHighlight,
'dimmed': isDimmed,
}"
:style="{
...actualBarStyle,
backgroundColor: taskStatus.color,
filter: 'brightness(1.15) saturate(0.9)', /* 加白并降低饱和度,与计划TaskBar色系一致 */
boxShadow: `0 6px 20px ${taskStatus.color}60, 0 3px 10px ${taskStatus.color}40`, /* 使用TaskBar颜色的阴影,移除白边 */
}"
>
<div class="actual-bar-content">
<span class="actual-progress">{{ task.progress || 0 }}%</span>
</div>
@@ -2769,6 +3020,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
'primary-highlight': isPrimaryHighlight,
dimmed: isDimmed,
'has-actual': showActualTaskbar && hasActualProgress, /* 只有在showActualTaskbar=true时才标记有实际进度 */
'resource-conflict': props.hasResourceConflict, /* v1.9.0 资源冲突样式 */
}"
@click="handleTaskBarClick"
@contextmenu="handleContextMenu"
@@ -2866,7 +3118,12 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
</div>
<!-- 任务名称 - 有实际TaskBar时隐藏 -->
<div v-if="barConfig.showTitle && !(showActualTaskbar && hasActualProgress)" ref="taskBarNameRef" :style="getNameStyles()">
<div
v-if="barConfig.showTitle && !(showActualTaskbar && hasActualProgress)"
ref="taskBarNameRef"
:style="viewMode.value === 'resource' ? {} : getNameStyles()"
:class="{ 'task-name-wrapper': viewMode.value === 'resource' }"
>
<slot v-if="hasContentSlot" name="custom-task-content" v-bind="slotPayload" />
<div v-else class="task-name">
{{ task.name }}
@@ -2877,7 +3134,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
<div
v-if="barConfig.showProgress && shouldShowProgress && !(showActualTaskbar && hasActualProgress)"
class="task-progress"
:style="getProgressStyles()"
:style="viewMode.value === 'resource' ? {} : getProgressStyles()"
>
{{ task.progress || 0 }}%
</div>
@@ -2983,6 +3240,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
</div>
</Teleport>
</div>
</div><!-- 关闭task-bar-wrapper -->
<!-- Tooltip 弹窗 -->
<Teleport to="body">
@@ -3044,18 +3302,40 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
</div>
</Teleport>
<!-- v1.9.0 拖拽预览效果资源视图垂直拖拽 -->
<Teleport to="body">
<div
v-if="dragPreviewVisible"
class="drag-preview"
:style="{
left: `${dragPreviewPosition.x}px`,
top: `${dragPreviewPosition.y}px`,
width: taskBarStyle.width,
height: taskBarStyle.height,
backgroundColor: taskStatus.color,
borderColor: taskStatus.borderColor,
}"
>
<div class="drag-preview-content">{{ task.name }}</div>
</div>
</Teleport>
<!-- TaskBar悬停提示框 -->
<Teleport to="body">
<div
v-if="showHoverTooltip"
class="task-hover-tooltip"
:class="{ 'tooltip-below': isTooltipBelow }"
:style="{
left: `${hoverTooltipPosition.x}px`,
top: `${hoverTooltipPosition.y}px`,
backgroundColor: taskStatus.color,
}"
>
<div class="hover-tooltip-arrow" :style="{ borderTopColor: taskStatus.color }"></div>
<div class="hover-tooltip-arrow" :style="{
borderTopColor: isTooltipBelow ? 'transparent' : taskStatus.color,
borderBottomColor: isTooltipBelow ? taskStatus.color : 'transparent'
}"></div>
<div class="hover-tooltip-content">
<div class="hover-tooltip-title">{{ task.name }}</div>
<div class="hover-tooltip-row">
@@ -3080,6 +3360,18 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
</template>
<style scoped>
/* 根容器:透明容器,仅用于接收传递的style属性 */
.task-bar-wrapper {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none; /* 让所有事件穿透到子元素 */
}
.task-bar-wrapper > * {
pointer-events: auto; /* 恢复子元素的事件响应 */
}
.task-bar {
position: absolute;
border-radius: 4px;
@@ -3088,7 +3380,8 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
transition:
box-shadow 0.2s,
transform 0.3s,
filter 0.3s;
filter 0.3s,
z-index 0s; /* v1.9.0 z-index不使用动画 */
z-index: 100;
border: 2px solid;
/* 添加半透明黑色边框增强对比度 */
@@ -3096,6 +3389,11 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
overflow: visible; /* 允许内容超出 TaskBar */
}
/* v1.9.0 悬停时提升z-index,确保资源视图中重叠的TaskBar可以正常交互 */
.task-bar:hover {
z-index: 160 !important;
}
/* 有实际进度时,计划条使用虚线边框样式 */
.task-bar.has-actual {
/* 不再强制设置半透明背景,由内联样式的isTaskBarHovered控制 */
@@ -3138,6 +3436,30 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
cursor: pointer;
}
/* v1.9.0 资源冲突样式(根据UI规范)*/
.task-bar.resource-conflict {
/* 左侧3px红色色带 */
border-left: 3px solid var(--gantt-error-color, #f56c6c) !important;
/* 浅红底色 */
background-color: rgba(245, 108, 108, 0.08) !important;
/* 斑马纹背景(叠加在底色上)*/
background-image:
repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
rgba(245, 108, 108, 0.12) 10px,
rgba(245, 108, 108, 0.12) 20px
) !important;
}
/* 冲突状态的文字保持清晰可读 */
.task-bar.resource-conflict .task-name,
.task-bar.resource-conflict .task-progress {
color: var(--gantt-text-primary);
font-weight: 600;
}
.task-bar.dragging {
opacity: 0.8;
z-index: 1000;
@@ -3537,6 +3859,13 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
padding-left: 8px;
}
/* 资源视图中的标题包裹器,禁用绝对定位 */
.task-name-wrapper {
position: relative;
width: 100%;
text-align: center;
}
.task-name {
white-space: nowrap;
overflow: visible;
@@ -3552,6 +3881,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
font-size: 11px;
font-weight: 700; /* 加粗显示 */
z-index: 10;
line-height: 1.2;
/* 移除背景样式,保持原始状态 */
}
@@ -3876,6 +4206,31 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
backdrop-filter: blur(2px);
}
/* v1.9.0 拖拽预览效果 */
.drag-preview {
position: fixed;
opacity: 0.5;
border-radius: 6px;
border: 2px dashed rgba(255, 255, 255, 0.8);
z-index: 999999998;
pointer-events: none;
/* v1.9.0 不使用transform居中,直接定位保持时间对齐 */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
padding: 0 8px;
}
.drag-preview-content {
color: white;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.drag-tooltip .tooltip-row {
display: flex;
justify-content: space-between;
@@ -3911,20 +4266,35 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
z-index: 999999999;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
pointer-events: none;
transform: translate(-50%, -100%);
transform: translate(-50%, -100%); /* 默认显示在上方 */
margin-top: -8px;
}
/* 显示在下方时的样式 */
.task-hover-tooltip.tooltip-below {
transform: translate(-50%, 0); /* 显示在下方 */
margin-top: 0;
}
.hover-tooltip-arrow {
position: absolute;
left: 50%;
bottom: -5px;
bottom: -5px; /* 默认箭头在底部,指向下方 */
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid rgba(0, 0, 0, 0.85);
border-bottom: 0;
}
/* 显示在下方时,箭头在顶部,指向上方 */
.tooltip-below .hover-tooltip-arrow {
bottom: auto;
top: -5px;
border-top: 0;
border-bottom: 6px solid rgba(0, 0, 0, 0.85);
}
.hover-tooltip-content {
+46 -8
View File
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, useSlots, computed, inject } from 'vue'
import type { StyleValue, Slots } from 'vue'
import type { StyleValue, Slots, Ref, ComputedRef } from 'vue'
import TaskRow from './taskRow/TaskRow.vue'
import { useI18n } from '../../composables/useI18n'
import type { Task } from '../../models/classes/Task'
import type { Resource } from '../../models/classes/Resource'
import type { TaskListConfig, TaskListColumnConfig } from '../../models/configs/TaskListConfig'
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'
import { useTaskRowDrag } from '../../composables/useTaskRowDrag'
import { useTaskListColumns } from './composables/taskList/useTaskListColumns'
import { useTaskListLayout } from './composables/taskList/useTaskListLayout'
@@ -43,23 +46,47 @@ const emit = defineEmits<{
newParent: Task | null
},
]
'resource-click': [resource: Resource] // v1.9.0
}>()
const slots = useSlots()
const hasRowSlot = computed(() => Boolean(slots['custom-task-content']))
// 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(() => []))
const listConfig = inject<ComputedRef<TaskListConfig | ResourceListConfig | undefined>>(
'gantt-list-config',
computed(() => undefined),
)
// GanttChart slots
const columnSlots = inject<Slots>('gantt-column-slots', {})
//
const { t } = useI18n()
// v1.9.0
const getDefaultColumns = computed(() => {
if (viewMode.value === 'resource') {
return DEFAULT_RESOURCE_LIST_COLUMNS as unknown as TaskListColumnConfig[]
}
return DEFAULT_TASK_LIST_COLUMNS
})
// v1.9.0 inject
const finalColumnsConfig = computed(() => {
// 使inject使props
const config = listConfig.value || props.taskListConfig
return config?.columns || getDefaultColumns.value
})
// 使 composable
const { declarativeColumns, getColumnWidthStyle: getDeclarativeColumnWidth } =
useTaskListColumns(
computed(() => props.taskListColumnRenderMode || 'default'),
slots,
props.taskListConfig?.columns || DEFAULT_TASK_LIST_COLUMNS,
finalColumnsConfig.value,
)
// 使
@@ -76,12 +103,18 @@ const visibleColumns = computed(() => {
if (props.taskListColumnRenderMode === 'declarative') {
return [] as TaskListColumnConfig[]
}
const columns = props.taskListConfig?.columns || DEFAULT_TASK_LIST_COLUMNS
return columns.filter(col => col.visible !== false)
return finalColumnsConfig.value.filter(col => col.visible !== false)
})
// 使 props.tasks
const localTasks = computed(() => props.tasks || [])
// v1.9.0 使props.tasks
const localTasks = computed(() => {
if (viewMode.value === 'resource') {
// 使dataSource
return (dataSource.value || []) as Task[]
}
// 使props.tasks
return (props.tasks || []) as Task[]
})
// 使 composable
const {
@@ -92,8 +125,8 @@ const {
endSpacerHeight,
} = useTaskListLayout(localTasks)
//
const hoveredTaskId = ref<number | null>(null)
// - v1.9.0 ID
const hoveredTaskId = ref<number | string | null>(null)
//
const isSplitterDragging = ref(false)
@@ -205,6 +238,11 @@ const handleTaskRowMoved = (payload: {
})
}
// v1.9.0
const handleResourceClick = (resource: Resource) => {
emit('resource-click', resource)
}
//
const { startDrag, handleDragOver } = useTaskRowDrag({
enabled: props.enableTaskRowMove ?? false,
@@ -9,7 +9,7 @@ import { updateParentTasksData, getAllTasks } from './useTaskParentCalculation'
export interface TaskListEventHandlersOptions {
tasks: Ref<Task[]>
hoveredTaskId: Ref<number | null>
hoveredTaskId: Ref<number | string | null>
isSplitterDragging: Ref<boolean>
taskListScrollTop: Ref<number>
taskListBodyRef: Ref<HTMLElement | null>
@@ -30,7 +30,7 @@ export function useTaskListEventHandlers(options: TaskListEventHandlersOptions)
// ==================== 悬停事件处理 ====================
const handleTaskRowHover = (taskId: number | null) => {
const handleTaskRowHover = (taskId: number | string | null) => {
if (isSplitterDragging.value) {
return
}
+79 -44
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, computed, useSlots, toRef, inject, type ComputedRef } from 'vue'
import { ref, computed, useSlots, toRef, inject, type ComputedRef, type Ref } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from '../../../composables/useI18n'
import { formatPredecessorDisplay } from '../../../utils/predecessorUtils'
import type { Task } from '../../../models/classes/Task'
import type { Resource } from '../../../models/classes/Resource'
import type { TaskListColumnConfig } from '../../../models/configs/TaskListConfig'
import type { DeclarativeColumnConfig } from '../composables/taskList/useTaskListColumns'
import TaskContextMenu from '../../TaskContextMenu.vue'
@@ -21,7 +22,7 @@ interface TaskRowSlotProps {
level: number
indent: string
isHovered: boolean
hoveredTaskId: number | null
hoveredTaskId: number | string | null
isParent: boolean
hasChildren: boolean
collapsed: boolean
@@ -42,8 +43,8 @@ interface Props {
level: number
rowIndex?: number
isHovered?: boolean
hoveredTaskId?: number | null
onHover?: (taskId: number | null) => void
hoveredTaskId?: number | string | null
onHover?: (taskId: number | string | null) => void
columns: TaskListColumnConfig[]
declarativeColumns?: DeclarativeColumnConfig[]
renderMode?: 'default' | 'declarative'
@@ -82,6 +83,14 @@ const daysText = computed(() => t.value?.days ?? '')
const slots = useSlots()
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'))
// v1.9.0 Detect if current row is a resource
const isResourceRow = computed(() => {
return viewMode.value === 'resource' && 'tasks' in props.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))
@@ -289,6 +298,7 @@ const assigneeDisplayData = computed(() => {
:data-task-id="props.task.id"
:class="{
'task-row-hovered': isHovered,
'task-type-story': viewMode === 'resource', /* v1.9.0 资源视图始终显示左边框 */
'parent-task': isParentTask,
'milestone-group-row': isMilestoneGroup,
'task-type-story': isStoryTask,
@@ -358,11 +368,21 @@ const assigneeDisplayData = computed(() => {
<!-- 叶子节点的占位空间无折叠按钮且无图标显示时 -->
<span
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false"
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false && !isResourceRow"
class="leaf-spacer"
></span>
<!-- v1.9.0 资源视图直接显示资源名称 -->
<div v-if="isResourceRow" class="resource-row-name">
<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>
<span class="resource-name-text">{{ (props.task as any).name || '-' }}</span>
</div>
<!-- 任务视图正常渲染 -->
<TaskRowNameContent
v-else
:task="props.task"
:is-parent-task="isParentTask"
:has-children="hasChildren"
@@ -417,7 +437,12 @@ const assigneeDisplayData = computed(() => {
{{ column.formatter(props.task, column) }}
</template>
<!-- 优先级3: 内置列类型渲染 -->
<!-- v1.9.0 资源视图使用formatter或直接显示资源属性 -->
<template v-else-if="isResourceRow">
{{ (props.task as any)[column.key] || '-' }}
</template>
<!-- 优先级3: 内置列类型渲染任务视图 -->
<!-- 前置任务列 -->
<template v-else-if="column.key === 'predecessor'">
{{ formatPredecessorDisplay(props.task.predecessor) }}
@@ -540,24 +565,20 @@ const assigneeDisplayData = computed(() => {
align-items: center;
color: var(--gantt-text-secondary);
cursor: pointer;
transition: all 0.3s ease;
transform: scale(1);
transform-origin: 5px center; /* 从左侧偏右5px的位置作为放大中心 */
transition: background-color 0.15s ease, box-shadow 0.15s ease;
z-index: 1;
position: relative;
}
.task-row:hover {
background-color: var(--gantt-bg-hover);
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15);
z-index: 10;
}
.task-row-hovered {
background-color: var(--gantt-bg-hover) !important;
transform: scale(1.02) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15) !important;
z-index: 10 !important;
}
@@ -568,15 +589,13 @@ const assigneeDisplayData = computed(() => {
.task-row.parent-task:hover {
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover));
transform: scale(1.02);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15);
z-index: 10;
}
.task-row.parent-task.task-row-hovered {
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover)) !important;
transform: scale(1.02) !important;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2) !important;
box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15) !important;
z-index: 10 !important;
}
@@ -586,12 +605,9 @@ const assigneeDisplayData = computed(() => {
background: linear-gradient(90deg, var(--gantt-bg-tertiary) 0%, var(--gantt-bg-primary) 100%);
}
.milestone-group-row:hover {
.mbox-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15);
ilestone-group-row:hover {
background: linear-gradient(90deg, var(--gantt-bg-hover-parent) 0%, var(--gantt-bg-hover) 100%);
transform: scale(1.02);
box-shadow:
0 6px 16px rgba(245, 108, 108, 0.3),
0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 10;
border-left-color: var(--gantt-danger, #f56c6c);
border-left-width: 4px; /* 悬停时边框稍微加粗 */
@@ -610,30 +626,30 @@ const assigneeDisplayData = computed(() => {
border-left: 3px solid var(--gantt-danger, #f56c6c);
}
/* 任务类型悬停时保持并增强左边框 */
/* 任务类型悬停时保持左边框,无需加粗 */
.task-type-story:hover {
border-left: 5px solid var(--gantt-primary, #409eff);
border-left: 3px solid var(--gantt-primary, #409eff);
}
.task-type-task:hover {
border-left: 5px solid var(--gantt-warning, #e6a23c);
border-left: 3px solid var(--gantt-warning, #e6a23c);
}
.task-type-milestone:hover {
border-left: 5px solid var(--gantt-danger, #f56c6c);
border-left: 3px solid var(--gantt-danger, #f56c6c);
}
/* 悬停状态下的左边框保持 */
.task-row-hovered.task-type-story {
border-left: 5px solid var(--gantt-primary, #409eff) !important;
border-left: 3px solid var(--gantt-primary, #409eff) !important;
}
.task-row-hovered.task-type-task {
border-left: 5px solid var(--gantt-warning, #e6a23c) !important;
border-left: 3px solid var(--gantt-warning, #e6a23c) !important;
}
.task-row-hovered.task-type-milestone {
border-left: 5px solid var(--gantt-danger, #f56c6c) !important;
border-left: 3px solid var(--gantt-danger, #f56c6c) !important;
}
:global(html[data-theme='dark']) .milestone-group-row {
@@ -787,35 +803,25 @@ const assigneeDisplayData = computed(() => {
/* 暗黑模式的悬停效果 */
:global(html[data-theme='dark']) .task-row:hover {
box-shadow:
0 4px 12px rgba(255, 255, 255, 0.1),
0 2px 8px rgba(0, 0, 0, 0.3);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
}
:global(html[data-theme='dark']) .task-row.task-row-hovered {
background-color: var(--gantt-bg-hover) !important;
box-shadow:
0 4px 12px rgba(255, 255, 255, 0.1),
0 2px 8px rgba(0, 0, 0, 0.3) !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15) !important;
}
:global(html[data-theme='dark']) .task-row.parent-task:hover {
box-shadow:
0 6px 16px rgba(255, 255, 255, 0.15),
0 2px 8px rgba(0, 0, 0, 0.4);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
}
:global(html[data-theme='dark']) .task-row.parent-task.task-row-hovered {
background: var(--gantt-bg-hover-parent) !important;
box-shadow:
0 6px 16px rgba(255, 255, 255, 0.15),
0 2px 8px rgba(0, 0, 0, 0.4) !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15) !important;
}
:global(html[data-theme='dark']) .milestone-group-row:hover {
box-shadow:
0 6px 16px rgba(246, 124, 124, 0.4),
0 2px 8px rgba(255, 255, 255, 0.1);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
}
/* TaskRow拖拽样式 */
@@ -835,6 +841,35 @@ const assigneeDisplayData = computed(() => {
background-color: rgba(64, 158, 255, 0.05) !important;
}
/* v1.9.0 资源视图行样式 */
.resource-row-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: var(--gantt-text-primary);
}
.resource-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.resource-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.resource-name-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:global(html[data-theme='dark']) .task-row-drop-target.drop-after {
background-color: rgba(125, 180, 240, 0.1) !important;
}
+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)
// ResizeObservertimeline-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)
})
// viewModedataSource线
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保持一致的悬停背景色 */
/* 降低层级,避免覆盖任务条等元素 */