4981 lines
161 KiB
Vue
4981 lines
161 KiB
Vue
/* 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 Tooltip:TaskBar 只负责触发,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
|
||
// 上面的状态重置会自动触发watch,watch会同步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 Tooltip:Timer后 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>
|