Files
jordium-gantt-vue3/src/components/TaskBar.vue
2026-03-05 13:01:31 +08:00

4981 lines
161 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable @typescript-eslint/no-explicit-any */
<script setup lang="ts">
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'
import LinkAnchor from './LinkAnchor.vue'
import TaskBarTab from './Timeline/TaskBarTab.vue'
import { useI18n } from '../composables/useI18n'
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
import { DEFAULT_TASK_BAR_CONFIG } from '../models/configs/TaskBarConfig'
import type { PositionCache } from '../utils/positionCache' // v1.9.6 Phase1 位置计算缓存
// 禁用自动继承attributes手动应用到wrapper
defineOptions({
inheritAttrs: false,
})
const props = defineProps<Props>()
const emit = defineEmits([
'update:task',
'bar-mounted',
'click',
'dblclick',
'drag-end',
'resize-end',
'scroll-to-position',
'start-timer',
'stop-timer',
'add-predecessor',
'add-successor',
'delete',
'delete-link',
'context-menu',
'long-press',
'link-drag-start',
'link-drag-move',
'link-drag-end',
// Singleton TooltipTaskBar 只负责触发Timeline 统一渲染
'tooltip-show',
'tooltip-hide',
])
defineSlots<{
'custom-task-content'(props: TaskBarSlotProps): unknown
}>()
// 从 GanttChart 注入 enableLinkAnchor 配置
const enableLinkAnchor = inject<ComputedRef<boolean>>('enable-link-anchor', computed(() => true))
// v1.9.0 计算当前资源的利用率
const resourcePercent = computed(() => {
// 如果直接传递了占比,使用传递的值
if (props.resourceAllocationPercent !== undefined) {
const val = Number(props.resourceAllocationPercent)
if (Number.isFinite(val) && val > 0) {
return Math.max(0, Math.min(100, val))
}
}
// 在资源视图中尝试从task.resources中查找当前资源的占比
if (viewMode.value === 'resource' && props.currentResourceId && props.task.resources) {
const allocation = props.task.resources.find((r: any) => {
// 确保ID类型匹配可能是字符串或数字
return String(r.id) === String(props.currentResourceId)
})
if (allocation && allocation.capacity !== undefined) {
const val = Number(allocation.capacity)
if (Number.isFinite(val) && val >= 0) {
return Math.max(0, Math.min(100, val))
}
}
}
// v1.9.10 注释默认100%(向后兼容)
// 在资源视图中,如果任务没有 resources 字段或未找到匹配的资源分配,
// 说明任务隶属于该资源但未明确指定占比视为全职投入100%
return 100
})
// v1.9.2 获取当前资源颜色
const currentResourceColor = computed(() => {
if (viewMode.value === 'resource' && props.currentResourceId && props.resources) {
// 从外部传入的 resources 列表中查找资源颜色(与 TaskRow 逻辑一致)
const resource = props.resources.find(r => String(r.id) === String(props.currentResourceId))
const finalColor = resource?.color || '#85ce61'
return finalColor
}
return '#85ce61'
})
// v1.9.2 当前资源名称
const currentResourceName = computed(() => {
if (!props.currentResourceId) return ''
const resource = props.resources?.find(r => r.id === props.currentResourceId)
return resource?.name || ''
})
// v1.9.0 是否显示占比文字(占比<100%时显示)
// const shouldShowPercentText = computed(() => {
// return viewMode.value === 'resource' && resourcePercent.value < 100
// })
interface Props {
task: Task
rowHeight: number
dayWidth: number
startDate: Date
isParent?: boolean
onClick?: (task: Task, event: MouseEvent) => void
rowIndex?: number
// 新增:用于粘性文字显示的滚动位置信息
scrollLeft?: number
containerWidth?: number
// 新增外部控制半圆隐藏状态用于Timeline初始化等场景
hideBubbles?: boolean
// 新增时间线数据用于精确计算subDays定位
timelineData?: any
// 新增:当前时间刻度
currentTimeScale?: TimelineScale
// TaskBar 配置
taskBarConfig?: TaskBarConfig
// 是否允许拖拽和拉伸(默认为 true
allowDragAndResize?: boolean
// 是否显示实际任务条(默认为 false
showActualTaskbar?: boolean
// 是否启用 TaskBar 气泡提示框(默认为 true
enableTaskBarTooltip?: boolean
// 自定义任务状态背景色
pendingTaskBackgroundColor?: string
delayTaskBackgroundColor?: string
completeTaskBackgroundColor?: string
ongoingTaskBackgroundColor?: string
// 是否被高亮显示(前置或后置任务)
isHighlighted?: boolean
// 是否是主要高亮(被长按的任务)
isPrimaryHighlight?: boolean
// 是否处于高亮模式(有任务被高亮)
isInHighlightMode?: boolean
// 连接线拖拽模式:'predecessor' | 'successor' | null
dragLinkMode?: 'predecessor' | 'successor' | null
// 是否是连接线拖拽的起始任务
isLinkDragSource?: boolean
// 是否是有效的连接目标
isValidLinkTarget?: boolean
// 是否是无效的连接目标
isInvalidLinkTarget?: boolean
// 所有任务列表(用于右键菜单删除链接功能)
allTasks?: Task[]
// v1.9.0 资源视图:是否存在资源冲突(时间重叠)
hasResourceConflict?: boolean
// v1.9.2 资源视图:冲突任务列表(用于显示详细冲突信息)
conflictTasks?: Task[]
// v1.9.0 资源视图:当前资源在任务中的利用率 (20-100)
resourceAllocationPercent?: number
// v1.9.0 资源视图当前资源ID用于查找占比信息
currentResourceId?: string | number
// v1.9.0 资源视图:任务在子行中的索引(换行布局)
taskSubRow?: number
// v1.9.0 资源视图:每个子行的高度数组(换行布局)
rowHeights?: number[]
// v1.9.2 资源列表(用于查找资源名称等信息)
resources?: Array<{ id: string | number; name: string; color?: string }>
}
interface TaskStatus {
type: string
color: string
bgColor: string
borderColor: string
}
interface TaskBarSlotProps {
type: string
task: Task
status: TaskStatus
statusType: string
isParent?: boolean
progress: number
currentTimeScale?: TimelineScale
rowHeight: number
dayWidth: number
}
const slots = useSlots()
// 注入视图模式
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
// v1.9.5 注入showTaskbarTab配置
const showTaskbarTab = inject<ComputedRef<boolean>>('gantt-show-taskbar-tab', computed(() => true))
// 注入资源布局信息(用于判断跨行拖拽边界)
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()))
// v1.9.6 Phase1 - 注入位置计算缓存实例由Timeline提供
const positionCache = inject<PositionCache | null>('positionCache', null)
// 注入右键菜单配置
const enableTaskBarContextMenu = inject<ComputedRef<boolean>>('enable-task-bar-context-menu', computed(() => true))
const hasTaskBarContextMenuSlot = inject<ComputedRef<boolean>>('task-bar-context-menu-slot', computed(() => false))
const declarativeTaskBarContextMenu = inject<ComputedRef<any>>('declarative-task-bar-context-menu', computed(() => null))
// 判断是否应该显示任何右键菜单
const shouldShowAnyContextMenu = computed(() => {
// 如果 enableTaskBarContextMenu 为 false则不显示任何菜单
if (!enableTaskBarContextMenu.value) {
return false
}
return true
})
// 判断是否显示默认右键菜单enableTaskBarContextMenu=true 且没有自定义 slot 时显示)
const shouldShowDefaultContextMenu = computed(() => {
if (!enableTaskBarContextMenu.value) {
return false
}
return !hasTaskBarContextMenuSlot.value
})
// 判断是否显示自定义右键菜单enableTaskBarContextMenu=true 且有自定义 slot 时显示)
const shouldShowCustomContextMenu = computed(() => {
if (!enableTaskBarContextMenu.value) {
return false
}
if (!hasTaskBarContextMenuSlot.value) {
return false
}
// 检查 taskType 过滤
const config = declarativeTaskBarContextMenu.value
if (config?.taskType !== undefined) {
const taskType = props.task.type || 'task'
const allowedTypes = Array.isArray(config.taskType) ? config.taskType : [config.taskType]
return allowedTypes.includes(taskType)
}
return true
})
const { getTranslation } = useI18n()
const t = (key: string): string => {
return getTranslation(key)
}
const hasContentSlot = computed(() => Boolean(slots['custom-task-content']))
// 合并默认配置和用户配置
const barConfig = computed(() => ({
...DEFAULT_TASK_BAR_CONFIG,
...props.taskBarConfig,
}))
// 日期工具函数 - 处理时区安全的日期创建和操作
const createLocalDate = (dateString: string | Date | undefined | null): Date | null => {
if (!dateString) return null
if (dateString instanceof Date) {
return dateString
}
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
const [year, month, day] = dateString.split('-').map(Number)
return new Date(year, month - 1, day)
}
// 支持带时间的日期字符串 (yyyy-mm-dd hh:mm)
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(dateString)) {
const [datePart, timePart] = dateString.split(' ')
const [year, month, day] = datePart.split('-').map(Number)
const [hour, minute] = timePart.split(':').map(Number)
return new Date(year, month - 1, day, hour, minute)
}
const d = new Date(dateString)
return isNaN(d.getTime()) ? null : d
}
const createLocalToday = (): Date => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
}
// 缓存今天的日期,避免频繁创建
// 每分钟更新一次缓存(对于日期判断来说足够了)
const cachedToday = ref(createLocalToday())
let todayCacheTimer: number | null = null
onMounted(() => {
// 每60秒更新一次今天的日期缓存
todayCacheTimer = window.setInterval(() => {
cachedToday.value = createLocalToday()
}, 60000)
})
onUnmounted(() => {
if (todayCacheTimer !== null) {
clearInterval(todayCacheTimer)
todayCacheTimer = null
}
})
const formatDateToLocalString = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
// 在小时视图中,格式化为包含时间的字符串
if (props.currentTimeScale === TimelineScale.HOUR) {
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
return `${year}-${month}-${day}`
}
const addDaysToLocalDate = (date: Date, days: number): Date => {
const result = new Date(date)
result.setDate(result.getDate() + days)
return result
}
// 新增:小时视图下的时间计算工具函数
const addMinutesToDate = (date: Date, minutes: number): Date => {
const result = new Date(date)
result.setMinutes(result.getMinutes() + minutes)
return result
}
// 新增:计算两个日期之间的分钟差
const getMinutesDiff = (startDate: Date, endDate: Date): number => {
return Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60))
}
// 计算是否应该禁用拖拽和调整大小(年度视图下禁用或任务不可编辑时禁用)
const isInteractionDisabled = computed(() => {
// 检查任务的 isEditable 属性
if (props.task.isEditable === false) {
return true
}
// 年度视图下禁用
return props.currentTimeScale === TimelineScale.YEAR
})
// 拖拽状态
const isDragging = ref(false)
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.2 注入Timeline的拖拽状态用于冲突检测优化
const timelineIsDraggingTaskBar = inject<Ref<boolean>>('isDraggingTaskBar', ref(false))
// v1.9.7 注入Timeline容器的拖拽状态用于禁止TaskBar在Timeline拖拽时被拖拽
const isTimelineDragging = inject<Ref<boolean>>('isDraggingTimeline', ref(false))
// v1.9.0 拖拽预览效果(资源视图垂直拖拽)
const dragPreviewVisible = ref(false)
const dragPreviewPosition = ref({ x: 0, y: 0 })
const dragPreviewOffsetX = ref(0) // 鼠标在TaskBar内的X偏移量用于保持预览对齐
const dragEndX = ref(0) // 记录松开鼠标时的X位置用于计算新日期
const tempTaskPixelLeft = ref<number | null>(null) // 资源视图拖拽时的精确像素位置
// 长按检测状态
const longPressTimer = ref<number | null>(null)
const longPressTriggered = ref(false)
const LONG_PRESS_DURATION = 1000 // 1秒缩短了
// TaskBar 悬停状态(用于显示 LinkAnchor
const isTaskBarHovered = ref(false)
// v1.9.2 Tab悬停状态用于阻止tooltip
const isTabHovered = ref(false)
// v1.9.2 动态边框颜色tab悬停时使用资源颜色
const dynamicBorderColor = computed(() => {
if (isTabHovered.value && viewMode.value === 'resource') {
return currentResourceColor.value
}
return taskStatus.value.borderColor
})
// 防误触配置 - 使用配置项或默认值
const dragThreshold = computed(() => barConfig.value.dragThreshold ?? 5)
const isDragThresholdMet = ref(false) // 是否达到拖拽阈值
const dragType = ref<'drag' | 'resize-left' | 'resize-right' | null>(null) // 记录操作类型
const dragDelayTimer = ref<number | null>(null) // 延迟拖拽定时器
const isDelayPassed = ref(false) // 延迟是否已过
// 相对位置拖拽方案记录鼠标相对于TaskBar的位置
const mouseOffsetX = ref(0) // 鼠标在TaskBar内的相对位置
// 缓存拖拽/拉伸过程中的临时数据,只在鼠标抬起时提交更新
const tempTaskData = ref<{
startDate?: string
endDate?: string
} | null>(null)
// 季度视图拖拽时的位置覆盖(直接使用像素位置)
const quarterDragOverride = ref<{
left?: number
width?: number
} | null>(null)
const barRef = ref<HTMLElement | null>(null)
const taskBarNameRef = ref<HTMLElement | null>(null)
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: `${taskBarHeight}px`,
top: `${topOffset}px`,
}
}
const currentStartDate = tempTaskData.value?.startDate || props.task.startDate
const currentEndDate = tempTaskData.value?.endDate || props.task.endDate
const startDate = createLocalDate(currentStartDate)
const endDate = createLocalDate(currentEndDate)
const baseStart = parsedBaseStartDate.value
// 如果startDate和endDate都不存在返回0宽度实际不会渲染由shouldRenderTaskBar控制
if (!startDate && !endDate) {
const taskBarHeight = props.rowHeight - 10
const topOffset = (props.rowHeight - taskBarHeight) / 2
return {
left: '0px',
width: '0px',
height: `${taskBarHeight}px`,
top: `${topOffset}px`,
}
}
// 渲染时使用有效日期如果只有startDate或endDate用它来计算开始和结束位置
// 注意不修改原始的startDate/endDate变量只用于渲染计算
const renderStartDate = startDate || endDate
const renderEndDate = endDate || startDate
const renderBaseStart = baseStart
// 安全检查renderStartDate和renderEndDate必定存在因为上面已经检查过
// 但baseStart可能不存在如果不存在则无法计算位置
if (!renderStartDate || !renderEndDate || !renderBaseStart) {
const taskBarHeight = props.rowHeight - 10
const topOffset = (props.rowHeight - taskBarHeight) / 2
return {
left: '0px',
width: '0px',
height: `${taskBarHeight}px`,
top: `${topOffset}px`,
}
}
let left = 0
let width = 0
// 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) {
// 小时视图:按分钟精确计算位置(需要考虑时间部分)
// 计算时间线开始日期的00:00:00作为全局基准
const timelineStartOfDay = new Date(renderBaseStart)
timelineStartOfDay.setHours(0, 0, 0, 0)
// 处理没有时间部分的日期字符串
let adjustedStartDate = renderStartDate
let adjustedEndDate = renderEndDate
// 检查原始日期字符串是否包含时间部分
const originalStartStr = currentStartDate || props.task.startDate
const originalEndStr = currentEndDate || props.task.endDate
// 如果startDate没有时间部分格式为YYYY-MM-DD设置为当日00:00
if (
typeof originalStartStr === 'string' &&
/^\d{4}-\d{2}-\d{2}$/.test(originalStartStr.trim())
) {
adjustedStartDate = new Date(renderStartDate)
adjustedStartDate.setHours(0, 0, 0, 0)
}
// 如果endDate没有时间部分格式为YYYY-MM-DD设置为次日00:00
if (typeof originalEndStr === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(originalEndStr.trim())) {
adjustedEndDate = new Date(renderEndDate)
adjustedEndDate.setDate(adjustedEndDate.getDate() + 1)
adjustedEndDate.setHours(0, 0, 0, 0)
}
// 计算任务开始和结束相对于时间线开始的总分钟数
const startMinutesTotal = getMinutesDiff(timelineStartOfDay, adjustedStartDate)
const endMinutesTotal = getMinutesDiff(timelineStartOfDay, adjustedEndDate)
// 每小时40px每分钟40/60 = 2/3 px
const pixelPerMinute = 40 / 60
// 位置和宽度计算
left = Math.max(0, startMinutesTotal * pixelPerMinute)
width = Math.max(4, (endMinutesTotal - startMinutesTotal) * pixelPerMinute) // 确保最小4px宽度
} else {
// 日视图、周视图、月视图、年视图:只考虑日期部分,忽略时间部分
// 将日期标准化为当天的00:00:00忽略时间部分
const startDateOnly = new Date(
renderStartDate.getFullYear(),
renderStartDate.getMonth(),
renderStartDate.getDate(),
)
const endDateOnly = new Date(
renderEndDate.getFullYear(),
renderEndDate.getMonth(),
renderEndDate.getDate(),
)
const baseStartOnly = new Date(
renderBaseStart.getFullYear(),
renderBaseStart.getMonth(),
renderBaseStart.getDate(),
)
if (
props.timelineData &&
props.currentTimeScale &&
(props.currentTimeScale === TimelineScale.WEEK ||
props.currentTimeScale === TimelineScale.MONTH ||
props.currentTimeScale === TimelineScale.QUARTER ||
props.currentTimeScale === TimelineScale.YEAR)
) {
// v1.9.6 Phase1 - 优先使用缓存查询O(1)),提升性能
// 周/月/季/年视图从O(n)遍历优化为O(1)查表
let startPosition: number
let endPosition: number
if (positionCache) {
// 尝试从缓存获取位置
const cachedStartPos = positionCache.getPosition(startDateOnly, props.currentTimeScale)
if (cachedStartPos !== null) {
startPosition = cachedStartPos
} else {
// 缓存未命中使用原算法作为fallback
startPosition = calculatePositionFromTimelineData(
startDateOnly,
props.timelineData,
props.currentTimeScale,
)
}
// 计算结束位置:为结束日期添加一天来获取正确的结束位置
const nextDay = new Date(endDateOnly)
nextDay.setDate(nextDay.getDate() + 1)
const cachedEndPos = positionCache.getPosition(nextDay, props.currentTimeScale)
if (cachedEndPos !== null) {
endPosition = cachedEndPos
} else {
// 缓存未命中使用原算法作为fallback
endPosition = calculatePositionFromTimelineData(
nextDay,
props.timelineData,
props.currentTimeScale,
)
}
} else {
// 没有缓存,使用原算法(向后兼容)
startPosition = calculatePositionFromTimelineData(
startDateOnly,
props.timelineData,
props.currentTimeScale,
)
const nextDay = new Date(endDateOnly)
nextDay.setDate(nextDay.getDate() + 1)
endPosition = calculatePositionFromTimelineData(
nextDay,
props.timelineData,
props.currentTimeScale,
)
}
// 如果结束日期+1天超出范围使用结束日期的位置+一天的宽度
if (endPosition === startPosition) {
let dayWidth = 60 / 30 // 默认月视图
if (props.currentTimeScale === TimelineScale.WEEK) {
dayWidth = 60 / 7
} else if (props.currentTimeScale === TimelineScale.QUARTER) {
dayWidth = 60 / 90 // 季度视图每季度60px约90天
} else if (props.currentTimeScale === TimelineScale.YEAR) {
dayWidth = 180 / 182 // 年度视图每半年180px约182天
}
endPosition =
calculatePositionFromTimelineData(
endDateOnly,
props.timelineData,
props.currentTimeScale,
) + dayWidth
}
left = startPosition
width = Math.max(endPosition - startPosition, 4) // 确保最小4px宽度
} else if (
props.timelineData &&
props.currentTimeScale === TimelineScale.DAY
) {
// v1.9.6 Phase1 - 日视图也使用缓存优化
let startPosition: number
let endPosition: number
if (positionCache) {
// 尝试从缓存获取位置
const cachedStartPos = positionCache.getPosition(startDateOnly, props.currentTimeScale)
if (cachedStartPos !== null) {
startPosition = cachedStartPos
} else {
// 缓存未命中,使用原算法
startPosition = calculatePositionFromTimelineData(
startDateOnly,
props.timelineData,
props.currentTimeScale,
)
}
// 计算结束位置:为结束日期添加一天
const nextDay = new Date(endDateOnly)
nextDay.setDate(nextDay.getDate() + 1)
const cachedEndPos = positionCache.getPosition(nextDay, props.currentTimeScale)
if (cachedEndPos !== null) {
endPosition = cachedEndPos
} else {
endPosition = calculatePositionFromTimelineData(
nextDay,
props.timelineData,
props.currentTimeScale,
)
}
} else {
// 没有缓存,使用原算法
startPosition = calculatePositionFromTimelineData(
startDateOnly,
props.timelineData,
props.currentTimeScale,
)
const nextDay = new Date(endDateOnly)
nextDay.setDate(nextDay.getDate() + 1)
endPosition = calculatePositionFromTimelineData(
nextDay,
props.timelineData,
props.currentTimeScale,
)
}
// 如果结束日期+1天超出范围使用结束日期的位置+一天的宽度
if (endPosition === startPosition) {
endPosition = calculatePositionFromTimelineData(
endDateOnly,
props.timelineData,
props.currentTimeScale,
) + 30 // 日视图每天30px
}
left = startPosition
width = Math.max(endPosition - startPosition, 4) // 确保最小4px宽度
} else {
// 其他情况(没有 timelineData基于日期的简单计算
const startDiff = Math.floor(
(startDateOnly.getTime() - baseStartOnly.getTime()) / (1000 * 60 * 60 * 24),
)
// 计算持续天数(基于日期,忽略时间)
const timeDiffMs = endDateOnly.getTime() - startDateOnly.getTime()
const daysDiff = Math.round(timeDiffMs / (1000 * 60 * 60 * 24))
// 如果开始和结束是同一天duration = 1否则是实际天数差 + 1包含结束日期
const duration = daysDiff === 0 ? 1 : daysDiff + 1
left = startDiff * props.dayWidth
width = duration * props.dayWidth
}
}
// v1.9.1 计算TaskBar高度资源视图固定41px全高度不再按占比缩放
const baseTaskBarHeight = props.rowHeight - 10 // 基础高度(与任务视图一致)
const taskBarHeight = baseTaskBarHeight
// v1.9.1 资源视图固定全高度不缩放占比通过CSS伪元素实现
// 任务视图:保持原有高度
// 资源视图的占比视觉效果通过CSS的::before和::after伪元素实现
// v1.9.1 计算垂直位置:资源视图中支持换行布局
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
}
// v1.9.1 在当前子行内居中对齐因为TaskBar固定41px高度
// 第一行padding-top(5px) + 居中
// 后续行居中无padding-top
if (subRow === 0) {
// 第一行顶部5px padding居中对齐
topOffset = cumulativeOffset + 5 + (currentRowHeight - 5 - taskBarHeight) / 2
} else {
// 后续行:居中对齐
topOffset = cumulativeOffset + (currentRowHeight - taskBarHeight) / 2
}
}
return {
left: `${left}px`,
width: `${width}px`,
height: `${taskBarHeight}px`,
top: `${topOffset}px`,
}
})
// TaskBar的left位置数值用于TaskBarTab磁吸计算
const taskBarLeft = computed(() => {
const leftStr = taskBarStyle.value.left
return parseFloat(leftStr) || 0
})
// 缓存 TaskBar 的位置信息,减少 DOM 读取频率
const cachedPosition = ref({
left: 0,
top: 0,
width: 0,
height: 0,
timestamp: 0,
})
// 位置缓存有效期(毫秒)
const POSITION_CACHE_TTL = 100 // 100ms 内使用缓存值
// 缓存解析后的结束日期,避免在 taskStatus 中重复解析
const parsedEndDate = computed(() => createLocalDate(props.task.endDate || ''))
// 缓存解析后的基准开始日期
const parsedBaseStartDate = computed(() => createLocalDate(props.startDate))
// 判断是否应该渲染TaskBar只考虑startDate和endDate都不存在时不渲染
const shouldRenderTaskBar = computed(() => {
const currentStartDate = tempTaskData.value?.startDate || props.task.startDate
const currentEndDate = tempTaskData.value?.endDate || props.task.endDate
// 只要startDate或endDate有一个存在就渲染
return !!(currentStartDate || currentEndDate)
})
// v1.9.2 TaskBar宽度用于Tab组件判断是否紧凑模式
const taskBarWidth = computed(() => {
const width = parseFloat(taskBarStyle.value.width)
return isNaN(width) ? 0 : width
})
// 计算任务状态和颜色
const taskStatus = computed(() => {
// 优先级最高如果task设置了barColor自定义颜色
if (props.task.barColor) {
// 将十六进制颜色转换为RGB
const hexToRgb = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
} : { r: 64, g: 158, b: 255 } // 默认蓝色
}
const rgb = hexToRgb(props.task.barColor)
// 父级任务直接使用自定义颜色作为主色
if (props.isParent) {
return {
type: 'custom-parent',
color: props.task.barColor,
bgColor: props.task.barColor,
borderColor: props.task.barColor,
}
}
// 普通任务生成浅色背景和中间色边框
// 生成浅色背景(接近白色,保持色调)- 约95%白色 + 5%原色
const bgColor = `rgb(${Math.round(255 * 0.95 + rgb.r * 0.05)}, ${Math.round(255 * 0.95 + rgb.g * 0.05)}, ${Math.round(255 * 0.95 + rgb.b * 0.05)})`
// 生成中间色边框约70%白色 + 30%原色)
const borderColor = `rgb(${Math.round(255 * 0.7 + rgb.r * 0.3)}, ${Math.round(255 * 0.7 + rgb.g * 0.3)}, ${Math.round(255 * 0.7 + rgb.b * 0.3)})`
return {
type: 'custom',
color: props.task.barColor,
bgColor,
borderColor,
}
}
// 父级任务(Story类型)使用与新建按钮一致的配色
if (props.isParent) {
return {
type: 'parent',
color: '#409eff', // 与新建按钮一致的蓝色
bgColor: '#409eff',
borderColor: '#409eff',
}
}
// 辅助函数:根据主色生成浅色背景和中间色边框
const generateColorsFromMain = (mainColor: string) => {
const hexToRgb = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
} : { r: 64, g: 158, b: 255 }
}
const rgb = hexToRgb(mainColor)
const bgColor = `rgb(${Math.round(255 * 0.95 + rgb.r * 0.05)}, ${Math.round(255 * 0.95 + rgb.g * 0.05)}, ${Math.round(255 * 0.95 + rgb.b * 0.05)})`
const borderColor = `rgb(${Math.round(255 * 0.7 + rgb.r * 0.3)}, ${Math.round(255 * 0.7 + rgb.g * 0.3)}, ${Math.round(255 * 0.7 + rgb.b * 0.3)})`
return { bgColor, borderColor }
}
// 使用缓存的今天日期,避免频繁创建日期对象
const today = cachedToday.value
const endDate = parsedEndDate.value
const progress = props.task.progress || 0
const actualStartDate = props.task.actualStartDate ? new Date(props.task.actualStartDate) : null
// 已完成(优先级高)
if (progress >= 100) {
// 优先使用自定义颜色,否则使用默认
const mainColor = props.completeTaskBackgroundColor || '#909399'
const colors = generateColorsFromMain(mainColor)
return {
type: 'completed',
color: mainColor,
bgColor: colors.bgColor,
borderColor: colors.borderColor,
}
}
// 已延迟(结束日期早于今天且未完成)
if (endDate && endDate < today && progress < 100) {
const mainColor = props.delayTaskBackgroundColor || '#f56c6c'
const colors = generateColorsFromMain(mainColor)
return {
type: 'delayed',
color: mainColor,
bgColor: colors.bgColor,
borderColor: colors.borderColor,
}
}
// 进行中actualStartDate存在未完成且未逾期
if (actualStartDate && progress < 100 && endDate && endDate >= today) {
const mainColor = props.ongoingTaskBackgroundColor || '#e6a23c'
const colors = generateColorsFromMain(mainColor)
return {
type: 'ongoing',
color: mainColor,
bgColor: colors.bgColor,
borderColor: colors.borderColor,
}
}
// 待处理(未开始且未逾期)- actualStartDate不存在当前日期早于计划结束日
if (!actualStartDate && (!endDate || endDate >= today)) {
const mainColor = props.pendingTaskBackgroundColor || '#409eff'
const colors = generateColorsFromMain(mainColor)
return {
type: 'pending',
color: mainColor,
bgColor: colors.bgColor,
borderColor: colors.borderColor,
}
}
// 默认状态(兜底)
return {
type: 'default',
color: '#409eff',
bgColor: '#ecf5ff',
borderColor: '#b3d8ff',
}
})
// Slot payload for content slot - 使用 v-bind 方式传递所有属性
const slotPayload = computed(() => ({
type: 'task-bar',
task: props.task,
status: taskStatus.value,
statusType: taskStatus.value.type,
isParent: props.isParent ?? false,
progress: props.task.progress || 0,
currentTimeScale: props.currentTimeScale,
rowHeight: props.rowHeight,
dayWidth: props.dayWidth,
}))
// 处理avatar数组和assignee生成头像
const avatarList = computed(() => {
const avatar = props.task.avatar
const assignee = props.task.assignee
// 如果有avatar使用avatar
if (avatar) {
return Array.isArray(avatar) ? avatar : [avatar]
}
// 如果没有avatar但有assignee使用assignee生成头像数据
if (assignee) {
const assigneeList = Array.isArray(assignee) ? assignee : [assignee]
// 返回对象数组,包含名字信息用于生成文字头像
return assigneeList.map(name => ({ isText: true, name }))
}
return []
})
// 判断是否已完成
const isCompleted = computed(() => (props.task.progress || 0) >= 100)
// 判断是否应该显示为暗淡(处于高亮模式但自己不是高亮的)
const isDimmed = computed(() => {
return props.isInHighlightMode && !props.isHighlighted && !props.isPrimaryHighlight
})
// 计算完成部分的宽度
const progressWidth = computed(() => {
const progress = props.task.progress || 0
const totalWidth = parseInt(taskBarStyle.value.width)
return `${(progress / 100) * totalWidth}px`
})
// 判断是否为周视图dayWidth小于等于9为周视图
const isWeekView = computed(() => props.dayWidth <= 9)
// 判断是否为短TaskBar宽度小于80px
const isShortTaskBar = computed(() => {
const width = parseFloat(taskBarStyle.value.width || '0')
return width < 80
})
// 判断是否有实际进度数据
const hasActualProgress = computed(() => {
return !!(props.task.actualStartDate || props.task.actualEndDate)
})
// 计算实际进度条的样式独立的TaskBar在下层
const actualBarStyle = computed(() => {
// 只有当showActualTaskbar=true且存在actualStartDate时才显示实际任务条
if (!props.showActualTaskbar || !props.task.actualStartDate || props.isParent) {
return null
}
const actualStart = createLocalDate(props.task.actualStartDate)
const actualEnd = createLocalDate(props.task.actualEndDate)
const planStart = createLocalDate(props.task.startDate)
const baseStartOnly = parsedBaseStartDate.value
if (!baseStartOnly) {
return null
}
// 实际开始日期,如果没有则使用计划开始日期
const effectiveStart = actualStart || planStart
// 实际结束日期,如果没有则使用当前日期(任务进行中)
const effectiveEnd = actualEnd || (actualStart ? createLocalToday() : null)
if (!effectiveStart || !effectiveEnd) {
return null
}
// 计算实际进度条的绝对位置(与计划条使用相同逻辑)
let actualLeft = 0
let actualWidth = 100
// 根据时间刻度计算位置与taskBarStyle逻辑一致
if (
props.timelineData &&
props.currentTimeScale &&
(props.currentTimeScale === TimelineScale.WEEK ||
props.currentTimeScale === TimelineScale.MONTH ||
props.currentTimeScale === TimelineScale.QUARTER ||
props.currentTimeScale === TimelineScale.YEAR)
) {
const startPosition = calculatePositionFromTimelineData(
effectiveStart,
props.timelineData,
props.currentTimeScale,
)
const nextDay = new Date(effectiveEnd)
nextDay.setDate(nextDay.getDate() + 1)
let endPosition = calculatePositionFromTimelineData(
nextDay,
props.timelineData,
props.currentTimeScale,
)
if (endPosition === startPosition) {
let dayWidth = 60 / 30
if (props.currentTimeScale === TimelineScale.WEEK) {
dayWidth = 60 / 7
} else if (props.currentTimeScale === TimelineScale.QUARTER) {
dayWidth = 60 / 90
} else if (props.currentTimeScale === TimelineScale.YEAR) {
dayWidth = 180 / 182
}
endPosition = calculatePositionFromTimelineData(
effectiveEnd,
props.timelineData,
props.currentTimeScale,
) + dayWidth
}
actualLeft = startPosition
actualWidth = Math.max(endPosition - startPosition, 4)
} else if (props.timelineData && props.currentTimeScale === TimelineScale.DAY) {
const startPosition = calculatePositionFromTimelineData(
effectiveStart,
props.timelineData,
props.currentTimeScale,
)
const nextDay = new Date(effectiveEnd)
nextDay.setDate(nextDay.getDate() + 1)
let endPosition = calculatePositionFromTimelineData(
nextDay,
props.timelineData,
props.currentTimeScale,
)
if (endPosition === startPosition) {
endPosition = calculatePositionFromTimelineData(
effectiveEnd,
props.timelineData,
props.currentTimeScale,
) + 30
}
actualLeft = startPosition
actualWidth = Math.max(endPosition - startPosition, 4)
} else {
const startDiff = Math.floor(
(effectiveStart.getTime() - baseStartOnly.getTime()) / (1000 * 60 * 60 * 24),
)
const timeDiffMs = effectiveEnd.getTime() - effectiveStart.getTime()
const daysDiff = Math.round(timeDiffMs / (1000 * 60 * 60 * 24))
const duration = daysDiff === 0 ? 1 : daysDiff + 1
actualLeft = startDiff * props.dayWidth
actualWidth = duration * props.dayWidth
}
// 实际进度条固定高度20px垂直居中显示
const actualHeight = 20
const topOffset = (props.rowHeight - actualHeight) / 2 // 上下居中对齐
return {
left: `${actualLeft}px`,
width: `${actualWidth}px`,
height: `${actualHeight}px`,
top: `${topOffset}px`,
}
})
// 判断是否需要溢出效果周视图且短TaskBar
const needsOverflowEffect = computed(() => isWeekView.value && isShortTaskBar.value)
// 鼠标事件处理 - 使用相对位置拖拽方案(带防误触机制)
const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-right') => {
// v1.9.7 如果Timeline正在被拖拽禁止TaskBar的拖拽和拉伸让事件传播到Timeline
if (isTimelineDragging.value) {
// 不阻止事件传播让Timeline可以继续滚动
return
}
// 如果处于高亮状态不阻止事件传播让Timeline可以滚动
if (props.isHighlighted || props.isPrimaryHighlight) {
// 不调用 e.preventDefault() 和 e.stopPropagation()
// 让事件继续传播到父容器,从而触发 Timeline 的滚动
return
}
// 如果禁用了拖拽和拉伸,直接返回
if (props.allowDragAndResize === false) {
return
}
// 如果已完成或是父级任务或年度视图,禁用所有交互
if (isCompleted.value || props.isParent || isInteractionDisabled.value) {
return
}
e.preventDefault()
e.stopPropagation()
// 清空之前的临时数据
tempTaskData.value = null
// 重置防误触状态
isDragThresholdMet.value = false
isDelayPassed.value = false
dragType.value = type
// 重置长按状态
longPressTriggered.value = false
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
// 启动长按检测定时器(仅在拖拽模式下)
if (type === 'drag') {
longPressTimer.value = window.setTimeout(() => {
// 检查是否发生了拖拽或拉伸(通过检查是否达到拖拽阈值)
const noDragOrResize =
!isDragThresholdMet.value &&
!isDragging.value &&
!isResizingLeft.value &&
!isResizingRight.value
if (noDragOrResize) {
longPressTriggered.value = true
emit('long-press', props.task.id)
}
longPressTimer.value = null
}, LONG_PRESS_DURATION)
}
// 如果启用了拖拽延迟,设置定时器
if (barConfig.value.enableDragDelay && barConfig.value.dragDelayTime) {
dragDelayTimer.value = window.setTimeout(() => {
isDelayPassed.value = true
dragDelayTimer.value = null
}, barConfig.value.dragDelayTime)
} else {
// 未启用延迟,立即标记为已过延迟
isDelayPassed.value = true
}
// 获取TaskBar相对于Timeline容器的位置
const timelineContainer = document.querySelector('.timeline') as HTMLElement
if (!timelineContainer || !barRef.value) return
// 在 mousedown 事件中读取位置是合理的(不是高频操作)
// 这个值用于计算拖拽偏移量,只在开始拖拽时读取一次
const barRect = barRef.value.getBoundingClientRect()
// 计算鼠标相对于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)
if (type === 'resize-left') {
resizeStartX.value = e.clientX
resizeStartWidth.value = parseInt(taskBarStyle.value.width)
resizeStartLeft.value = parseInt(taskBarStyle.value.left)
} else if (type === 'resize-right') {
resizeStartX.value = e.clientX
resizeStartWidth.value = parseInt(taskBarStyle.value.width)
resizeStartLeft.value = parseInt(taskBarStyle.value.left)
}
// 监听自动滚动事件
window.addEventListener('timeline-auto-scroll', handleAutoScroll as EventListener)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
// 处理自动滚动事件
const handleAutoScroll = (event: CustomEvent) => {
const { scrollDelta } = event.detail
// 当Timeline滚动时调整鼠标起始位置以保持相对位置
if (isDragging.value) {
dragStartX.value -= scrollDelta
} else if (isResizingLeft.value || isResizingRight.value) {
resizeStartX.value -= scrollDelta
}
}
// 使用缓存机制减少 DOM 读取频率,但保证位置准确性
let reportPositionScheduled = false
function reportBarPosition() {
// 如果已经安排了本帧的位置报告,则跳过
if (reportPositionScheduled) return
reportPositionScheduled = true
requestAnimationFrame(() => {
reportPositionScheduled = false
if (!barRef.value) return
const now = Date.now()
// 如果缓存未过期,使用缓存值
if (now - cachedPosition.value.timestamp < POSITION_CACHE_TTL) {
emit('bar-mounted', {
id: props.task.id,
left: cachedPosition.value.left,
top: cachedPosition.value.top,
width: cachedPosition.value.width,
height: cachedPosition.value.height,
})
return
}
// 缓存过期或首次调用,读取 DOM 并更新缓存
// TaskBar 传递绝对位置相对于视口Timeline 会将其转换为相对位置
const rect = barRef.value.getBoundingClientRect()
// 计算并缓存位置
const position = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
}
// 更新缓存
cachedPosition.value = {
...position,
timestamp: now,
}
emit('bar-mounted', {
id: props.task.id,
...position,
})
})
}
// 拖拽时的实时日期提示框状态
const dragTooltipVisible = ref(false)
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 && props.currentResourceId) {
const bodyRect = timelineBody.getBoundingClientRect()
const relativeY = e.clientY - bodyRect.top + (timelineBody as HTMLElement).scrollTop
// 获取当前资源行的位置和高度
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) {
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
}
// 检查延迟是否已过(如果启用了延迟)
if (barConfig.value.enableDragDelay && !isDelayPassed.value) {
return
}
// 检查是否达到拖拽阈值
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) // 资源视图拖拽X或Y有一个达到阈值即可
: deltaX // 任务视图或拉伸只考虑X轴
// 如果移动距离小于阈值,不执行任何操作
if (threshold < dragThreshold.value) {
return
}
// 达到阈值,激活对应的拖拽/拉伸状态
isDragThresholdMet.value = true
if (dragType.value === 'drag') {
isDragging.value = true
} else if (dragType.value === 'resize-left') {
isResizingLeft.value = true
} else if (dragType.value === 'resize-right') {
isResizingRight.value = true
}
}
// 只有达到阈值后才执行边界检测和提示框显示
if (!isDragThresholdMet.value) {
return
}
// 发送边界检测事件给Timeline
window.dispatchEvent(
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 标识是否资源视图
},
}),
)
// 更新拖拽提示框位置
if (isDragging.value || isResizingLeft.value || isResizingRight.value) {
dragTooltipVisible.value = true
dragTooltipPosition.value = {
x: e.clientX + 15, // 鼠标右侧偏移
y: e.clientY - 60, // 鼠标上方偏移
}
}
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 更新tempTaskData让TaskBar有视觉移动效果同行和跨行都需要
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
}
// 资源视图直接返回,不执行后续的任务视图逻辑
return
}
if (props.currentTimeScale === TimelineScale.HOUR) {
// 小时视图15分钟刻度对齐
const pixelPerMinute = 40 / 60 // 每分钟的像素数
const pixelPer15Minutes = pixelPerMinute * 15 // 15分钟的像素数
// 计算新的左侧位置对齐到15分钟刻度
const newLeftRaw = Math.max(0, dragStartLeft.value + deltaX)
const newLeft = Math.round(newLeftRaw / pixelPer15Minutes) * pixelPer15Minutes
// 计算新的开始时间(分钟精度)
const newStartMinutes = Math.round(newLeft / pixelPerMinute)
// 确保使用当天的00:00:00作为基准
const baseStartOfDay = new Date(props.startDate)
baseStartOfDay.setHours(0, 0, 0, 0)
const newStartDate = addMinutesToDate(baseStartOfDay, newStartMinutes)
// 保持任务的持续时间(计算原始任务的时长)
const originalStartDate = createLocalDate(props.task.startDate) || props.startDate
const originalEndDate = createLocalDate(props.task.endDate) || props.startDate
// 如果原始任务是日期格式,转换为当天的时间范围
let originalDurationMinutes: number
if (props.task.startDate && !props.task.startDate.includes(' ')) {
// 纯日期格式,默认按天计算(一天 = 1440 分钟)
const timeDiffMs = originalEndDate.getTime() - originalStartDate.getTime()
const daysDiff = Math.max(1, Math.round(timeDiffMs / (1000 * 60 * 60 * 24)) + 1)
originalDurationMinutes = daysDiff * 24 * 60 // 天数转分钟
} else {
// 包含时间格式,按实际时间差计算
originalDurationMinutes = getMinutesDiff(originalStartDate, originalEndDate)
}
const newEndDate = addMinutesToDate(newStartDate, originalDurationMinutes)
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
} else if (props.currentTimeScale === TimelineScale.QUARTER) {
// 季度视图:直接使用像素位置,保持拖拽跟随鼠标
const newLeft = Math.max(0, dragStartLeft.value + deltaX)
// 更新位置覆盖
quarterDragOverride.value = {
left: newLeft,
width: dragStartWidth.value,
}
// 同时计算日期用于提示框使用与Timeline相同的季度视图计算
const quarterWidth = 60 // 与Timeline.vue保持一致
const daysInQuarter = 90 // 季度平均天数
const pixelsPerDay = quarterWidth / daysInQuarter // 约0.67px/天
const dayOffset = Math.round(deltaX / pixelsPerDay)
// 基于原始任务日期计算新日期
const originalStartDate = createLocalDate(props.task.startDate) || props.startDate
const originalEndDate = createLocalDate(props.task.endDate) || props.startDate
const newStartDate = new Date(originalStartDate)
newStartDate.setDate(newStartDate.getDate() + dayOffset)
// 计算任务持续天数
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))
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
} else {
// 其他视图(包括日视图、周视图、月视图、季度视图、年度视图):保持原有逻辑
const newLeft = Math.max(0, dragStartLeft.value + deltaX)
// 日视图、月视图、季度视图或年度视图:如果有 timelineData使用精确计算
if (
(props.currentTimeScale === TimelineScale.DAY ||
props.currentTimeScale === TimelineScale.MONTH ||
props.currentTimeScale === TimelineScale.QUARTER ||
props.currentTimeScale === TimelineScale.YEAR) &&
props.timelineData
) {
const newStartDate = calculateDateFromPosition(
newLeft,
props.timelineData,
props.currentTimeScale,
)
if (newStartDate) {
// 计算任务持续天数
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))
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
}
} else {
// 其他情况:使用原有的简单计算
const newStartDate = addDaysToLocalDate(props.startDate, newLeft / props.dayWidth)
// 从原始任务日期计算持续天数避免从像素宽度反推dragStartWidth / dayWidth
// 导致的精度损失WEEK视图 dayWidth=60/7 非整数parseInt截断后往返计算误差会缩短任务
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.round(durationMs / (1000 * 60 * 60 * 24))
const newEndDate = addDaysToLocalDate(newStartDate, duration)
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
}
}
} else if (isResizingLeft.value) {
const deltaX = e.clientX - resizeStartX.value
if (props.currentTimeScale === TimelineScale.HOUR) {
// 小时视图15分钟刻度对齐
const pixelPerMinute = 40 / 60
const pixelPer15Minutes = pixelPerMinute * 15
// 计算新的左侧位置对齐到15分钟刻度
const newLeftRaw = Math.max(0, resizeStartLeft.value + deltaX)
const newLeft = Math.round(newLeftRaw / pixelPer15Minutes) * pixelPer15Minutes
// 计算新的开始时间
const newStartMinutes = Math.round(newLeft / pixelPerMinute)
// 确保使用当天的00:00:00作为基准
const baseStartOfDay = new Date(props.startDate)
baseStartOfDay.setHours(0, 0, 0, 0)
const newStartDate = addMinutesToDate(baseStartOfDay, newStartMinutes)
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate, // 保持原来的结束日期
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate || '',
}
} else if (props.currentTimeScale === TimelineScale.QUARTER) {
// 季度视图左侧resize直接使用像素位置
const newLeft = Math.max(0, resizeStartLeft.value + deltaX)
const newWidth = Math.max(10, resizeStartWidth.value - deltaX) // 保持最小宽度
// 更新位置覆盖
quarterDragOverride.value = {
left: newLeft,
width: newWidth,
}
// 计算日期用于提示框使用与Timeline相同的季度视图计算
const quarterWidth = 60 // 与Timeline.vue保持一致
const daysInQuarter = 90 // 季度平均天数
const pixelsPerDay = quarterWidth / daysInQuarter // 约0.67px/天
const dayOffset = Math.round(deltaX / pixelsPerDay)
const originalStartDate = createLocalDate(props.task.startDate) || props.startDate
const newStartDate = new Date(originalStartDate)
newStartDate.setDate(newStartDate.getDate() + dayOffset)
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate, // 保持原来的结束日期
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate || '',
}
} else {
// 其他视图(包括日视图、周视图、月视图、季度视图、年度视图):保持原有逻辑
const newLeft = Math.max(0, resizeStartLeft.value + deltaX)
// 日视图、月视图、季度视图或年度视图:如果有 timelineData使用精确计算
if (
(props.currentTimeScale === TimelineScale.DAY ||
props.currentTimeScale === TimelineScale.MONTH ||
props.currentTimeScale === TimelineScale.QUARTER ||
props.currentTimeScale === TimelineScale.YEAR) &&
props.timelineData
) {
const newStartDate = calculateDateFromPosition(
newLeft,
props.timelineData,
props.currentTimeScale,
)
if (newStartDate) {
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate, // 保持原来的结束日期
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate || '',
}
}
} else {
// 其他情况:使用原有的简单计算
const newStartDate = addDaysToLocalDate(props.startDate, newLeft / props.dayWidth)
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate, // 保持原来的结束日期
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate || '',
}
}
}
} else if (isResizingRight.value) {
const deltaX = e.clientX - resizeStartX.value
if (props.currentTimeScale === TimelineScale.HOUR) {
// 小时视图15分钟刻度对齐
const pixelPerMinute = 40 / 60
const pixelPer15Minutes = pixelPerMinute * 15
// 计算新的宽度对齐到15分钟刻度
const newWidthRaw = Math.max(pixelPer15Minutes, resizeStartWidth.value + deltaX)
const newWidth = Math.round(newWidthRaw / pixelPer15Minutes) * pixelPer15Minutes
// 计算新的持续时间(分钟)
const newDurationMinutes = Math.round(newWidth / pixelPerMinute)
// 计算新的结束时间
const originalStartDate = createLocalDate(props.task.startDate) || props.startDate
const newEndDate = addMinutesToDate(originalStartDate, newDurationMinutes)
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: props.task.startDate, // 保持原来的开始日期
endDate: formatDateToLocalString(newEndDate),
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: props.task.startDate || '',
endDate: formatDateToLocalString(newEndDate),
}
} else if (props.currentTimeScale === TimelineScale.QUARTER) {
// 季度视图右侧resize直接使用像素位置
const newWidth = Math.max(10, resizeStartWidth.value + deltaX) // 保持最小宽度
// 更新位置覆盖
quarterDragOverride.value = {
left: resizeStartLeft.value,
width: newWidth,
}
// 计算日期用于提示框使用与Timeline相同的季度视图计算
const quarterWidth = 60 // 与Timeline.vue保持一致
const daysInQuarter = 90 // 季度平均天数
const pixelsPerDay = quarterWidth / daysInQuarter // 约0.67px/天
const dayOffset = Math.round(deltaX / pixelsPerDay)
const originalEndDate = createLocalDate(props.task.endDate) || props.startDate
const newEndDate = new Date(originalEndDate)
newEndDate.setDate(newEndDate.getDate() + dayOffset)
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: props.task.startDate, // 保持原来的开始日期
endDate: formatDateToLocalString(newEndDate),
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: props.task.startDate || '',
endDate: formatDateToLocalString(newEndDate),
}
} else {
// 其他视图(包括日视图、周视图、月视图、季度视图、年度视图):保持原有逻辑
const newWidth = Math.max(props.dayWidth, resizeStartWidth.value + deltaX)
// 日视图、月视图、季度视图或年度视图:如果有 timelineData使用精确计算
if (
(props.currentTimeScale === TimelineScale.DAY ||
props.currentTimeScale === TimelineScale.MONTH ||
props.currentTimeScale === TimelineScale.QUARTER ||
props.currentTimeScale === TimelineScale.YEAR) &&
props.timelineData
) {
// 计算新的结束位置(左侧位置 + 新宽度)
const newRightPosition = resizeStartLeft.value + newWidth
const newEndDate = calculateDateFromPosition(
newRightPosition,
props.timelineData,
props.currentTimeScale,
)
if (newEndDate) {
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: props.task.startDate, // 保持原来的开始日期
endDate: formatDateToLocalString(newEndDate),
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: props.task.startDate || '',
endDate: formatDateToLocalString(newEndDate),
}
}
} else {
// 其他情况:使用原有的简单计算
const newDurationDays = newWidth / props.dayWidth
const newEndDate = addDaysToLocalDate(
props.startDate,
resizeStartLeft.value / props.dayWidth + newDurationDays - 1,
)
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: props.task.startDate, // 保持原来的开始日期
endDate: formatDateToLocalString(newEndDate),
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: props.task.startDate || '',
endDate: formatDateToLocalString(newEndDate),
}
}
}
}
}
const handleMouseUp = () => {
// 清除延迟定时器
if (dragDelayTimer.value !== null) {
window.clearTimeout(dragDelayTimer.value)
dragDelayTimer.value = null
}
// 清除长按定时器
if (longPressTimer.value !== null) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
// 隐藏拖拽提示框
dragTooltipVisible.value = false
// 清除季度视图位置覆盖
quarterDragOverride.value = null
// 停止边界检测
window.dispatchEvent(
new CustomEvent('drag-boundary-check', {
detail: {
mouseX: 0,
isDragging: false,
},
}),
)
// v1.9.0 资源视图垂直拖拽:检测是否移动到不同资源
// @ts-expect-error - targetResourceRowIndex预留变量未来可能使用
let targetResourceRowIndex: number | undefined
let isCrossRowDrag = false
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 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事件
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 直接传递TaskBar已经计算好的精确日期避免Timeline重复计算导致误差
calculatedStartDate: tempTaskData.value?.startDate,
calculatedEndDate: tempTaskData.value?.endDate,
},
}),
)
}
// 隐藏拖拽预览
dragPreviewVisible.value = false
}
// 只有达到拖拽阈值且有临时数据时才提交更新
// v1.9.0 资源视图跨行拖拽时不提交,由确认对话框处理
if (isDragThresholdMet.value && tempTaskData.value && !(viewMode.value === 'resource' && isCrossRowDrag)) {
const updatedTask = {
...props.task,
...tempTaskData.value,
}
// 判断是拖拽还是拉伸
if (isDragging.value) {
emit('drag-end', updatedTask)
} else if (isResizingLeft.value || isResizingRight.value) {
emit('resize-end', updatedTask)
}
emit('update:task', updatedTask)
// 清空临时数据
tempTaskData.value = null
// 下一帧报告新位置
nextTick(() => {
reportBarPosition()
})
}
// 清理自动滚动监听器
window.removeEventListener('timeline-auto-scroll', handleAutoScroll as EventListener)
// 如果发生了拖拽或调整大小,设置标志防止触发 click 事件
if (isDragging.value || isResizingLeft.value || isResizingRight.value) {
justFinishedDragOrResize.value = true
// 200ms 后清除标志
setTimeout(() => {
justFinishedDragOrResize.value = false
}, 200)
}
// 重置所有拖拽状态
isDragging.value = false
isResizingLeft.value = false
isResizingRight.value = false
isDragThresholdMet.value = false
isDelayPassed.value = false
dragType.value = null
tempTaskPixelLeft.value = null // v1.9.0 清除资源视图的像素位置缓存
// v1.9.7 不需要显式设置timelineIsDraggingTaskBar
// 上面的状态重置会自动触发watchwatch会同步timelineIsDraggingTaskBar的值
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
// ResizeObserver 引用(在组件卸载时清理)
let nameResizeObserver: ResizeObserver | null = null
onMounted(() => {
nextTick(() => {
reportBarPosition()
// 使用 ResizeObserver 监听任务名称宽度变化
if (taskBarNameRef.value) {
nameResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
nameTextWidth.value = entry.contentRect.width
}
})
nameResizeObserver.observe(taskBarNameRef.value)
}
})
// 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(() => {
reportBarPosition()
})
}
// 监听强制重新计算事件
const handleForceRecalculate = () => {
// 延迟稍长一点确保DOM完全更新
nextTick(() => {
setTimeout(() => {
reportBarPosition()
}, 10)
})
}
window.addEventListener('timeline-scale-updated', handleTimelineScaleUpdate)
window.addEventListener('timeline-force-recalculate', handleForceRecalculate)
// 监听全局关闭菜单事件
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
document.addEventListener('click', handleDocumentClick)
// 清理函数
onUnmounted(() => {
// 清理 ResizeObserver
if (nameResizeObserver) {
nameResizeObserver.disconnect()
nameResizeObserver = null
}
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)
// 清除长按定时器
if (longPressTimer.value !== null) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
// 清理单击定时器
if (clickTimer !== null) {
clearTimeout(clickTimer)
clickTimer = null
}
})
})
// 监听任务数据变化,重新报告位置
watch(
() => [props.task.startDate, props.task.endDate],
() => {
nextTick(() => {
reportBarPosition()
})
},
{ deep: true },
)
// 监听高亮状态变化(调试)
watch(
() => [props.isHighlighted, props.isPrimaryHighlight],
([highlighted, primary]) => {
if (highlighted || primary) {
// 🔧 修复:记录是否刚刚完成了交互操作
const wasInteracting = isDragging.value || isResizingLeft.value || isResizingRight.value
// 当TaskBar变为高亮状态时立即清理所有拖拽状态和事件监听器
// 无条件清理,即使没有正在拖拽也要重置状态
isDragging.value = false
isResizingLeft.value = false
isResizingRight.value = false
isDragThresholdMet.value = false
isDelayPassed.value = false
dragType.value = null
dragTooltipVisible.value = false
// 清理事件监听器
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
window.removeEventListener('timeline-auto-scroll', handleAutoScroll as EventListener)
// 清理定时器
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
if (dragDelayTimer.value) {
clearTimeout(dragDelayTimer.value)
dragDelayTimer.value = null
}
// 🔧 修复:只有在非交互状态下才触发 Timeline 拖拽
// 如果刚刚完成拖拽/resize不应该启动 Timeline 拖拽
if (!wasInteracting && !justFinishedDragOrResize.value) {
// 触发自定义事件通知Timeline启动拖拽滚动
window.dispatchEvent(
new CustomEvent('taskbar-highlighted', {
detail: {
taskId: props.task.id,
},
}),
)
}
}
},
)
// 单击/双击延迟处理
let clickTimer: number | null = null
const clickDelay = 250 // 延迟时间(毫秒)
// 处理TaskBar单击事件使用延迟以区分双击
const handleTaskBarClick = (e: MouseEvent) => {
// 如果正在拖拽或调整大小,或者刚刚完成拖拽/调整大小,不触发点击事件
if (
isDragging.value ||
isResizingLeft.value ||
isResizingRight.value ||
justFinishedDragOrResize.value
) {
return
}
// 如果刚刚触发了长按,不触发点击事件
if (longPressTriggered.value) {
longPressTriggered.value = false // 重置标记
return
}
// 如果已有定时器,说明是双击的第二次点击,清除定时器不触发单击
if (clickTimer !== null) {
return
}
// 设置延迟触发单击事件
clickTimer = window.setTimeout(() => {
clickTimer = null
// 优先调用外部传入的单击处理器
if (props.onClick && typeof props.onClick === 'function') {
props.onClick(props.task, e)
} else {
// 默认行为:发出单击事件给父组件
emit('click', props.task, e)
}
}, clickDelay)
}
// 处理TaskBar双击事件
const handleTaskBarDoubleClick = (e: MouseEvent) => {
// 阻止事件冒泡,避免触发拖拽等其他事件
e.stopPropagation()
e.preventDefault()
// 清除单击定时器,防止触发单击事件
if (clickTimer !== null) {
clearTimeout(clickTimer)
clickTimer = null
}
// 清理任何可能残留的拖拽状态和临时数据
isDragging.value = false
isResizingLeft.value = false
isResizingRight.value = false
tempTaskData.value = null
// 移除可能残留的事件监听器
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
// 发出双击事件给父组件
emit('dblclick', props.task, e)
}
// 计算粘性样式 - 支持左右边界的精细控制
const stickyStyles = computed(() => {
const scrollLeft = props.scrollLeft || 0
const containerWidth = props.containerWidth || 0
if (!scrollLeft && !containerWidth) {
return {
nameLeft: '',
namePosition: '',
nameTop: '',
progressLeft: '',
progressPosition: '',
progressTop: '',
avatarLeft: '',
avatarPosition: '',
}
}
const taskLeft = parseInt(taskBarStyle.value.left)
const taskWidth = parseInt(taskBarStyle.value.width)
const taskRight = taskLeft + taskWidth
const taskCenterX = taskLeft + taskWidth / 2
const leftBoundary = scrollLeft
const rightBoundary = scrollLeft + containerWidth
// 默认样式
let nameLeft = ''
let namePosition = ''
let nameTop = ''
let progressLeft = ''
let progressPosition = ''
let progressTop = ''
let avatarLeft = ''
let avatarPosition = ''
// 估算文字内容的实际位置
const nameWidth = Math.max(nameTextWidth.value, 40) // 最小40px
const progressWidth = 35
const singleAvatarWidth = 22 // 单个avatar 宽度
// 计算实际avatar总宽度多个头像时会重叠每个头像露出18px
const actualAvatarCount = avatarList.value.length
const avatarTotalWidth = actualAvatarCount > 0
? (actualAvatarCount === 1 ? singleAvatarWidth : singleAvatarWidth + (actualAvatarCount - 1) * 18)
: 0
const handleWidth = actualHandleWidth.value // 拉伸手柄宽度
// === 第一步:检测 Avatar 是否需要粘性定位 ===
const avatarDefaultLeft = handleWidth + 3 // 手柄宽度 + 3px 间距
const avatarLeftPos = taskLeft + avatarDefaultLeft
const avatarRightPos = taskLeft + avatarDefaultLeft + avatarTotalWidth
// Avatar 左侧粘性逻辑
const avatarNeedsLeftSticky =
(avatarLeftPos < leftBoundary && taskRight > leftBoundary) ||
(taskCenterX < leftBoundary && taskRight > leftBoundary)
// Avatar 右侧粘性逻辑:当 avatar 接近右边框 + name/progress 宽度 + 15px 时触发
const maxContentWidth = Math.max(nameWidth, progressWidth)
const avatarNeedsRightSticky =
(avatarRightPos + maxContentWidth + 15 > rightBoundary && taskLeft < rightBoundary)
// 计算 avatar 粘性时的偏移量
let avatarStickyOffset = 0
if (avatarNeedsLeftSticky) {
const offset = leftBoundary - taskLeft
avatarLeft = `${offset + handleWidth + 3}px` // 手柄宽度 + 3px 间距
avatarPosition = 'absolute'
avatarStickyOffset = avatarTotalWidth + 8 // avatar 总宽度 + 右侧间距
} else if (avatarNeedsRightSticky) {
// avatar 应该停靠在 name/progress 左侧 15px 的位置
// 计算 name/progress 在右边界时的位置(它们会贴在右边框上)
const maxContentWidth = Math.max(nameWidth, progressWidth)
// 内容贴右边框时的左侧位置,考虑手柄宽度
const contentRightPos = rightBoundary - taskLeft - maxContentWidth - handleWidth - 3
const offset = contentRightPos - avatarTotalWidth - 15 // avatar 在内容左侧 15px
avatarLeft = `${offset}px`
avatarPosition = 'absolute'
avatarStickyOffset = -(avatarTotalWidth + 15) // 负值表示在右侧avatar总宽度 + 15px间距
}
// === 第二步:处理名称粘性定位(考虑 avatar 偏移) ===
const nameLeftPos = taskCenterX - nameWidth / 2
const nameRightPos = taskCenterX + nameWidth / 2
// 判断是否显示 avatar只要配置开启就显示包括默认头像
const hasAvatar = barConfig.value.showAvatar
// Name 左侧磁吸触发条件优化
let nameNeedsLeftSticky = false
if (hasAvatar) {
// 如果 avatar 存在:当 name 左侧接近左边界(预留 avatar+间距 的空间)时触发
// name 默认居中,当它向左移动到需要为 avatar 留出空间时触发磁吸
const nameActualLeft = nameLeftPos // name 实际左侧位置
const avatarReservedSpace = avatarTotalWidth + 15 // avatar 需要的空间(实际总宽度 + 间距)
nameNeedsLeftSticky =
nameActualLeft < leftBoundary + avatarReservedSpace && taskRight > leftBoundary
} else {
// 如果 avatar 不存在:保留原逻辑
nameNeedsLeftSticky =
nameLeftPos < leftBoundary && taskRight > leftBoundary && taskCenterX < leftBoundary
}
const nameNeedsRightSticky =
nameRightPos > rightBoundary && taskLeft < rightBoundary && taskCenterX > rightBoundary
if (nameNeedsLeftSticky) {
const offset = leftBoundary - taskLeft
// 如果 avatar 也在左侧粘性,则 title 需要在 avatar 右侧
// 使用avatarStickyOffset已经包含avatar总宽度 + 8px间距
const extraOffset = avatarNeedsLeftSticky ? avatarStickyOffset : 0
nameLeft = `${offset + handleWidth + 3 + extraOffset}px` // 考虑手柄宽度 + 间距
namePosition = 'absolute'
nameTop = '2px'
} else if (nameNeedsRightSticky) {
const offset = rightBoundary - taskLeft - nameWidth
// name 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
nameLeft = `${offset - handleWidth - 10}px` // 考虑手柄宽度 + 间距
namePosition = 'absolute'
nameTop = '2px'
}
// === 第三步:处理进度粘性定位(考虑 avatar 偏移) ===
const progressLeftPos = taskCenterX - progressWidth / 2
const progressRightPos = taskCenterX + progressWidth / 2
// Progress 左侧磁吸触发条件优化
let progressNeedsLeftSticky = false
if (hasAvatar) {
// 如果 avatar 存在:当 progress 左侧接近左边界(预留 avatar+间距 的空间)时触发
// progress 默认居中,当它向左移动到需要为 avatar 留出空间时触发磁吸
const progressActualLeft = progressLeftPos // progress 实际左侧位置
const avatarReservedSpace = avatarTotalWidth + 15 // avatar 需要的空间(实际总宽度 + 间距)
progressNeedsLeftSticky =
progressActualLeft < leftBoundary + avatarReservedSpace && taskRight > leftBoundary
} else {
// 如果 avatar 不存在:保留原逻辑
progressNeedsLeftSticky =
progressLeftPos < leftBoundary && taskRight > leftBoundary && taskCenterX < leftBoundary
}
const progressNeedsRightSticky =
progressRightPos > rightBoundary && taskLeft < rightBoundary && taskCenterX > rightBoundary
if (progressNeedsLeftSticky) {
const offset = leftBoundary - taskLeft
// 如果 avatar 也在左侧粘性,则进度需要在 avatar 右侧
// 使用avatarStickyOffset已经包含avatar总宽度 + 8px间距
const extraOffset = avatarNeedsLeftSticky ? avatarStickyOffset : 0
progressLeft = `${offset + handleWidth + 3 + extraOffset}px` // 考虑手柄宽度 + 间距
progressPosition = 'absolute'
progressTop = '18px'
} else if (progressNeedsRightSticky) {
const offset = rightBoundary - taskLeft - progressWidth
// 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
progressLeft = `${offset - handleWidth - 3}px` // 考虑手柄宽度 + 间距
progressPosition = 'absolute'
progressTop = '18px'
}
return {
nameLeft,
namePosition,
nameTop,
progressLeft,
progressPosition,
progressTop,
avatarLeft,
avatarPosition,
}
})
// 计算气泡指示器的显示状态和位置
const bubbleIndicator = computed(() => {
const scrollLeft = props.scrollLeft || 0
const containerWidth = props.containerWidth || 0
// 如果没有有效的滚动信息,不显示气泡
if (!containerWidth || containerWidth <= 0) {
return {
show: false,
left: '0px',
side: 'left',
color: '#409eff',
animationType: 'none',
}
}
// 如果正在初始化、强制隐藏状态或外部要求隐藏,不显示气泡
if (isInitializing.value || bubbleHidden.value || props.hideBubbles) {
return {
show: false,
left: '0px',
side: 'left',
color: taskStatus.value.color,
animationType: 'none',
}
}
// 获取实际的DOM位置考虑缩放等因素
const taskLeft = parseInt(taskBarStyle.value.left)
const taskWidth = parseInt(taskBarStyle.value.width)
const taskRight = taskLeft + taskWidth
const leftBoundary = scrollLeft
const rightBoundary = scrollLeft + containerWidth
// 检查边界状态
const isCompletelyOutOfLeft = taskRight <= leftBoundary
const isCompletelyOutOfRight = taskLeft >= rightBoundary
// 只有完全超出边界时才显示半圆
if (isCompletelyOutOfLeft) {
return {
show: true,
left: `${leftBoundary - taskLeft - 4}px`, // 圆心在边界上,向左偏移半径(4px)
side: 'left',
color: taskStatus.value.color,
animationType: 'morphToSemiCircle',
}
}
if (isCompletelyOutOfRight) {
return {
show: true,
left: `${rightBoundary - taskLeft - 8}px`, // 右侧半圆位置调整,减少偏移量
side: 'right',
color: taskStatus.value.color,
animationType: 'morphToSemiCircle',
}
}
// 部分可见或完全可见时不显示
return {
show: false,
left: '0px',
side: 'left',
color: taskStatus.value.color,
animationType: 'none',
}
})
// 气泡 tooltip 状态
const showTooltip = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 })
// TaskBar 悬停 tooltip 延迟定时器(状态已上移至 Timeline
let hoverTooltipTimer: number | null = null
// 跟踪滚动状态,避免非滚动时的动画
const isScrollingContext = ref(false)
const scrollTimeout = ref<number | null>(null)
const hasManualResize = ref(false) // 跟踪是否有手动resize事件
const isInitializing = ref(true) // 跟踪初始化状态
const isAutoScrolling = ref(false) // 跟踪自动滚动状态(如定位到今日、点击半圆定位等)
const bubbleHidden = ref(false) // 控制半圆的强制隐藏状态
// 气泡点击事件 - 将TaskBar定位到Timeline中间
const handleBubbleClick = (e: MouseEvent) => {
// 阻止事件冒泡,避免触发 TaskBar 的 click 事件
e.stopPropagation()
e.preventDefault()
const scrollLeft = props.scrollLeft || 0
const containerWidth = props.containerWidth || 0
if (!scrollLeft && !containerWidth) return
const taskLeft = parseInt(taskBarStyle.value.left)
const taskWidth = parseInt(taskBarStyle.value.width)
const taskCenterX = taskLeft + taskWidth / 2
// 计算将TaskBar中心定位到Timeline中心所需的滚动位置
const targetScrollLeft = taskCenterX - containerWidth / 2
// 标记为自动滚动状态,隐藏所有半圆
isAutoScrolling.value = true
bubbleHidden.value = true
// 立即隐藏tooltip
showTooltip.value = false
// 通过事件向Timeline发送滚动请求
emit('scroll-to-position', targetScrollLeft)
}
// 监听滚动相关的props变化判断是否在滚动
watch(
() => [props.scrollLeft, props.containerWidth],
(newValues, oldValues) => {
// 检查是否是真实的滚动变化而非初始化或resize
const [newScrollLeft, newContainerWidth] = newValues
const [oldScrollLeft, oldContainerWidth] = oldValues || [0, 0]
// 确保数值有效
const safeNewScrollLeft = newScrollLeft || 0
const safeNewContainerWidth = newContainerWidth || 0
const safeOldScrollLeft = oldScrollLeft || 0
const safeOldContainerWidth = oldContainerWidth || 0
// 如果容器宽度发生变化包括Splitter拖拽、TaskList展开收起、窗口resize等
if (Math.abs(safeNewContainerWidth - safeOldContainerWidth) > 1 && safeOldContainerWidth > 0) {
hasManualResize.value = true
// 容器宽度变化时,强制重新计算气泡显示状态
// 立即触发bubbleIndicator重新计算
nextTick(() => {
// computed会自动重新计算
})
// 🔥 容器宽度变化时,标记初始化完成(修复 splitter 拖拽后半圆不显示的问题)
if (isInitializing.value) {
setTimeout(() => {
isInitializing.value = false
}, 300)
}
// 延长禁用动画的时间确保各种resize操作稳定
setTimeout(() => {
hasManualResize.value = false
}, 500) // 给容器变化留足够时间稳定
return
}
// 首次接收到有效的滚动数据,标记初始化完成
if (isInitializing.value && safeNewScrollLeft >= 0 && safeNewContainerWidth > 0) {
// 延迟标记初始化完成,等待初始滚动动画结束
setTimeout(() => {
isInitializing.value = false
}, 1000) // 给初始化滚动留足够时间
}
// 只有在scrollLeft变化且没有resize时才认为是滚动
if (safeNewScrollLeft !== safeOldScrollLeft && !hasManualResize.value) {
isScrollingContext.value = true
// 清除之前的超时
if (scrollTimeout.value) {
clearTimeout(scrollTimeout.value)
}
// 滚动结束后的处理
scrollTimeout.value = setTimeout(() => {
isScrollingContext.value = false
// 如果是自动滚动结束,恢复半圆显示
if (isAutoScrolling.value) {
isAutoScrolling.value = false
// 延迟一点时间再显示半圆,确保滚动完全停止
setTimeout(() => {
bubbleHidden.value = false
}, 300)
}
}, 200)
}
},
{ immediate: true },
)
// 监听外部hideBubbles属性变化确保Timeline的容器变化能及时反应
watch(
() => props.hideBubbles,
(newHidden, oldHidden) => {
// 当Timeline设置hideBubbles从true变为false时强制重新计算半圆状态
if (oldHidden && !newHidden) {
nextTick(() => {
// 强制重新计算bubbleIndicator确保容器宽度变化后正确显示半圆
})
}
},
)
// 监听TaskBar可见性变化只在滚动时实现重新出现动画
watch(
() => bubbleIndicator.value.show,
() => {
// TaskBar重新出现时不需要动画效果
// 半圆会自然消失TaskBar会立即显示
},
)
// 监听页面缩放和大小变化,重新计算气泡位置
const handleResize = () => {
// timeline区域resize时立即重新计算半圆显示状态
hasManualResize.value = true
// 强制重新计算bubbleIndicator
nextTick(() => {
// computed会自动重新计算
})
// 短时间后恢复正常状态,允许动画
setTimeout(() => {
hasManualResize.value = false
}, 300) // 缩短时间,快速恢复
}
onMounted(() => {
// 监听窗口大小变化和缩放
window.addEventListener('resize', handleResize)
window.addEventListener('zoom', handleResize) // 某些浏览器支持
})
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
window.removeEventListener('resize', handleResize)
window.removeEventListener('zoom', handleResize)
})
// 处理气泡悬停
const handleBubbleMouseEnter = (event: MouseEvent) => {
// 阻止事件冒泡
event.stopPropagation()
// v1.9.1 隐藏TaskBar的hover tooltip只显示bubble tooltip
emit('tooltip-hide')
// 清除可能正在等待的tooltip定时器
if (hoverTooltipTimer) {
window.clearTimeout(hoverTooltipTimer)
hoverTooltipTimer = null
}
showTooltip.value = true
// v1.9.6 智能定位强制tooltip显示在.gantt-panel-right容器内
const isRightBubble = bubbleIndicator.value.side === 'right'
const tooltipWidth = 250 // tooltip预估宽度
const tooltipHeight = 200 // tooltip预估高度
const padding = 10 // 与边界的安全距离
// 获取.gantt-panel-right容器的边界
const timelineContainer = document.querySelector('.gantt-panel-right')
const containerRect = timelineContainer?.getBoundingClientRect()
let x = event.clientX
let y = event.clientY - 10
if (containerRect) {
// 计算默认偏移后的x坐标
const defaultOffsetX = isRightBubble ? -180 : 15
let tentativeX = x + defaultOffsetX
// 强制约束确保tooltip左边界不超出容器左边界
const minX = containerRect.left + padding
const maxX = containerRect.right - tooltipWidth - padding
if (tentativeX < minX) {
// 如果超出左边界,贴左边界显示
tentativeX = minX
} else if (tentativeX > maxX) {
// 如果超出右边界,贴右边界显示
tentativeX = maxX
}
// 如果容器宽度不够,优先保证左边界
if (tentativeX < minX) {
tentativeX = minX
}
x = tentativeX
// 垂直方向边界检测
const minY = containerRect.top + padding
const maxY = containerRect.bottom - tooltipHeight - padding
if (y < minY) {
y = minY
} else if (y > maxY) {
y = maxY
}
} else {
// 如果找不到容器,使用默认偏移
const offsetX = isRightBubble ? -180 : 15
x = x + offsetX
}
tooltipPosition.value = { x, y }
}
const handleBubbleMouseLeave = (event: MouseEvent) => {
// 阻止事件冒泡
event.stopPropagation()
showTooltip.value = false
}
// 处理气泡点击事件 - 点击时隐藏tooltip但不影响定位功能
const handleBubbleMouseDown = (event: MouseEvent) => {
// 阻止mousedown事件冒泡防止影响其他功能
event.stopPropagation()
// 点击时隐藏tooltip
showTooltip.value = false
}
// 处理TaskBar悬停事件
const handleTaskBarMouseEnter = (event: MouseEvent) => {
isTaskBarHovered.value = true
// v1.9.2 如果 tab 正在悬停,不显示 TaskBar 的 tooltip
if (isTabHovered.value) {
return
}
// 如果启用了TaskBar Tooltip父级任务也显示tooltip
// 但在拖拽或拉伸时不显示tooltip
if (props.enableTaskBarTooltip !== false && !isDragging.value && !isResizingLeft.value && !isResizingRight.value) {
// 保存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避免快速滑过时显示
// Singleton TooltipTimer后 emit tooltip-show由 Timeline 统一计算位置和渲染
hoverTooltipTimer = window.setTimeout(() => {
const rect = targetElement.getBoundingClientRect()
emit('tooltip-show', {
task: props.task,
taskStatus: taskStatus.value,
resourcePercent: resourcePercent.value,
hasResourceConflict: props.hasResourceConflict ?? false,
targetRect: rect,
})
}, 300) // 300ms延迟
}
}
const handleTaskBarMouseLeave = () => {
isTaskBarHovered.value = false
// 清除定时器并通知 Timeline 隐藏 tooltip
if (hoverTooltipTimer) {
clearTimeout(hoverTooltipTimer)
hoverTooltipTimer = null
}
emit('tooltip-hide')
}
// 监听拖拽/拉伸状态,如果开始拖拽/拉伸立即隐藏tooltip
// v1.9.7 使用 flush: 'sync' 确保状态变化时立即同步执行避免在资源视图拖拽时因组件更新导致watch延迟执行
watch([isDragging, isResizingLeft, isResizingRight], ([dragging, resizingL, resizingR]) => {
if (dragging || resizingL || resizingR) {
emit('tooltip-hide')
if (hoverTooltipTimer) {
clearTimeout(hoverTooltipTimer)
hoverTooltipTimer = null
}
}
// v1.9.2 同步拖拽状态到Timeline用于冲突检测优化
const isDraggingOrResizing = dragging || resizingL || resizingR
if (timelineIsDraggingTaskBar.value !== isDraggingOrResizing) {
timelineIsDraggingTaskBar.value = isDraggingOrResizing
}
}, {
flush: 'sync', // 同步执行,确保状态重置时立即触发
})
// v1.9.2 监听 Tab 悬停状态,当 Tab 悬停时立即隐藏 TaskBar 的 tooltip
watch(isTabHovered, (tabHovered) => {
if (tabHovered) {
// Tab 悬停:隐藏 TaskBar 的 tooltip通知 Timeline
emit('tooltip-hide')
if (hoverTooltipTimer) {
clearTimeout(hoverTooltipTimer)
hoverTooltipTimer = null
}
}
// Tab 离开后不主动重显 tooltip等待用户下次 mouseenter 触发
})
// 格式化日期显示
const formatDisplayDate = (dateStr: string | undefined): string => {
if (!dateStr) return t('dateNotSet')
const date = createLocalDate(dateStr)
if (!date) return t('dateNotSet')
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 计算工时信息
const workHourInfo = computed(() => {
// 这里可以根据实际的数据结构调整
const startDate = createLocalDate(props.task.startDate)
const endDate = createLocalDate(props.task.endDate)
let totalHours = 0
if (startDate && endDate) {
const diffTime = Math.abs(endDate.getTime() - startDate.getTime())
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
totalHours = diffDays * 8 // 假设每天8小时
}
const progress = props.task.progress || 0
const usedHours = Math.round((totalHours * progress) / 100)
return {
total: totalHours,
used: usedHours,
}
})
// 判断是否应该显示进度百分比
const shouldShowProgress = computed(() => {
// 任何情况下都显示完成率包括0%和undefined确保始终展示
return true
})
// Helper functions to create type-safe style objects
const getNameStyles = () => {
const styles = stickyStyles.value
const result: Record<string, string> = {}
if (styles.nameLeft) result.left = styles.nameLeft
if (styles.namePosition) result.position = styles.namePosition
if (styles.nameTop) result.top = styles.nameTop
return result
}
const getProgressStyles = () => {
const styles = stickyStyles.value
const result: Record<string, string> = {}
if (styles.progressLeft) result.left = styles.progressLeft
if (styles.progressPosition) result.position = styles.progressPosition
if (styles.progressTop) result.top = styles.progressTop
return result
}
const getAvatarStyles = () => {
const styles = stickyStyles.value
const result: Record<string, string> = {}
if (styles.avatarLeft) result.left = styles.avatarLeft
if (styles.avatarPosition) result.position = styles.avatarPosition
return result
}
// 计算 avatar 是否应该渲染在外框边缘
const shouldRenderAvatarOutside = computed(() => {
const taskWidth = parseInt(taskBarStyle.value.width)
const avatarWidth = 22
return taskWidth - 10 < avatarWidth
})
// 计算 resize-handle 的样式
const resizeHandleStyle = computed(() => {
const width = Math.min(barConfig.value.resizeHandleWidth ?? 5, 15) // 最大15px
return {
width: `${width}px`,
}
})
// 计算实际手柄宽度(用于位置计算)
const actualHandleWidth = computed(() => {
return Math.min(barConfig.value.resizeHandleWidth ?? 5, 15)
})
// 基于timelineData和subDays精确计算日期位置的函数
const calculatePositionFromTimelineData = (
targetDate: Date,
timelineData: Array<{
year: number
month: number
startDate: Date
endDate: Date
days?: Array<{ date: Date; day: number }>
subDays?: Array<{ date: Date; dayOfWeek?: number }>
monthData?: { dayCount: number }
weeks?: Array<{
weekStart: Date
weekEnd: Date
subDays: Array<{ date: Date; dayOfWeek?: number }>
}>
}>,
timeScale: TimelineScale,
) => {
let cumulativePosition = 0
for (const periodData of timelineData) {
if (timeScale === TimelineScale.DAY) {
// 日视图处理days数组
const days = periodData.days || []
for (let i = 0; i < days.length; i++) {
const dayData = days[i]
const dayDate = new Date(dayData.date)
// 比较日期(忽略时分秒)
if (
dayDate.getFullYear() === targetDate.getFullYear() &&
dayDate.getMonth() === targetDate.getMonth() &&
dayDate.getDate() === targetDate.getDate()
) {
// 找到目标日期,返回累计位置 + 当前天数索引 * 日宽度
return cumulativePosition + i * 30 // 日视图每天30px
}
}
// 累加当前月份所有天数的宽度
cumulativePosition += days.length * 30
} else if (timeScale === TimelineScale.QUARTER) {
// 季度视图处理years数组每个year包含quarters
const quarters = (periodData as any).quarters || []
for (const quarter of quarters) {
const quarterStart = new Date(quarter.startDate)
const quarterEnd = new Date(quarter.endDate)
if (targetDate >= quarterStart && targetDate <= quarterEnd) {
// 找到目标日期所在的季度
const quarterWidth = 60
const daysInQuarter = Math.ceil(
(quarterEnd.getTime() - quarterStart.getTime()) / (1000 * 60 * 60 * 24),
)
const dayWidth = quarterWidth / daysInQuarter
const dayInQuarter = Math.ceil(
(targetDate.getTime() - quarterStart.getTime()) / (1000 * 60 * 60 * 24),
)
return cumulativePosition + dayInQuarter * dayWidth
}
// 累加每季度的宽度
cumulativePosition += 60
}
} else if (timeScale === TimelineScale.WEEK) {
// 周视图处理嵌套的weeks结构
const weeks = periodData.weeks || []
for (const week of weeks) {
const weekStart = new Date(week.weekStart)
const weekEnd = new Date(week.weekEnd)
if (targetDate >= weekStart && targetDate <= weekEnd) {
// 找到目标日期所在的周
const weekWidth = 60
const subDays = week.subDays || []
const dayWidth = weekWidth / 7
// 在subDays中查找目标日期的位置
for (let i = 0; i < subDays.length; i++) {
const subDay = subDays[i]
const subDayDate = new Date(subDay.date)
// 比较日期(忽略时分秒)
if (
subDayDate.getFullYear() === targetDate.getFullYear() &&
subDayDate.getMonth() === targetDate.getMonth() &&
subDayDate.getDate() === targetDate.getDate()
) {
return cumulativePosition + i * dayWidth
}
}
// 如果没找到精确匹配回退到dayOfWeek计算
const dayOfWeek = targetDate.getDay()
return cumulativePosition + dayOfWeek * dayWidth
}
// 累加每周的宽度
cumulativePosition += 60
}
} else if (timeScale === TimelineScale.MONTH) {
// 月视图处理扁平化的subDays结构
const periodStart = new Date(periodData.startDate)
const periodEnd = new Date(periodData.endDate)
if (targetDate >= periodStart && targetDate <= periodEnd) {
// 找到目标日期所在的时间段
const monthWidth = 60
const daysInMonth = periodData.monthData?.dayCount || 30
const dayWidth = monthWidth / daysInMonth
const dayInMonth = targetDate.getDate()
return cumulativePosition + (dayInMonth - 1) * dayWidth
}
// 累加每月的宽度
cumulativePosition += 60
} else if (timeScale === TimelineScale.YEAR) {
// 年度视图处理years数组每个year包含halfYears
const halfYears = (periodData as any).halfYears || []
for (const halfYear of halfYears) {
const halfYearStart = new Date(halfYear.startDate)
const halfYearEnd = new Date(halfYear.endDate)
if (targetDate >= halfYearStart && targetDate <= halfYearEnd) {
// 找到目标日期所在的半年
const halfYearWidth = 180 // 年度视图每半年180px
const daysInHalfYear = Math.ceil(
(halfYearEnd.getTime() - halfYearStart.getTime()) / (1000 * 60 * 60 * 24),
)
const dayWidth = halfYearWidth / daysInHalfYear
const dayInHalfYear = Math.ceil(
(targetDate.getTime() - halfYearStart.getTime()) / (1000 * 60 * 60 * 24),
)
return cumulativePosition + dayInHalfYear * dayWidth
}
// 累加每半年的宽度
cumulativePosition += 180
}
}
}
// 如果在timelineData中没找到使用数学计算作为后备方案
// 这对于VirtualTimelineManager场景特别重要当任务日期在未加载的chunk中时
if (timelineData.length > 0) {
const firstPeriod = timelineData[0]
const lastPeriod = timelineData[timelineData.length - 1]
const timelineStart = new Date(firstPeriod.startDate)
const timelineEnd = new Date(lastPeriod.endDate)
// 如果目标日期在时间轴之前
if (targetDate < timelineStart) {
const daysBefore = Math.ceil((timelineStart.getTime() - targetDate.getTime()) / (1000 * 60 * 60 * 24))
const dayWidth = timeScale === TimelineScale.DAY ? 30 : timeScale === TimelineScale.WEEK ? 60 / 7 : 2
return -daysBefore * dayWidth
}
// 如果目标日期在时间轴之后,基于最后可用的位置计算
if (targetDate > timelineEnd) {
const daysAfter = Math.ceil((targetDate.getTime() - timelineEnd.getTime()) / (1000 * 60 * 60 * 24))
const dayWidth = timeScale === TimelineScale.DAY ? 30 : timeScale === TimelineScale.WEEK ? 60 / 7 : 2
return cumulativePosition + daysAfter * dayWidth
}
}
return cumulativePosition
}
// 反向函数:从像素位置计算日期(基于 timelineData
const calculateDateFromPosition = (
pixelPosition: number,
timelineData: Array<{
year: number
month: number
startDate: Date
endDate: Date
days?: Array<{ date: Date; day: number }>
monthData?: { dayCount: number }
}>,
timeScale: TimelineScale,
): Date | null => {
if (!timelineData) {
return null
}
let cumulativePosition = 0
if (timeScale === TimelineScale.DAY) {
// 日视图:基于 days 数组
for (const periodData of timelineData) {
const days = periodData.days || []
const periodWidth = days.length * 30 // 日视图每天30px
// 检查像素位置是否在当前时间段内
if (pixelPosition >= cumulativePosition && pixelPosition < cumulativePosition + periodWidth) {
// 计算在当前时间段内的相对位置
const relativePosition = pixelPosition - cumulativePosition
const dayIndex = Math.floor(relativePosition / 30)
// 确保索引在范围内
if (dayIndex >= 0 && dayIndex < days.length) {
return new Date(days[dayIndex].date)
}
}
cumulativePosition += periodWidth
}
} else if (timeScale === TimelineScale.MONTH) {
// 月视图每个月60px
for (const periodData of timelineData) {
const monthWidth = 60
// 检查像素位置是否在当前月份内
if (pixelPosition >= cumulativePosition && pixelPosition < cumulativePosition + monthWidth) {
// 计算在当前月份内的相对位置
const relativePosition = pixelPosition - cumulativePosition
const daysInMonth = periodData.monthData?.dayCount || 30
const dayWidth = monthWidth / daysInMonth
// 计算是该月的第几天
const dayIndex = Math.floor(relativePosition / dayWidth)
const day = Math.min(dayIndex + 1, daysInMonth) // 1-based确保不超过该月天数
return new Date(periodData.year, periodData.month - 1, day)
}
cumulativePosition += monthWidth
}
} else if (timeScale === TimelineScale.QUARTER) {
// 季度视图每个季度60px
for (const periodData of timelineData) {
const quarters = (periodData as any).quarters || []
for (const quarter of quarters) {
const quarterStart = new Date(quarter.startDate)
const quarterEnd = new Date(quarter.endDate)
const quarterWidth = 60
// 检查像素位置是否在当前季度内
if (
pixelPosition >= cumulativePosition &&
pixelPosition < cumulativePosition + quarterWidth
) {
// 计算在当前季度内的相对位置
const relativePosition = pixelPosition - cumulativePosition
const daysInQuarter = Math.ceil(
(quarterEnd.getTime() - quarterStart.getTime()) / (1000 * 60 * 60 * 24),
)
const dayWidth = quarterWidth / daysInQuarter
// 计算是该季度的第几天
const dayIndex = Math.floor(relativePosition / dayWidth)
// 从季度开始日期加上天数
const resultDate = new Date(quarterStart)
resultDate.setDate(resultDate.getDate() + dayIndex)
return resultDate
}
cumulativePosition += quarterWidth
}
}
} else if (timeScale === TimelineScale.YEAR) {
// 年度视图每半年180px
for (const periodData of timelineData) {
const halfYears = (periodData as any).halfYears || []
for (const halfYear of halfYears) {
const halfYearStart = new Date(halfYear.startDate)
const halfYearEnd = new Date(halfYear.endDate)
const halfYearWidth = 180
// 检查像素位置是否在当前半年内
if (
pixelPosition >= cumulativePosition &&
pixelPosition < cumulativePosition + halfYearWidth
) {
// 计算在当前半年内的相对位置
const relativePosition = pixelPosition - cumulativePosition
const daysInHalfYear = Math.ceil(
(halfYearEnd.getTime() - halfYearStart.getTime()) / (1000 * 60 * 60 * 24),
)
const dayWidth = halfYearWidth / daysInHalfYear
// 计算是该半年的第几天
const dayIndex = Math.floor(relativePosition / dayWidth)
// 从半年开始日期加上天数
const resultDate = new Date(halfYearStart)
resultDate.setDate(resultDate.getDate() + dayIndex)
return resultDate
}
cumulativePosition += halfYearWidth
}
}
}
return null // 如果没找到,返回 null
}
// 处理右键菜单
const contextMenuVisible = ref(false)
const contextMenuPosition = ref({ x: 0, y: 0 })
const contextMenuTask = computed(() => props.task)
function handleContextMenu(event: MouseEvent) {
// 如果不应该显示任何菜单,直接阻止
if (!shouldShowAnyContextMenu.value) {
event.preventDefault()
return
}
// 高亮模式下禁用右键菜单
if (props.isHighlighted || props.isPrimaryHighlight) {
event.preventDefault()
return
}
// 先广播关闭所有TaskBar菜单
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
const taskType = props.task.type || 'task'
if (taskType !== 'task' && taskType !== 'story') {
// 为了排除里程碑类型
event.preventDefault()
contextMenuVisible.value = false
return
}
event.preventDefault()
contextMenuVisible.value = true
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
}
function closeContextMenu() {
contextMenuVisible.value = false
}
// 处理文档点击事件,点击菜单外部时关闭
function handleDocumentClick(event: MouseEvent) {
if (!contextMenuVisible.value) return
const target = event.target as HTMLElement
// 检查点击是否在右键菜单内部
const contextMenuElement = document.querySelector('.task-context-menu')
if (contextMenuElement && contextMenuElement.contains(target)) {
return
}
closeContextMenu()
}
const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
// 触发删除事件
emit('delete', task, deleteChildren)
closeContextMenu()
}
// 处理删除链接事件
const handleDeleteLink = (event: { sourceTaskId: number; targetTaskId: number }) => {
emit('delete-link', event)
closeContextMenu()
}
// 连接线触点事件处理
const handleLinkDragStart = (event: { task: Task; type: 'predecessor' | 'successor'; mouseEvent: MouseEvent }) => {
emit('link-drag-start', event)
}
const handleLinkDragMove = (event: { mouseX: number; mouseY: number }) => {
emit('link-drag-move', event)
}
const handleLinkDragEnd = (event: { task: Task; type: 'predecessor' | 'successor' }) => {
emit('link-drag-end', event)
}
// 处理 LinkAnchor 的 drag-start 事件(转换为统一格式)
const handleAnchorDragStart = (anchorEvent: { taskId: number; type: 'predecessor' | 'successor'; x: number; y: number }) => {
const mouseEvent = {
clientX: anchorEvent.x,
clientY: anchorEvent.y,
} as MouseEvent
handleLinkDragStart({
task: props.task,
type: anchorEvent.type,
mouseEvent,
})
}
// 处理 LinkAnchor 的 drag-move 事件
const handleAnchorDragMove = (anchorEvent: { x: number; y: number }) => {
handleLinkDragMove({
mouseX: anchorEvent.x,
mouseY: anchorEvent.y,
})
}
// 处理 LinkAnchor 的 drag-end 事件
const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor' | 'successor' }) => {
handleLinkDragEnd({
task: props.task,
type: anchorEvent.type,
})
}
</script>
<template>
<!-- 根容器包裹所有非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>
<!-- 头像和标题放置在实际TaskBar尾部外面 -->
<div class="actual-bar-trailing">
<!-- 实际TaskBar的多头像容器 -->
<div
v-if="barConfig.showAvatar && avatarList.length > 0"
class="actual-avatars-container"
>
<div
v-for="(avatarItem, index) in avatarList"
:key="index"
class="actual-task-avatar"
:class="{ 'avatar-default': !avatarItem || (typeof avatarItem === 'object' && !avatarItem.isText && !avatarItem) }"
:style="{
zIndex: index + 1,
marginLeft: index > 0 ? '-8px' : '0'
}"
>
<!-- 文字头像从assignee生成 -->
<span v-if="avatarItem && typeof avatarItem === 'object' && avatarItem.isText" class="avatar-text">
{{ avatarItem.name.charAt(0).toUpperCase() }}
</span>
<!-- 图片头像 -->
<img v-else-if="avatarItem && typeof avatarItem === 'string'" :src="avatarItem" :alt="`avatar-${index}`" />
<!-- 默认灰色用户图标 -->
<svg v-else class="avatar-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/>
</svg>
</div>
</div>
<!-- 任务标题 - 支持HTML解析和自定义slot -->
<div
v-if="barConfig.showTitle"
class="actual-task-name-wrapper"
:style="{
fontSize: '12px',
color: taskStatus.color,
}"
>
<slot v-if="hasContentSlot" name="custom-task-content" v-bind="slotPayload" />
<div v-else class="actual-task-name" v-html="task.name"></div>
</div>
</div>
</div>
<!-- 计划进度条原有TaskBar -->
<div
v-if="shouldRenderTaskBar"
ref="barRef"
class="task-bar"
:data-task-id="task.id"
:style="{
...taskBarStyle,
backgroundColor: (showActualTaskbar && hasActualProgress && isTaskBarHovered) ? 'transparent' : taskStatus.bgColor,
borderColor: dynamicBorderColor,
...(viewMode === 'resource' && currentResourceId ? {
borderTopWidth: '2px',
borderTopStyle: 'solid',
borderTopColor: currentResourceColor,
} : {}),
color: taskStatus.color,
cursor: isCompleted || isParent ? 'default' : 'move',
'--row-height': `${rowHeight}px` /* 传递行高给CSS变量 */,
'--handle-width': `${actualHandleWidth}px` /* 传递手柄宽度给CSS变量 */,
'--parent-color': taskStatus.color, /* 传递父级TaskBar颜色给伪元素箭头使用 */
'--allocation-capacity': (Number.isFinite(resourcePercent) ? resourcePercent / 100 : 1), /* v1.9.1 传递占比给CSS变量 */
'--task-bar-bg-color': taskStatus.bgColor, /* v1.9.1 传递背景色给伪元素 */
'--task-bar-border-color': dynamicBorderColor, /* v1.9.2 使用动态边框颜色 */
boxShadow: isParent
? `0 4px 16px ${taskStatus.color}40, 0 2px 8px ${taskStatus.color}26` /* 父级任务也使用动态颜色阴影 */
: `0 4px 16px ${taskStatus.color}40, 0 2px 8px ${taskStatus.color}26`, /* 使用TaskBar颜色的阴影 - 加强版 */
}"
:class="{
dragging: isDragging,
resizing: isResizingLeft || isResizingRight,
completed: isCompleted,
'parent-task': isParent,
'week-view': isWeekView,
'short-task-bar': isShortTaskBar,
'overflow-effect': needsOverflowEffect,
highlighted: isHighlighted,
'primary-highlight': isPrimaryHighlight,
dimmed: isDimmed,
'has-actual': showActualTaskbar && hasActualProgress, /* 只有在showActualTaskbar=true时才标记有实际进度 */
'resource-conflict': props.hasResourceConflict, /* v1.9.0 资源冲突样式 */
'resource-view': viewMode === 'resource', /* v1.9.1 资源视图专属样式 */
'has-bubble': bubbleIndicator.show, /* v1.9.1 有气泡时提升层级确保气泡不被其他TaskBar遮挡 */
}"
@click="handleTaskBarClick"
@contextmenu="handleContextMenu"
@dblclick="handleTaskBarDoubleClick"
@mouseenter="handleTaskBarMouseEnter"
@mouseleave="handleTaskBarMouseLeave"
>
<!-- 父级任务的标题直接在内部居中显示 -->
<div v-if="isParent" class="parent-label-inner">
<slot v-if="hasContentSlot" name="custom-task-content" v-bind="slotPayload" />
<template v-else> {{ task.name }} ({{ task.progress || 0 }}%) </template>
</div>
<!-- 完成进度条非父级任务 -->
<div
v-if="!isParent && task.progress && task.progress > 0"
class="progress-bar"
:style="{
width: progressWidth,
backgroundColor: taskStatus.color,
}"
></div>
<!-- v1.9.2 资源视图Tab标签 -->
<!-- v1.9.5 可通过 show-taskbar-tab prop 控制是否显示 -->
<TaskBarTab
v-if="showTaskbarTab && viewMode === 'resource' && !isParent && currentResourceId"
:key="`tab-${task.id}-${currentResourceId}`"
:task="task"
:current-resource-id="currentResourceId"
:resource-color="currentResourceColor"
:resource-capacity="resourcePercent"
:resource-name="currentResourceName"
:task-bar-width="taskBarWidth"
:task-bar-left="taskBarLeft"
:scroll-left="scrollLeft || 0"
:container-width="containerWidth || 0"
:has-conflict="hasResourceConflict"
:conflict-tasks="conflictTasks"
:resources="resources"
@hover-change="isTabHovered = $event"
/>
<!-- 左侧调整把手 -->
<div
v-if="
!isCompleted &&
!isParent &&
!isInteractionDisabled &&
props.allowDragAndResize !== false &&
!isHighlighted &&
!isPrimaryHighlight
"
class="resize-handle resize-handle-left"
:style="resizeHandleStyle"
@mousedown="e => handleMouseDown(e, 'resize-left')"
></div>
<!-- 任务条主体(非父级任务) -->
<div
v-if="!isParent"
class="task-bar-content"
:style="{
cursor:
isInteractionDisabled ||
props.allowDragAndResize === false ||
isHighlighted ||
isPrimaryHighlight
? 'grab'
: 'move',
}"
@mousedown="
e => {
// 高亮状态下不处理让事件冒泡到Timeline
if (isHighlighted || isPrimaryHighlight) {
return
}
// 禁用交互时也不处理
if (isInteractionDisabled || props.allowDragAndResize === false) {
return
}
// 正常拖拽
handleMouseDown(e, 'drag')
}
"
>
<!-- 任务头像 - 有实际TaskBar时隐藏支持多头像 -->
<div
v-if="barConfig.showAvatar && !(showActualTaskbar && hasActualProgress) && avatarList.length > 0"
class="task-avatars-container"
:class="{ 'avatar-outside': shouldRenderAvatarOutside }"
:style="getAvatarStyles()"
>
<div
v-for="(avatarItem, index) in avatarList"
:key="index"
class="task-avatar"
:class="{ 'avatar-default': !avatarItem || (typeof avatarItem === 'object' && !avatarItem.isText && !avatarItem) }"
:style="{
zIndex: index + 1,
marginLeft: index > 0 ? '-8px' : '0'
}"
>
<!-- 文字头像从assignee生成 -->
<span v-if="avatarItem && typeof avatarItem === 'object' && avatarItem.isText" class="avatar-text">
{{ avatarItem.name.charAt(0).toUpperCase() }}
</span>
<!-- 图片头像 -->
<img v-else-if="avatarItem && typeof avatarItem === 'string'" :src="avatarItem" :alt="`avatar-${index}`" />
<!-- 默认灰色用户图标 -->
<svg v-else class="avatar-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/>
</svg>
</div>
</div>
<!-- 任务名称 - 有实际TaskBar时隐藏 -->
<div
v-if="barConfig.showTitle && !(showActualTaskbar && hasActualProgress)"
ref="taskBarNameRef"
:style="getNameStyles()"
>
<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-capacity-text">
{{ resourcePercent }}%
</span>-->
</div>
</div>
<!-- 进度百分比 - 有实际TaskBar时隐藏 -->
<div
v-if="barConfig.showProgress && shouldShowProgress && !(showActualTaskbar && hasActualProgress)"
class="task-progress"
:style="getProgressStyles()"
>
{{ task.progress || 0 }}%
</div>
</div>
<!-- 右侧调整把手 -->
<div
v-if="
!isCompleted &&
!isParent &&
!isInteractionDisabled &&
props.allowDragAndResize !== false &&
!isHighlighted &&
!isPrimaryHighlight
"
class="resize-handle resize-handle-right"
:style="resizeHandleStyle"
@mousedown="e => handleMouseDown(e, 'resize-right')"
></div>
<!-- 连接线触点 - 只在非高亮模式且非父级任务时显示 -->
<!-- 前置任务触点(左侧) -->
<LinkAnchor
v-if="enableLinkAnchor && !isParent && !isInHighlightMode"
type="predecessor"
:task-id="task.id"
:visible="isTaskBarHovered"
:is-drag-source="isLinkDragSource && dragLinkMode === 'predecessor'"
:is-drag-target="isValidLinkTarget || isInvalidLinkTarget"
:is-valid-target="isValidLinkTarget"
:global-dragging="!!dragLinkMode"
@drag-start="handleAnchorDragStart"
@drag-move="handleAnchorDragMove"
@drag-end="handleAnchorDragEnd"
/>
<!-- 后置任务触点(右侧) -->
<LinkAnchor
v-if="enableLinkAnchor && !isParent && !isInHighlightMode"
type="successor"
:task-id="task.id"
:visible="isTaskBarHovered"
:is-drag-source="isLinkDragSource && dragLinkMode === 'successor'"
:is-drag-target="isValidLinkTarget || isInvalidLinkTarget"
:is-valid-target="isValidLinkTarget"
:global-dragging="!!dragLinkMode"
@drag-start="handleAnchorDragStart"
@drag-move="handleAnchorDragMove"
@drag-end="handleAnchorDragEnd"
/>
<!-- 半圆气泡指示器 - 只在 TaskBar 完全消失时显示 -->
<div
v-if="bubbleIndicator.show && !isParent"
class="bubble-indicator"
:class="[
`bubble-${bubbleIndicator.side}`,
`bubble-animation-${bubbleIndicator.animationType}`,
]"
:style="{
left: bubbleIndicator.left,
backgroundColor: bubbleIndicator.color,
borderColor: bubbleIndicator.color,
}"
@mouseenter.stop="handleBubbleMouseEnter"
@mouseleave.stop="handleBubbleMouseLeave"
@mousedown.stop="handleBubbleMouseDown"
@click.stop="handleBubbleClick"
></div>
<!-- 默认右键菜单 -->
<TaskContextMenu
v-if="shouldShowDefaultContextMenu"
:visible="contextMenuVisible"
:task="contextMenuTask"
:position="contextMenuPosition"
:all-tasks="allTasks"
@close="closeContextMenu"
@start-timer="$emit('start-timer', props.task)"
@stop-timer="$emit('stop-timer', props.task)"
@add-predecessor="$emit('add-predecessor', props.task)"
@add-successor="$emit('add-successor', props.task)"
@delete="handleTaskDelete"
@delete-link="handleDeleteLink"
/>
<!-- 声明式右键菜单 -->
<Teleport to="body">
<div
v-if="shouldShowCustomContextMenu && contextMenuVisible && declarativeTaskBarContextMenu?.defaultSlot"
class="gantt-context-menu-wrapper"
:style="{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
zIndex: 9999,
}"
>
<component
:is="declarativeTaskBarContextMenu.defaultSlot"
:row="contextMenuTask"
:$index="props.rowIndex ?? -1"
/>
</div>
</Teleport>
</div>
</div><!-- 关闭task-bar-wrapper -->
<!-- Tooltip 弹窗tab悬停时不显示 -->
<Teleport to="body">
<div
v-if="showTooltip && !isTabHovered"
class="task-tooltip"
:style="{
left: `${tooltipPosition.x}px`,
top: `${tooltipPosition.y}px`,
}"
>
<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('resourceView.capacity') || '利用率' }}:</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>
</div>
<div class="tooltip-row">
<span class="tooltip-label">{{ t('endDate') }}:</span>
<span class="tooltip-value">{{ formatDisplayDate(task.endDate) }}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">{{ t('estimatedHours') }}:</span>
<span class="tooltip-value">{{ workHourInfo.total }}h</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label"> {{ t('actualHours') }}:</span>
<span class="tooltip-value">{{ workHourInfo.used }}h</span>
</div>
<div class="tooltip-row">
<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('resourceView.overloaded') || '资源超负荷' }}</span>
</div>
</div>
</div>
</Teleport>
<!-- 拖拽实时反馈提示框 -->
<Teleport to="body">
<div
v-if="dragTooltipVisible"
class="drag-tooltip"
:style="{
left: `${dragTooltipPosition.x}px`,
top: `${dragTooltipPosition.y}px`,
}"
>
<div class="drag-tooltip-content">
<div class="tooltip-row">
<span class="tooltip-label">{{ t('startDate') }}:</span>
<span class="tooltip-value">{{ formatDisplayDate(dragTooltipContent.startDate) }}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">{{ t('endDate') }}:</span>
<span class="tooltip-value">{{ formatDisplayDate(dragTooltipContent.endDate) }}</span>
</div>
</div>
</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悬停 Tooltip 已上移至 Timeline 单例渲染Singleton Tooltip-->
</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;
user-select: none;
/* 阴影通过JS动态设置使用TaskBar的颜色 */
transition:
box-shadow 0.2s,
transform 0.3s,
filter 0.3s,
z-index 0s; /* v1.9.0 z-index不使用动画 */
z-index: 100;
border: 2px solid;
/* 添加半透明黑色边框增强对比度 */
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
overflow: visible; /* 允许内容超出 TaskBar */
}
/* v1.9.0 悬停时提升z-index确保资源视图中重叠的TaskBar可以正常交互 */
.task-bar:hover {
z-index: 160 !important;
}
/* v1.9.1 有气泡指示器时提升z-index解决DOM渲染顺序导致的遮挡问题 */
.task-bar.has-bubble {
z-index: 200 !important;
}
/* 有实际进度时,计划条使用虚线边框样式 */
.task-bar.has-actual {
/* 不再强制设置半透明背景由内联样式的isTaskBarHovered控制 */
border-width: 2px;
border-style: dashed; /* 虚线边框表示这是计划 */
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
0 2px 8px rgba(0, 0, 0, 0.15); /* 内阴影增强边缘清晰度 */
}
/* 有实际TaskBar的计划TaskBar悬停时提升到顶层 */
.task-bar.has-actual:hover {
z-index: 160 !important; /* 高于实际TaskBar的150 */
}
/* 有实际进度时,增强文字清晰度 */
.task-bar.has-actual .task-bar-content {
text-shadow:
0 1px 3px rgba(255, 255, 255, 0.8),
0 0 1px rgba(255, 255, 255, 1); /* 白色外发光增强对比度 */
}
.task-bar.has-actual .task-name,
.task-bar.has-actual .task-progress {
font-weight: 600;
color: #333 !important; /* 深色文字确保可读性 */
}
.task-bar:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.task-bar.completed {
cursor: pointer !important;
}
.task-bar.completed:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
cursor: pointer;
}
/* v1.9.1 资源视图TaskBar全高度占比设计 */
.task-bar.resource-view {
display: flex;
align-items: center;
justify-content: center;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
/* 上半部分:镂空区域(仅上、左、右三边虚线) */
.task-bar.resource-view::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: calc((1 - var(--allocation-capacity, 1)) * 100%);
border-top: 1.5px dashed currentColor;
border-left: 1.5px dashed currentColor;
border-right: 1.5px dashed currentColor;
border-bottom: none;
background: transparent;
box-sizing: border-box;
pointer-events: none;
border-radius: 4px 4px 0 0;
opacity: 0.6;
z-index: -1; /* v1.9.1 设置为负值,确保不遮挡 bubble-indicator */
}
/* 占比100%时,隐藏上半部分镂空区域 */
.task-bar.resource-view[style*="--allocation-capacity: 1"]::before {
display: none;
}
/* 下半部分:实心填充区域(四边实线边框 + 背景色) */
.task-bar.resource-view::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: calc(var(--allocation-capacity, 1) * 100%);
background: var(--task-bar-bg-color, #e3f2fd);
/*border: 1px solid var(--task-bar-border-color, #90caf9);*/
border-radius: 0 0 4px 4px;
box-sizing: border-box;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
pointer-events: none;
z-index: -1; /* v1.9.1 设置为负值,确保不遮挡 bubble-indicator */
}
/* 占比100%时整个TaskBar都是实心四个角圆角 */
.task-bar.resource-view[style*="--allocation-capacity: 1"]::after {
border-radius: 4px;
}
/* 进度条限制在填充区内 */
.task-bar.resource-view .progress-bar {
position: absolute;
bottom: 0;
left: 0;
top: auto;
height: calc(var(--allocation-capacity, 1) * 100%);
z-index: 1;
border-radius: 0 0 4px 4px;
pointer-events: none;
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.8); /* 右侧黑色阴影,增强层次感 */
}
/* 占比100%时,进度条四个角圆角 */
.task-bar.resource-view[style*="--allocation-capacity: 1"] .progress-bar {
border-radius: 4px;
}
/* 已完成任务的进度条不显示阴影 */
.task-bar.completed.resource-view .progress-bar {
box-shadow: none;
}
/* 文字层在最上层覆盖整个41px高度 */
.task-bar.resource-view .task-bar-content {
position: relative;
/*z-index: 2;*/
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
/* 资源视图悬停效果 */
.task-bar.resource-view:hover::after {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
/* v1.9.0 资源冲突样式 - 已移除由GanttConflicts组件独立渲染 */
/*
.task-bar.resource-conflict::before {
border-color: var(--gantt-error-color, #f56c6c) !important;
opacity: 1 !important;
z-index: 10 !important;
}
.task-bar.resource-conflict::after {
border-left: 3px solid var(--gantt-error-color, #f56c6c) !important;
background-color: rgba(245, 108, 108, 0.12) !important;
background-image:
repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
rgba(245, 108, 108, 0.1) 10px,
rgba(245, 108, 108, 0.1) 20px
) !important;
z-index: 1 !important;
}
.task-bar.completed.resource-conflict::after {
background-color: transparent !important;
background-image: none !important;
border-left: 3px solid var(--gantt-error-color, #f56c6c) !important;
}
.task-bar.resource-conflict .task-name,
.task-bar.resource-conflict .task-progress {
font-weight: 600;
z-index: 11;
}
*/
.task-bar.dragging {
opacity: 0.8;
z-index: 1000;
}
.task-bar.resizing {
z-index: 1000;
}
/* 高亮模式下非高亮的TaskBar变暗淡 */
.task-bar.dimmed {
opacity: 0.35 !important;
filter: grayscale(0.3) !important;
transition: all 0.3s ease !important;
z-index: 1 !important; /* 确保dimmed的任务在蒙版之下 */
}
/* 高亮样式 - 前置/后置任务 */
.task-bar.highlighted {
z-index: 1002 !important;
box-shadow:
0 8px 24px rgba(64, 158, 255, 0.5),
0 6px 16px rgba(0, 0, 0, 0.3) !important;
transform: translateY(-5px) scale(1.05) !important;
transition: all 0.3s ease !important;
cursor: grab !important;
}
/* 高亮TaskBar的内容加粗 */
.task-bar.highlighted .task-bar-content {
font-weight: bold !important;
}
/* 主要高亮样式 - 被长按的任务 */
.task-bar.primary-highlight {
z-index: 1003 !important;
box-shadow:
0 12px 32px rgba(64, 158, 255, 0.6),
0 8px 20px rgba(0, 0, 0, 0.35) !important;
transform: translateY(-8px) scale(1.08) !important;
transition: all 0.3s ease !important;
cursor: grab !important;
}
/* 主要高亮TaskBar的内容加粗 */
.task-bar.primary-highlight .task-bar-content {
font-weight: bold !important;
}
.task-bar.parent-task {
position: relative;
border-radius: 0 !important; /* 不要圆角 */
height: 15px !important; /* 高度15px */
border: none; /* 移除边框 */
/* background通过内联样式设置使用taskStatus.bgColor支持自定义barColor */
top: 50% !important; /* 上下居中 */
transform: translateY(-50%); /* 上下居中 */
cursor: pointer !important; /* 允许双击编辑 */
overflow: visible; /* 确保标题和箭头可见 */
display: flex;
align-items: center;
justify-content: center;
}
/* 高亮的父任务覆盖默认样式 */
.task-bar.parent-task.highlighted {
box-shadow:
0 8px 24px rgba(64, 158, 255, 0.5),
0 6px 16px rgba(0, 0, 0, 0.3) !important;
filter: brightness(1.2) drop-shadow(0 0 8px rgba(64, 158, 255, 0.4)) !important;
transform: translateY(-50%) scale(1.05) !important;
}
.task-bar.parent-task.primary-highlight {
box-shadow:
0 12px 32px rgba(64, 158, 255, 0.6),
0 8px 20px rgba(0, 0, 0, 0.35) !important;
filter: brightness(1.25) drop-shadow(0 0 12px rgba(64, 158, 255, 0.6)) !important;
transform: translateY(-50%) scale(1.08) !important;
}
/* 左侧向下箭头 */
.task-bar.parent-task::before {
content: '';
position: absolute;
top: 14px; /* 紧贴进度条下方,消除缝隙 */
left: 0;
width: 0;
height: 0;
border-right: 6px solid transparent;
border-top: 10px solid var(--parent-color, #409eff); /* 使用父级TaskBar的动态颜色 */
z-index: 15;
}
/* 右侧向下箭头 */
.task-bar.parent-task::after {
content: '';
position: absolute;
top: 14px; /* 紧贴进度条下方,消除缝隙 */
right: 0;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-top: 10px solid var(--parent-color, #409eff); /* 使用父级TaskBar的动态颜色 */
z-index: 15;
}
/* 父级任务的标题(内部居中显示) */
.task-bar.parent-task .parent-label-inner {
color: white;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.progress-bar {
position: absolute;
top: 0;
left: 0;
height: 100%;
opacity: 0.3;
transition: width 0.3s ease;
}
/* 实际进度条样式 - 独立在顶层渲染 */
.actual-bar {
position: absolute;
height: 20px !important; /* 固定高度,比计划条窄 */
border-radius: 10px; /* 两头圆形高度20px的50% */
/* 边框通过内联样式设置使用TaskBar颜色 */
/* 阴影通过JS动态设置使用TaskBar的颜色 */
z-index: 150; /* 高于计划条的z-index(100),显示在顶层 */
transition: all 0.3s ease;
user-select: none;
pointer-events: none; /* 不响应鼠标事件,避免干扰计划条的交互 */
opacity: 1; /* 完全不透明,实际条要清晰可见 */
/* 背景色通过内联样式设置但会在filter中加白和增加透明度 */
}
/* 实际TaskBar的dimmed状态 - 确保在蒙版之下 */
.actual-bar.dimmed {
z-index: 1 !important;
opacity: 0.35 !important;
filter: grayscale(0.3) brightness(1.15) saturate(0.9) !important;
}
.actual-bar:hover {
/* hover阴影也通过JS动态设置 */
}
/* 实际TaskBar的高亮样式 - 跟随计划TaskBar */
.actual-bar.highlighted {
z-index: 1004 !important; /* 高于计划TaskBar的highlighted(1002),确保在上层 */
transform: translateY(-5px) scale(1.05) !important;
transition: all 0.3s ease !important;
filter: brightness(1.25) saturate(1.0) !important; /* 高亮时更亮 */
}
/* 实际TaskBar的主要高亮样式 */
.actual-bar.primary-highlight {
z-index: 1005 !important; /* 高于计划TaskBar的primary-highlight(1003),确保在上层 */
transform: translateY(-8px) scale(1.08) !important;
transition: all 0.3s ease !important;
filter: brightness(1.3) saturate(1.0) !important; /* 主要高亮时最亮 */
}
/* 实际TaskBar的头像样式 - 在尾部外面 */
.actual-task-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
overflow: hidden;
background: #f0f0f0;
border: 2px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
pointer-events: none;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 200; /* 显示在最顶层 */
}
.actual-task-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.actual-task-avatar .avatar-text {
font-size: 11px;
font-weight: 600;
color: #ffffff;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.actual-task-avatar .avatar-icon {
width: 14px;
height: 14px;
color: #999;
}
.actual-bar-content {
display: flex;
align-items: center;
justify-content: flex-end; /* 右对齐 */
height: 100%;
padding: 0 8px;
color: #ffffff;
font-size: 11px;
font-weight: 700;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
white-space: nowrap;
overflow: hidden;
position: relative;
z-index: 200; /* 显示在最顶层 */
}
/* 尾部外面的容器:头像 + 标题 */
.actual-bar-trailing {
position: absolute;
left: 100%; /* 从实际TaskBar右侧开始 */
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 10px; /* 头像和标题之间的间距 */
margin-left: 6px;
pointer-events: none;
z-index: 200; /* 显示在最顶层超过计划TaskBar */
}
/* 实际TaskBar的标题容器 */
.actual-task-name-wrapper {
position: relative;
z-index: 200; /* 显示在最顶层 */
display: flex;
align-items: center; /* 垂直居中 */
}
/* 实际TaskBar的标题样式 - 与原始.task-name完全一致 */
.actual-task-name {
white-space: nowrap;
overflow: visible;
line-height: 1.2;
font-size: 12px;
font-weight: 700; /* 加粗显示 */
z-index: 10;
/* 移除背景样式,保持原始状态 */
}
/* 暗黑主题下的标题颜色 */
:global(.gantt-root[data-theme='dark']) .actual-task-name {
color: #ffffff !important;
}
.actual-progress {
flex-shrink: 0;
opacity: 0.95;
font-weight: 700;
letter-spacing: 0.3px;
position: relative;
z-index: 200; /* 显示在最顶层 */
}
.task-bar-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
padding: 0 8px;
font-size: 12px;
font-weight: 500;
text-align: center;
overflow: visible; /* 允许内容超出 */
position: relative;
z-index: 1;
}
/* 多头像容器 - 继承原始头像的定位样式 */
.task-avatars-container {
display: flex;
align-items: center;
position: absolute;
left: calc(var(--handle-width, 5px) + 3px); /* 手柄宽度 + 3px 间距 */
top: 50%;
transform: translateY(-50%);
}
.actual-avatars-container {
display: flex;
align-items: center;
position: relative;
}
/* 多头像重叠效果 */
.task-avatars-container .task-avatar,
.actual-avatars-container .actual-task-avatar {
transition: transform 0.2s ease, z-index 0s;
cursor: pointer;
position: relative; /* 改为相对定位,在容器内排列 */
}
/* 悬停时突出显示当前头像 */
.task-avatars-container .task-avatar:hover,
.actual-avatars-container .actual-task-avatar:hover {
transform: translateY(-3px) scale(1.15);
z-index: 999 !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.task-avatar {
width: 22px;
height: 22px;
border-radius: 50%;
overflow: hidden;
background: #f0f0f0;
border: 2px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
z-index: 15;
pointer-events: none; /* 不阻止拖拽 */
flex-shrink: 0; /* 防止被压缩 */
display: flex;
align-items: center;
justify-content: center;
}
.task-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 默认头像样式(灰色背景) */
.task-avatar.avatar-default {
background: var(--gantt-bg-tertiary, #e0e0e0);
color: var(--gantt-text-tertiary, #9e9e9e);
}
/* 文字头像样式 */
.task-avatar .avatar-text {
font-size: 11px;
font-weight: 600;
color: var(--gantt-text-white, #ffffff);
background: var(--gantt-primary-color, #409eff);
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
/* 默认图标样式 */
.task-avatar .avatar-icon {
width: 14px;
height: 14px;
color: var(--gantt-text-tertiary, #9e9e9e);
}
/* 当 taskbar 较窄时,将 avatar 渲染到外框左边缘 */
.task-avatars-container.avatar-outside {
left: -12px; /* 位于 taskbar 左侧外框边缘 */
z-index: 20; /* 提高层级确保在最上层 */
}
.task-avatar.avatar-outside {
border-width: 2px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
/* 当任务条较窄时调整内容padding以避免与头像重叠 */
.task-bar-content:has(.task-avatars-container) {
padding-left: 36px; /* 为头像留出空间 */
}
/* 当 avatar 在外框时,不需要额外的 padding */
.task-bar-content:has(.task-avatars-container.avatar-outside) {
padding-left: 8px;
}
/* v1.9.1 资源视图中的标题包裹器确保在41px全高度内居中 */
.task-name-wrapper {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.task-name {
white-space: nowrap;
overflow: visible;
line-height: 1.2;
font-size: 12px;
font-weight: 700; /* 加粗显示 */
z-index: 10;
/* 移除背景样式,保持原始状态 */
}
/* v1.9.0 资源占比文字样式 */
.resource-capacity-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-capacity-text {
display: none;
}
}
.task-progress {
opacity: 0.9;
font-size: 11px;
font-weight: 700; /* 加粗显示 */
z-index: 10;
line-height: 1.2;
/* 移除背景样式,保持原始状态 */
}
.resize-handle {
position: absolute;
top: 0;
/* 宽度通过内联样式设置默认10px */
height: 100%;
cursor: ew-resize;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
transition: all 0.2s;
z-index: 2;
}
.resize-handle:hover {
background: rgba(0, 0, 0, 0.25); /* 悬停时更明显 */
transform: scaleX(1.2); /* 悬停时稍微增宽 */
}
.resize-handle-left {
left: 0;
}
.resize-handle-right {
right: 0;
}
/* 溢出效果下的拉伸handle优化 */
.task-bar.overflow-effect .resize-handle {
z-index: 20; /* 确保handle在溢出内容之上 */
background: rgba(0, 0, 0, 0.15); /* 稍微加深以提高可见性 */
}
.task-bar.overflow-effect .resize-handle:hover {
background: rgba(0, 0, 0, 0.3);
transform: scaleX(1.3); /* 悬停时进一步增宽 */
}
/* 溢出模式下左右handle的位置调整 */
.task-bar.overflow-effect .resize-handle-left {
left: 0;
}
.task-bar.overflow-effect .resize-handle-right {
right: 0;
}
/* === 半圆气泡指示器样式 === */
.bubble-indicator {
position: absolute;
top: 50%;
width: 8px; /* 半圆宽度 */
height: 16px; /* 半圆高度 */
z-index: 9999 !important; /* v1.9.1 使用超高层级和 !important确保不被任何元素遮挡 */
cursor: pointer;
border: 2px solid;
transform: translateY(-50%);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.15),
0 1px 3px rgba(0, 0, 0, 0.3);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 左侧半圆 - 圆心在边界上,只显示右半部分 */
.bubble-left {
border-radius: 0 8px 8px 0;
border-left: none;
transform: translateY(-50%); /* 不需要额外偏移,圆心已在边界 */
}
/* 右侧半圆 - 圆心在边界上,只显示左半部分 */
.bubble-right {
border-radius: 8px 0 0 8px;
border-right: none;
transform: translateY(-50%); /* 不需要额外偏移,圆心已在边界 */
}
/* 悬停效果 */
.bubble-indicator:hover {
transform: translateY(-50%) scale(1.2);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.2),
0 2px 6px rgba(0, 0, 0, 0.4);
}
.bubble-left:hover {
transform: translateY(-50%) scale(1.2);
}
.bubble-right:hover {
transform: translateY(-50%) scale(1.2);
}
/* === 半圆气泡动画效果 === */
/* TaskBar 边缘变成左侧半圆的动画 */
@keyframes morphToLeftSemiCircle {
0% {
width: 60px;
height: 30px;
border-radius: 4px 0 0 4px;
border-right: 2px solid;
border-left: none;
opacity: 0.8;
transform: translateY(-50%);
}
30% {
width: 30px;
height: 28px;
border-radius: 6px 0 0 6px;
opacity: 0.9;
transform: translateY(-50%);
}
70% {
width: 12px;
height: 20px;
border-radius: 0 10px 10px 0;
border-right: 2px solid;
border-left: none;
opacity: 1;
transform: translateY(-50%);
}
100% {
width: 8px;
height: 16px;
border-radius: 0 8px 8px 0;
border-right: 2px solid;
border-left: none;
opacity: 1;
transform: translateY(-50%);
}
}
/* TaskBar 边缘变成右侧半圆的动画 */
@keyframes morphToRightSemiCircle {
0% {
width: 60px;
height: 30px;
border-radius: 0 4px 4px 0;
border-left: 2px solid;
border-right: none;
opacity: 0.8;
transform: translateY(-50%);
}
30% {
width: 30px;
height: 28px;
border-radius: 0 6px 6px 0;
opacity: 0.9;
transform: translateY(-50%);
}
70% {
width: 12px;
height: 20px;
border-radius: 10px 0 0 10px;
border-left: 2px solid;
border-right: none;
opacity: 1;
transform: translateY(-50%);
}
100% {
width: 8px;
height: 16px;
border-radius: 8px 0 0 8px;
border-left: 2px solid;
border-right: none;
opacity: 1;
transform: translateY(-50%);
}
}
/* 半圆的脉动效果 */
@keyframes semiCirclePulse {
0% {
opacity: 0.8;
transform: translateY(-50%) scale(1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
50% {
opacity: 1;
transform: translateY(-50%) scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
100% {
opacity: 0.8;
transform: translateY(-50%) scale(1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
}
/* 左侧半圆脉动 */
@keyframes leftSemiCirclePulse {
0% {
opacity: 0.8;
transform: translateY(-50%) scale(1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
50% {
opacity: 1;
transform: translateY(-50%) scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
100% {
opacity: 0.8;
transform: translateY(-50%) scale(1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
}
/* 右侧半圆脉动 */
@keyframes rightSemiCirclePulse {
0% {
opacity: 0.8;
transform: translateY(-50%) scale(1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
50% {
opacity: 1;
transform: translateY(-50%) scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
100% {
opacity: 0.8;
transform: translateY(-50%) scale(1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
}
/* 应用动画类 */
.bubble-animation-morphToSemiCircle {
animation: semiCirclePulse 2s ease-in-out infinite;
}
/* 左侧半圆的变换动画 */
.bubble-left.bubble-animation-morphToSemiCircle {
animation:
morphToLeftSemiCircle 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards,
leftSemiCirclePulse 2s ease-in-out 0.8s infinite;
}
/* 右侧半圆的变换动画 */
.bubble-right.bubble-animation-morphToSemiCircle {
animation:
morphToRightSemiCircle 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards,
rightSemiCirclePulse 2s ease-in-out 0.8s infinite;
}
/* TaskBar 重新出现动画已移除,保持简洁 */
/* === Tooltip 样式 === */
.task-tooltip {
position: fixed;
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 12px;
border-radius: 8px;
font-size: 12px;
z-index: 9999999999; /* 确保在最上层 */
max-width: 250px;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.4),
0 4px 12px rgba(0, 0, 0, 0.3);
pointer-events: none;
backdrop-filter: blur(4px); /* 增加模糊背景效果 */
border: 1px solid rgba(255, 255, 255, 0.1);
}
.tooltip-title {
font-weight: 700;
font-size: 13px;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
color: #ffffff;
}
.tooltip-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.tooltip-row {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 18px;
}
.tooltip-label {
opacity: 0.8;
min-width: 60px;
color: #e5e5e5;
}
.tooltip-value {
font-weight: 600;
text-align: right;
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;
background: rgba(0, 123, 255, 0.95);
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
z-index: 999999999;
box-shadow: 0 2px 12px rgba(0, 123, 255, 0.4);
pointer-events: none;
border: 1px solid rgba(255, 255, 255, 0.2);
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;
align-items: center;
margin-bottom: 2px;
}
.drag-tooltip .tooltip-row:last-child {
margin-bottom: 0;
}
.drag-tooltip .tooltip-label {
opacity: 0.9;
min-width: 55px;
font-size: 11px;
}
.drag-tooltip .tooltip-value {
font-weight: 600;
text-align: right;
font-size: 11px;
margin-left: 8px;
}
/* === TaskBar悬停提示框样式 === */
.task-hover-tooltip {
position: fixed;
background-color: rgba(0, 0, 0, 0.85);
color: white;
padding: 10px 14px;
border-radius: 6px;
font-size: 12px;
z-index: 999999999;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
pointer-events: none;
transform: translate(-50%, -100%); /* 默认显示在上方 */
margin-top: -8px;
min-width: 150px;
}
/* 显示在下方时的样式 */
.task-hover-tooltip.tooltip-below {
transform: translate(-50%, 0); /* 显示在下方 */
margin-top: 0;
}
.hover-tooltip-arrow {
position: absolute;
left: 50%;
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 {
display: flex;
flex-direction: column;
gap: 6px;
}
.hover-tooltip-title {
font-weight: 600;
font-size: 13px;
margin-bottom: 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
padding-bottom: 4px;
}
.hover-tooltip-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.hover-tooltip-label {
opacity: 0.9;
font-size: 11px;
white-space: nowrap;
}
.hover-tooltip-value {
font-weight: 500;
text-align: right;
font-size: 11px;
}
.sticky-text {
position: absolute;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
line-height: 1.2;
z-index: 10;
}
/* 暗色主题支持 */
:global(.gantt-root[data-theme='dark']) .task-bar {
border-color: #111827 !important;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.7),
0 2px 4px rgba(0, 0, 0, 0.3) !important;
}
:global(.gantt-root[data-theme='dark']) .task-bar:hover {
box-shadow:
0 6px 20px rgba(0, 0, 0, 0.8),
0 4px 8px rgba(0, 0, 0, 0.4) !important;
transform: translateY(-2px);
transition: all 0.2s ease;
}
:global(.gantt-root[data-theme='dark']) .task-bar:hover::after {
background: rgba(7, 10, 15, 0.98) !important;
color: #f9fafb !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6) !important;
}
:global(.gantt-root[data-theme='dark']) .task-bar.normal {
background: linear-gradient(135deg, #1e40af, #1e3a8a) !important;
border-color: #1e3a8a !important;
}
:global(.gantt-root[data-theme='dark']) .task-bar.milestone {
background: linear-gradient(135deg, #c2410c, #9a3412) !important;
border-color: #9a3412 !important;
}
:global(.gantt-root[data-theme='dark']) .task-bar.completed {
background: linear-gradient(135deg, #14532d, #16a34a) !important;
border-color: #14532d !important;
}
:global(.gantt-root[data-theme='dark']) .task-bar.delayed {
background: linear-gradient(135deg, #991b1b, #dc2626) !important;
border-color: #991b1b !important;
}
:global(.gantt-root[data-theme='dark']) .task-bar.parent {
background: linear-gradient(135deg, #581c87, #7c3aed) !important;
border-color: #581c87 !important;
}
:global(.gantt-root[data-theme='dark']) .task-content {
color: #ffffff !important;
}
:global(.gantt-root[data-theme='dark']) .task-name {
color: #ffffff !important;
}
:global(.gantt-root[data-theme='dark']) .progress-bar {
background: rgba(255, 255, 255, 0.2) !important;
}
:global(.gantt-root[data-theme='dark']) .progress-fill {
background: rgba(255, 255, 255, 0.8) !important;
}
:global(.gantt-root[data-theme='dark']) .resize-handle {
background: rgba(255, 255, 255, 0.1) !important;
}
:global(.gantt-root[data-theme='dark']) .resize-handle:hover {
background: rgba(255, 255, 255, 0.3) !important;
}
/* 周视图下的短TaskBar样式优化 */
.task-bar.week-view.short-task-bar {
position: relative;
overflow: visible;
}
/* 周视图下短TaskBar的内容溢出效果 */
.task-bar.overflow-effect .task-bar-content {
/* 保持与日视图一致的布局 */
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
padding: 0 8px;
font-size: 12px;
font-weight: 500;
text-align: center;
overflow: visible;
position: relative;
z-index: 10;
/* 确保主体内容区域仍可拖拽 */
pointer-events: auto;
}
.task-bar.overflow-effect .task-name {
/* 保持与日视图一致的样式 */
white-space: nowrap;
overflow: visible;
line-height: 1.2;
font-size: 12px;
font-weight: 700;
z-index: 15;
pointer-events: none;
/* 允许溢出显示,但保持原始样式 */
min-width: max-content;
}
/* 周视图下进度百分比保持与日视图一致 */
.task-bar.overflow-effect .task-progress {
/* 保持与日视图一致的基础样式 */
opacity: 0.9;
font-size: 11px;
font-weight: 700;
z-index: 16;
pointer-events: none;
padding: 1px 3px;
/*border-radius: 2px;*/
}
/* 周视图下的TaskBar基础样式调整 */
.task-bar.week-view {
min-width: 4px; /* 确保即使很短的任务也有最小可见宽度 */
border-width: 1px;
/*border-radius: 2px;*/
}
/* 暗色主题下的短TaskBar溢出效果 */
:global(.gantt-root[data-theme='dark']) .task-bar.overflow-effect .resize-handle {
background: rgba(255, 255, 255, 0.15);
}
:global(.gantt-root[data-theme='dark']) .task-bar.overflow-effect .resize-handle:hover {
background: rgba(255, 255, 255, 0.3);
}
/* 暗色主题下的进度百分比样式 */
:global(.gantt-root[data-theme='dark']) .task-bar.overflow-effect .task-progress {
background: rgba(0, 0, 0, 0.9);
color: white;
}
:global(.gantt-root[data-theme='dark']) .task-bar.week-view {
border-color: var(--gantt-border-light, #555555);
}
/* 暗色主题下的头像样式 */
:global(.gantt-root[data-theme='dark']) .task-avatar {
border-color: rgba(255, 255, 255, 0.3);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
/* 暗色主题下的默认头像 */
:global(.gantt-root[data-theme='dark']) .task-avatar.avatar-default {
background: var(--gantt-bg-tertiary, #4a5568);
color: var(--gantt-text-tertiary, #718096);
}
/* 暗色主题下的图标颜色 */
:global(.gantt-root[data-theme='dark']) .task-avatar .avatar-icon {
color: var(--gantt-text-tertiary, #718096);
}
:global(.gantt-root[data-theme='dark']) .task-avatar.avatar-outside {
border-color: rgba(255, 255, 255, 0.4);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
}
</style>