6874 lines
223 KiB
Vue
6874 lines
223 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted, computed, watch, nextTick, shallowRef, inject, provide } from 'vue'
|
||
import type { Ref, ComputedRef } from 'vue'
|
||
import TaskBar from './TaskBar.vue'
|
||
import MilestonePoint from './MilestonePoint.vue'
|
||
import GanttLinks from './GanttLinks.vue'
|
||
import LinkDragGuide from './LinkDragGuide.vue'
|
||
import GanttConflicts from './Timeline/GanttConflicts.vue'
|
||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||
import { useI18n } from '../composables/useI18n'
|
||
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
|
||
import { getPredecessorIds } from '../utils/predecessorUtils'
|
||
import { perfMonitor } from '../utils/perfMonitor'
|
||
import { perfMonitor2 } from '../utils/perfMonitor2' // v1.9.6 性能诊断工具
|
||
import type { Task } from '../models/classes/Task'
|
||
import type { Resource } from '../models/types/Resource'
|
||
import type { Milestone } from '../models/classes/Milestone'
|
||
import type { TimelineConfig } from '../models/configs/TimelineConfig'
|
||
import { TimelineScale } from '../models/types/TimelineScale'
|
||
import { assignTaskRows } from '../utils/taskLayoutUtils' // v1.9.0 换行布局算法
|
||
import { positionCache } from '../utils/positionCache' // v1.9.6 Phase1 位置计算缓存
|
||
|
||
// 定义Props接口
|
||
interface Props {
|
||
// 任务数据
|
||
tasks?: Task[]
|
||
// 里程碑数据
|
||
milestones?: Milestone[]
|
||
startDate: Date
|
||
endDate: Date
|
||
useDefaultDrawer?: boolean
|
||
useDefaultMilestoneDialog?: boolean
|
||
onTaskDelete?: (task: Task) => void
|
||
onTaskUpdate?: (task: Task) => void
|
||
onMilestoneDelete?: (milestone: Milestone) => void
|
||
onMilestoneUpdate?: (milestone: Milestone) => void
|
||
onMilestoneSave?: (milestone: Task) => void
|
||
workingHours?: {
|
||
morning?: { start: number; end: number }
|
||
afternoon?: { start: number; end: number }
|
||
}
|
||
// TaskBar 配置
|
||
taskBarConfig?: TaskBarConfig
|
||
// 是否允许拖拽和拉伸(默认为 true)
|
||
allowDragAndResize?: boolean
|
||
// 是否显示实际任务条(默认为 false)
|
||
showActualTaskbar?: boolean
|
||
// 是否启用 TaskBar 气泡提示框(默认为 true)
|
||
enableTaskBarTooltip?: boolean
|
||
// 自定义任务状态背景色
|
||
pendingTaskBackgroundColor?: string
|
||
delayTaskBackgroundColor?: string
|
||
completeTaskBackgroundColor?: string
|
||
ongoingTaskBackgroundColor?: string
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
tasks: () => [],
|
||
milestones: () => [],
|
||
useDefaultDrawer: true,
|
||
useDefaultMilestoneDialog: true,
|
||
onTaskDelete: undefined,
|
||
onTaskUpdate: undefined,
|
||
onMilestoneDelete: undefined,
|
||
onMilestoneUpdate: undefined,
|
||
onMilestoneSave: undefined,
|
||
workingHours: () => ({
|
||
morning: { start: 8, end: 11 },
|
||
afternoon: { start: 13, end: 17 },
|
||
}),
|
||
taskBarConfig: undefined,
|
||
allowDragAndResize: true,
|
||
showActualTaskbar: false,
|
||
enableTaskBarTooltip: true,
|
||
pendingTaskBackgroundColor: undefined,
|
||
delayTaskBackgroundColor: undefined,
|
||
completeTaskBackgroundColor: undefined,
|
||
ongoingTaskBackgroundColor: undefined,
|
||
})
|
||
|
||
// 定义emits
|
||
const emit = defineEmits<{
|
||
'timeline-scale-changed': [scale: TimelineScale]
|
||
'click-task': [task: Task, event: MouseEvent]
|
||
'edit-task': [task: Task]
|
||
'milestone-double-click': [milestone: Milestone]
|
||
'start-timer': [task: Task]
|
||
'stop-timer': [task: Task]
|
||
'add-predecessor': [task: Task] // 新增:添加前置任务事件
|
||
'add-successor': [task: Task] // 新增:添加后置任务事件
|
||
'predecessor-added': [{ targetTask: Task; newTask: Task }] // 前置任务已添加
|
||
'successor-added': [{ targetTask: Task; newTask: Task }] // 后置任务已添加
|
||
delete: [task: Task, deleteChildren?: boolean]
|
||
'link-deleted': [{ sourceTaskId: number; targetTaskId: number; updatedTask: Task }] // 链接已删除
|
||
'resource-drag-end': [{ task: Task; sourceResourceIndex: number; targetResourceIndex: number; targetResource: Resource; newStartDate?: string; newEndDate?: string }] // v1.9.0 资源视图垂直拖拽结束
|
||
}>()
|
||
|
||
// 多语言
|
||
const { formatYearMonth, formatMonth, getTranslation } = useI18n()
|
||
|
||
// 翻译函数
|
||
const t = (key: string): string => {
|
||
return getTranslation(key)
|
||
}
|
||
|
||
// v1.9.0 从 GanttChart 注入视图模式和数据源
|
||
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
|
||
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
|
||
|
||
// v1.9.0 从 GanttChart 注入资源冲突信息(由 GanttChart 计算并响应 updateTaskTrigger)
|
||
const resourceConflicts = inject<ComputedRef<Map<string, Set<number>>>>('resourceConflicts', computed(() => new Map()))
|
||
|
||
// TaskBar渲染key,用于在容器变化时强制重新渲染
|
||
const taskBarRenderKey = ref(0)
|
||
|
||
// v1.9.2 计算资源视图中每个任务的冲突任务列表(用于显示详细冲突信息)
|
||
const getConflictTasksForTask = (resourceId: string | number, taskId: string | number): Task[] => {
|
||
if (viewMode.value !== 'resource') return []
|
||
|
||
const conflictTaskIds = resourceConflicts.value.get(String(resourceId))
|
||
if (!conflictTaskIds || !conflictTaskIds.has(Number(taskId))) return []
|
||
|
||
const resources = dataSource.value as Resource[]
|
||
const resource = resources.find(r => String(r.id) === String(resourceId))
|
||
if (!resource || !resource.tasks) return []
|
||
|
||
const currentTask = resource.tasks.find(t => t.id === taskId)
|
||
if (!currentTask || !currentTask.startDate || !currentTask.endDate) return []
|
||
|
||
const currentStart = new Date(currentTask.startDate).getTime()
|
||
const currentEnd = new Date(currentTask.endDate).getTime()
|
||
|
||
// v1.9.7 修复:返回所有与当前任务时间重叠的冲突任务
|
||
// 不需要再次验证占比相加是否超过100%,因为resourceConflicts已经包含了所有冲突的任务ID
|
||
// 当多个任务同时重叠时(如3个任务各75%),应该全部返回,而不是只返回第一个两两超载的任务
|
||
// v1.9.9 修复:endDate 包含当天,需要 +1 天来判断交集(与 conflictUtils 保持一致)
|
||
const currentEndPlus = currentEnd + 24 * 60 * 60 * 1000
|
||
|
||
const conflictTasks = resource.tasks.filter(task => {
|
||
if (task.id === taskId) return false
|
||
if (!task.startDate || !task.endDate) return false
|
||
// 任务必须在冲突任务集合中
|
||
if (!conflictTaskIds.has(task.id)) return false
|
||
|
||
const taskStart = new Date(task.startDate).getTime()
|
||
const taskEnd = new Date(task.endDate).getTime()
|
||
const taskEndPlus = taskEnd + 24 * 60 * 60 * 1000
|
||
|
||
// 检查时间重叠:endDate 包含当天,所以需要 +1 天来判断
|
||
// 例如:任务A endDate=12-24, 任务B startDate=12-24,应判断为重叠(都占用12-24这一天)
|
||
return currentStart < taskEndPlus && taskStart < currentEndPlus
|
||
})
|
||
|
||
return conflictTasks
|
||
}
|
||
|
||
// v1.9.2 拖拽状态管理(用于冲突检测优化)
|
||
const isDraggingTaskBar = ref(false)
|
||
provide('isDraggingTaskBar', isDraggingTaskBar)
|
||
|
||
// 记录最近变化的TaskBar ID(用于增量冲突更新)
|
||
const lastChangedTaskId = ref<string | number | null>(null)
|
||
provide('lastChangedTaskId', lastChangedTaskId)
|
||
|
||
// v1.9.6 Phase1 - 提供位置计算缓存实例给所有TaskBar
|
||
provide('positionCache', positionCache)
|
||
|
||
// v1.9.5 P2-4优化 - 注入Split Bar拖拽状态
|
||
const isSplitBarDragging = inject<Ref<boolean>>('isSplitBarDragging', ref(false))
|
||
|
||
// v1.9.5 注入showConflicts配置
|
||
const showConflicts = inject<ComputedRef<boolean>>('gantt-show-conflicts', computed(() => true))
|
||
|
||
// v1.9.6 Sprint1(P0) - 布局缓存(用于resourceTaskLayouts按需计算)
|
||
const layoutCache = new Map<string, {
|
||
taskRowMap: Map<string | number, number>,
|
||
rowHeights: number[],
|
||
totalHeight: number
|
||
}>()
|
||
|
||
// v1.9.6 Phase2 - 计算资源视图的任务行布局(按需计算+缓存优化)
|
||
// 策略:只计算可见资源的布局,通过缓存避免重复计算
|
||
let resourceTaskLayoutsCallCount = 0
|
||
const resourceTaskLayouts = computed(() => {
|
||
resourceTaskLayoutsCallCount++
|
||
|
||
const layoutMap = new Map<string | number, {
|
||
taskRowMap: Map<string | number, number>,
|
||
rowHeights: number[],
|
||
totalHeight: number
|
||
}>()
|
||
|
||
if (viewMode.value !== 'resource') {
|
||
return layoutMap
|
||
}
|
||
|
||
const resources = dataSource.value as Resource[]
|
||
|
||
// ⚠️ 重要:必须计算所有资源的布局,不能只计算可见资源
|
||
// 因为 visibleTaskRange 的计算依赖于 resourceRowPositions,而 resourceRowPositions 依赖于这里的布局数据
|
||
// 如果只计算可见资源,会导致循环依赖和布局错误
|
||
|
||
// 性能监控
|
||
let cacheHits = 0
|
||
let cacheMisses = 0
|
||
|
||
// 为所有资源计算布局(使用缓存优化性能)
|
||
resources.forEach(resource => {
|
||
// 先检查缓存命中情况
|
||
const cacheKey = `${resource.id}-${((resource as any).tasks || []).length}`
|
||
const isCacheHit = layoutCache.has(cacheKey)
|
||
|
||
// 获取布局(如果缓存未命中会自动计算并缓存)
|
||
const layout = getResourceLayout(resource)
|
||
layoutMap.set(resource.id, layout)
|
||
|
||
// 统计缓存命中率
|
||
if (isCacheHit) {
|
||
cacheHits++
|
||
} else {
|
||
cacheMisses++
|
||
}
|
||
})
|
||
|
||
return layoutMap
|
||
})
|
||
|
||
// v1.9.6 Sprint1(P0) - 缓存清理策略(保留最近100个条目,防止内存泄漏)
|
||
watch(dataSource, () => {
|
||
if (layoutCache.size > 100) {
|
||
const keysToDelete = Array.from(layoutCache.keys()).slice(0, layoutCache.size - 100)
|
||
keysToDelete.forEach(key => layoutCache.delete(key))
|
||
}
|
||
})
|
||
|
||
// v1.9.6 Sprint1(P0) - 获取单个资源的布局(按需计算+缓存)
|
||
const getResourceLayout = (resource: Resource) => {
|
||
const resourceTasks = (resource as any).tasks || []
|
||
|
||
// 🎯 修复:缓存key需要包含任务的时间信息,因为时间交汇会影响换行布局
|
||
// 生成任务时间范围的简单哈希(拼接所有任务的id-start-end)
|
||
const timeHash = resourceTasks
|
||
.map((t: Task) => `${t.id}-${t.startDate || ''}-${t.endDate || ''}`)
|
||
.join('|')
|
||
const cacheKey = `${resource.id}-${resourceTasks.length}-${timeHash}`
|
||
|
||
// 检查缓存
|
||
if (layoutCache.has(cacheKey)) {
|
||
return layoutCache.get(cacheKey)!
|
||
}
|
||
|
||
// 未命中缓存,重新计算
|
||
let result
|
||
if (resourceTasks.length > 0) {
|
||
result = assignTaskRows(resourceTasks, 51)
|
||
} else {
|
||
result = {
|
||
taskRowMap: new Map(),
|
||
rowHeights: [51],
|
||
totalHeight: 51,
|
||
}
|
||
}
|
||
|
||
layoutCache.set(cacheKey, result)
|
||
return result
|
||
}
|
||
|
||
// v1.9.6 Phase2 - 计算资源行的累积位置(懒加载优化:只计算到需要的位置)
|
||
let resourceRowPositionsCallCount = 0
|
||
const resourceRowPositions = computed(() => {
|
||
resourceRowPositionsCallCount++
|
||
const positions = new Map<string | number, number>()
|
||
|
||
if (viewMode.value !== 'resource') {
|
||
return positions
|
||
}
|
||
|
||
const resources = dataSource.value as Resource[]
|
||
const scrollTop = timelineBodyScrollTop.value
|
||
const containerHeight = timelineBodyHeight.value || 600
|
||
const scrollBottom = scrollTop + containerHeight + ROW_HEIGHT * VERTICAL_BUFFER * 2
|
||
|
||
let cumulativeTop = 0
|
||
let processedCount = 0
|
||
|
||
// 🎯 Phase2优化:懒加载计算,只计算到scrollBottom以下一定范围
|
||
// 而不是一次性计算所有100个资源
|
||
for (const resource of resources) {
|
||
positions.set(resource.id, cumulativeTop)
|
||
|
||
// 使用辅助函数获取布局(自动缓存)
|
||
const layout = getResourceLayout(resource)
|
||
const resourceHeight = layout.totalHeight || 51
|
||
cumulativeTop += resourceHeight
|
||
processedCount++
|
||
|
||
// 优化:如果已经计算到scrollBottom之下足够远,停止计算
|
||
// 保留一定余量,避免滚动时需要重新计算
|
||
if (cumulativeTop > scrollBottom + ROW_HEIGHT * 20) {
|
||
// 仍需继续计算剩余资源的近似位置(假设都是51px高度)
|
||
for (let i = processedCount; i < resources.length; i++) {
|
||
positions.set(resources[i].id, cumulativeTop) // 设置当前资源的位置
|
||
cumulativeTop += 51 // 累加位置,为下一个资源准备
|
||
}
|
||
break
|
||
}
|
||
}
|
||
|
||
return positions
|
||
})
|
||
|
||
// 获取以今天为中心的时间线范围(缓存结果,避免每次计算创建新对象)
|
||
const cachedTodayCenteredRange = (() => {
|
||
const today = new Date()
|
||
|
||
// 计算开始日期:今天往前6个月的月初
|
||
const startDate = new Date(today.getFullYear(), today.getMonth() - 6, 1)
|
||
|
||
// 计算结束日期:今天往后6个月的月末
|
||
const endDate = new Date(today.getFullYear(), today.getMonth() + 6 + 1, 0) // +1后再设为0,得到当月最后一天
|
||
|
||
return {
|
||
startDate,
|
||
endDate,
|
||
}
|
||
})()
|
||
|
||
const getTodayCenteredRange = () => cachedTodayCenteredRange
|
||
|
||
// 新增:接收外部传入的startDate和endDate
|
||
const timelineStartDate = computed(() => {
|
||
// 优先使用props.startDate
|
||
return props.startDate ? new Date(props.startDate) : getTodayCenteredRange().startDate
|
||
})
|
||
const timelineEndDate = computed(() => {
|
||
// 优先使用props.endDate
|
||
return props.endDate ? new Date(props.endDate) : getTodayCenteredRange().endDate
|
||
})
|
||
|
||
const timelineConfig = ref<TimelineConfig>({
|
||
startDate: timelineStartDate.value,
|
||
endDate: timelineEndDate.value,
|
||
zoomLevel: 1,
|
||
})
|
||
|
||
// 当前时间刻度
|
||
const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
|
||
|
||
// 响应外部props变化,动态更新timelineConfig
|
||
watch([timelineStartDate, timelineEndDate], ([newStart, newEnd]) => {
|
||
// 所有视图都正常响应props变化
|
||
if (props.startDate || props.endDate) {
|
||
if (!isUpdatingTimelineConfig) {
|
||
timelineConfig.value.startDate = newStart
|
||
timelineConfig.value.endDate = newEnd
|
||
}
|
||
}
|
||
})
|
||
// 优化:使用常量避免每次创建新空数组
|
||
const EMPTY_TASKS_ARRAY: Task[] = []
|
||
|
||
// v1.9.0 使用视图模式决定数据源:资源视图使用dataSource,任务视图使用props.tasks
|
||
const tasks = computed(() => {
|
||
if (viewMode.value === 'resource') {
|
||
// 资源视图:使用注入的 dataSource (Resource[])
|
||
return (dataSource.value || []) as unknown as Task[]
|
||
}
|
||
// 任务视图:使用 props.tasks
|
||
return props.tasks ?? EMPTY_TASKS_ARRAY
|
||
})
|
||
|
||
// DOM 元素缓存,避免重复查询
|
||
const timelineContainerElement = ref<HTMLElement | null>(null)
|
||
const timelinePanelElement = ref<HTMLElement | null>(null)
|
||
|
||
// 根据时间刻度计算每日宽度
|
||
const dayWidth = computed(() => {
|
||
if (currentTimeScale.value === TimelineScale.HOUR) {
|
||
// 小时视图:每小时40px,一天24小时,每天960px
|
||
return 960 // 24 * 40
|
||
} else if (currentTimeScale.value === TimelineScale.WEEK) {
|
||
// 周视图:每周60px,分7天,每天约8.57px
|
||
return 60 / 7
|
||
} else if (currentTimeScale.value === TimelineScale.MONTH) {
|
||
// 月视图:动态计算,基于当前月的实际天数
|
||
// 这里返回一个平均值,具体定位时会根据每个月的实际天数重新计算
|
||
return 2 // 月视图下每天约2px(60px/30天的平均值)
|
||
} else if (currentTimeScale.value === TimelineScale.QUARTER) {
|
||
// 季度视图:每季度60px,分90天(平均),每天约0.67px
|
||
return 60 / 90
|
||
} else if (currentTimeScale.value === TimelineScale.YEAR) {
|
||
// 年视图:每年360px,分365天,每天约0.99px
|
||
return 360 / 365
|
||
} else {
|
||
// 日视图:每天30px
|
||
return 30
|
||
}
|
||
})
|
||
|
||
type TaskDateRange = { minDate: Date; maxDate: Date } | null
|
||
let cachedTaskDateRange: TaskDateRange = null
|
||
|
||
const invalidateTaskDateRangeCache = () => {
|
||
cachedTaskDateRange = null
|
||
}
|
||
|
||
const computeTasksDateRange = (): TaskDateRange => {
|
||
if (!tasks.value || tasks.value.length === 0) {
|
||
return null
|
||
}
|
||
|
||
const dates: Date[] = []
|
||
|
||
const collectDatesFromTask = (task: Task) => {
|
||
if (task.startDate) {
|
||
dates.push(new Date(task.startDate))
|
||
}
|
||
if (task.endDate) {
|
||
dates.push(new Date(task.endDate))
|
||
}
|
||
|
||
if (task.children && task.children.length > 0) {
|
||
for (const child of task.children) {
|
||
collectDatesFromTask(child)
|
||
}
|
||
}
|
||
}
|
||
|
||
// v1.9.0 资源视图:从Resource.tasks中提取任务日期
|
||
if (viewMode.value === 'resource') {
|
||
for (const resource of tasks.value as any) {
|
||
if (resource.tasks && Array.isArray(resource.tasks)) {
|
||
for (const task of resource.tasks) {
|
||
collectDatesFromTask(task)
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// 任务视图:直接遍历任务
|
||
for (const task of tasks.value) {
|
||
collectDatesFromTask(task)
|
||
}
|
||
}
|
||
|
||
if (dates.length === 0) {
|
||
return null
|
||
}
|
||
|
||
let minTime = Infinity
|
||
let maxTime = -Infinity
|
||
for (const date of dates) {
|
||
const time = date.getTime()
|
||
if (!isNaN(time)) {
|
||
if (time < minTime) minTime = time
|
||
if (time > maxTime) maxTime = time
|
||
}
|
||
}
|
||
|
||
if (minTime === Infinity || maxTime === -Infinity) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
minDate: new Date(minTime),
|
||
maxDate: new Date(maxTime),
|
||
}
|
||
}
|
||
|
||
// 获取任务数据的日期范围(用于月度/年度视图时间轴范围计算)
|
||
const getTasksDateRange = () => {
|
||
if (cachedTaskDateRange) {
|
||
return cachedTaskDateRange
|
||
}
|
||
|
||
cachedTaskDateRange = computeTasksDateRange()
|
||
return cachedTaskDateRange
|
||
}
|
||
|
||
// 获取小时视图的时间范围
|
||
const getHourTimelineRange = () => {
|
||
const taskRange = getTasksDateRange()
|
||
const containerWidth = timelineContainerWidth.value || 1200
|
||
const hourWidth = 40 // 小时视图:每小时40px
|
||
|
||
if (!taskRange) {
|
||
// 如果没有任务,只显示前一天、当天、后一天(共3天)
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
|
||
const startDate = new Date(today)
|
||
startDate.setDate(startDate.getDate() - 1)
|
||
|
||
const endDate = new Date(today)
|
||
endDate.setDate(endDate.getDate() + 1)
|
||
endDate.setHours(23, 59, 59, 999)
|
||
|
||
return { startDate, endDate }
|
||
}
|
||
|
||
const { minDate, maxDate } = taskRange
|
||
|
||
// 开始日期:任务最小开始日期的前一天0点
|
||
const startDate = new Date(minDate)
|
||
startDate.setHours(0, 0, 0, 0)
|
||
startDate.setDate(startDate.getDate() - 1)
|
||
|
||
// 结束日期:任务最大结束日期的后一天23:59:59
|
||
const endDate = new Date(maxDate)
|
||
endDate.setHours(23, 59, 59, 999)
|
||
endDate.setDate(endDate.getDate() + 1)
|
||
|
||
// 计算当前范围需要的宽度
|
||
const hoursDiff = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60))
|
||
const currentWidth = hoursDiff * hourWidth
|
||
|
||
// 如果当前宽度小于容器宽度,按天扩展范围
|
||
if (currentWidth < containerWidth) {
|
||
const hoursNeeded = Math.ceil(containerWidth / hourWidth)
|
||
const additionalHours = Math.ceil((hoursNeeded - hoursDiff) / 2)
|
||
const additionalDays = Math.ceil(additionalHours / 24)
|
||
|
||
// 向前扩展
|
||
startDate.setDate(startDate.getDate() - additionalDays)
|
||
|
||
// 向后扩展
|
||
endDate.setDate(endDate.getDate() + additionalDays)
|
||
}
|
||
|
||
return { startDate, endDate }
|
||
}
|
||
|
||
// 获取日视图的时间范围
|
||
const getDayTimelineRange = () => {
|
||
const taskRange = getTasksDateRange()
|
||
const containerWidth = timelineContainerWidth.value || 1200
|
||
const dayWidth = 30 // 日视图:每天30px
|
||
|
||
if (!taskRange) {
|
||
// 如果没有任务,根据容器宽度计算范围
|
||
const today = new Date()
|
||
const daysNeeded = Math.max(Math.ceil(containerWidth / dayWidth), 60) // 至少60天
|
||
|
||
const startDate = new Date(today)
|
||
startDate.setDate(startDate.getDate() - Math.floor(daysNeeded / 2))
|
||
|
||
const endDate = new Date(today)
|
||
endDate.setDate(endDate.getDate() + Math.ceil(daysNeeded / 2))
|
||
|
||
return { startDate, endDate }
|
||
}
|
||
|
||
const { minDate, maxDate } = taskRange
|
||
|
||
// 开始日期:任务最小开始日期-30天
|
||
let startDate = new Date(minDate)
|
||
startDate.setDate(startDate.getDate() - 30)
|
||
|
||
// 结束日期:任务最大结束日期+30天
|
||
let endDate = new Date(maxDate)
|
||
endDate.setDate(endDate.getDate() + 30)
|
||
|
||
// 计算当前范围需要的宽度
|
||
const daysDiff = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24))
|
||
const currentWidth = daysDiff * dayWidth
|
||
|
||
// 如果当前宽度小于容器宽度,扩展范围
|
||
if (currentWidth < containerWidth) {
|
||
const daysNeeded = Math.ceil(containerWidth / dayWidth)
|
||
const additionalDays = Math.ceil((daysNeeded - daysDiff) / 2)
|
||
|
||
// 向前扩展
|
||
const newStartDate = new Date(startDate)
|
||
newStartDate.setDate(newStartDate.getDate() - additionalDays)
|
||
startDate = newStartDate
|
||
|
||
// 向后扩展
|
||
const newEndDate = new Date(endDate)
|
||
newEndDate.setDate(newEndDate.getDate() + additionalDays)
|
||
endDate = newEndDate
|
||
}
|
||
|
||
return { startDate, endDate }
|
||
}
|
||
|
||
// 获取周视图的时间范围
|
||
const getWeekTimelineRange = () => {
|
||
const taskRange = getTasksDateRange()
|
||
const containerWidth = timelineContainerWidth.value || 1200
|
||
const weekWidth = 60 // 周视图:每周60px
|
||
|
||
if (!taskRange) {
|
||
// 如果没有任务,根据容器宽度计算范围
|
||
const today = new Date()
|
||
const weeksNeeded = Math.max(Math.ceil(containerWidth / weekWidth), 20) // 向上取整确保填满,至少20周
|
||
|
||
const startDate = new Date(today)
|
||
startDate.setDate(startDate.getDate() - Math.floor(weeksNeeded / 2) * 7)
|
||
|
||
const endDate = new Date(today)
|
||
endDate.setDate(endDate.getDate() + Math.ceil(weeksNeeded / 2) * 7)
|
||
|
||
return { startDate, endDate }
|
||
}
|
||
|
||
const { minDate, maxDate } = taskRange
|
||
|
||
// 工具函数:获取某日期所在周的周一
|
||
function getMonday(date: Date) {
|
||
const d = new Date(date)
|
||
const day = d.getDay() || 7
|
||
d.setDate(d.getDate() - (day - 1))
|
||
return d
|
||
}
|
||
|
||
// 工具函数:获取某月第一个周一在该月的周(该月最小日期作为周一的周)
|
||
function getFirstMondayOfMonth(year: number, month: number): Date {
|
||
let day = 1
|
||
while (day <= 31) {
|
||
const date = new Date(year, month, day)
|
||
if (date.getMonth() !== month) break // 超出该月
|
||
const monday = getMonday(date)
|
||
if (monday.getMonth() === month) {
|
||
return monday
|
||
}
|
||
day++
|
||
}
|
||
return new Date(year, month, 1) // 兜底
|
||
}
|
||
|
||
// 工具函数:获取某月最后一个周一在该月的周(该月最大日期作为周一的周)
|
||
function getLastMondayOfMonth(year: number, month: number): Date {
|
||
const lastDay = new Date(year, month + 1, 0).getDate()
|
||
for (let day = lastDay; day >= 1; day--) {
|
||
const date = new Date(year, month, day)
|
||
const monday = getMonday(date)
|
||
if (monday.getMonth() === month) {
|
||
return monday
|
||
}
|
||
}
|
||
return new Date(year, month, 1) // 兜底
|
||
}
|
||
|
||
// 工具函数:获取某月的所有周(周一在该月的周)
|
||
function getWeeksOfMonth(year: number, month: number): Date[] {
|
||
const weeks: Date[] = []
|
||
const firstMonday = getFirstMondayOfMonth(year, month)
|
||
const lastMonday = getLastMondayOfMonth(year, month)
|
||
const current = new Date(firstMonday)
|
||
while (current <= lastMonday) {
|
||
weeks.push(new Date(current))
|
||
current.setDate(current.getDate() + 7)
|
||
}
|
||
return weeks
|
||
}
|
||
|
||
// 1. 获取最早TaskBar/Milestone的最小开始日期所在月份的第一周周一
|
||
const minMonday = getMonday(minDate)
|
||
const minYear = minMonday.getFullYear()
|
||
const minMonth = minMonday.getMonth()
|
||
|
||
// 2. 基于第一周周一往前追加一个完整月份的周数作为baseBuffer
|
||
const prevMonth = minMonth === 0 ? 11 : minMonth - 1
|
||
const prevYear = minMonth === 0 ? minYear - 1 : minYear
|
||
const prevMonthWeeks = getWeeksOfMonth(prevYear, prevMonth)
|
||
|
||
// 3. 获取最晚TaskBar/Milestone的最大开始日期所在月份的最后一周周一
|
||
const maxMonday = getMonday(maxDate)
|
||
const maxYear = maxMonday.getFullYear()
|
||
const maxMonth = maxMonday.getMonth()
|
||
|
||
// 4. 基于最后一周周日往后追加一个完整月份的周数作为baseBuffer
|
||
const nextMonth = maxMonth === 11 ? 0 : maxMonth + 1
|
||
const nextYear = maxMonth === 11 ? maxYear + 1 : maxYear
|
||
const nextMonthWeeks = getWeeksOfMonth(nextYear, nextMonth)
|
||
|
||
// 初始weeks:前buffer月 + 最小月到最大月之间所有月 + 后buffer月
|
||
let weeks: Date[] = []
|
||
|
||
// 添加前buffer月
|
||
weeks.push(...prevMonthWeeks)
|
||
|
||
// 添加最小月到最大月之间的所有月份的周
|
||
let currentYear = minYear
|
||
let currentMonth = minMonth
|
||
while (currentYear < maxYear || (currentYear === maxYear && currentMonth <= maxMonth)) {
|
||
const monthWeeks = getWeeksOfMonth(currentYear, currentMonth)
|
||
weeks.push(...monthWeeks)
|
||
currentMonth++
|
||
if (currentMonth > 11) {
|
||
currentMonth = 0
|
||
currentYear++
|
||
}
|
||
}
|
||
|
||
// 添加后buffer月
|
||
weeks.push(...nextMonthWeeks)
|
||
|
||
// 5. 判断是否填满容器,不够则继续扩展前后的完整月份
|
||
let totalWidth = weeks.length * weekWidth
|
||
while (totalWidth < containerWidth) {
|
||
// 前面扩展一个完整月
|
||
const firstWeek = weeks[0]
|
||
const firstYear = firstWeek.getFullYear()
|
||
const firstMonth = firstWeek.getMonth()
|
||
const extendPrevMonth = firstMonth === 0 ? 11 : firstMonth - 1
|
||
const extendPrevYear = firstMonth === 0 ? firstYear - 1 : firstYear
|
||
const extendPrevWeeks = getWeeksOfMonth(extendPrevYear, extendPrevMonth)
|
||
weeks = [...extendPrevWeeks, ...weeks]
|
||
totalWidth = weeks.length * weekWidth
|
||
|
||
if (totalWidth >= containerWidth) break
|
||
|
||
// 后面扩展一个完整月
|
||
const lastWeek = weeks[weeks.length - 1]
|
||
const lastYear = lastWeek.getFullYear()
|
||
const lastMonth = lastWeek.getMonth()
|
||
const extendNextMonth = lastMonth === 11 ? 0 : lastMonth + 1
|
||
const extendNextYear = lastMonth === 11 ? lastYear + 1 : lastYear
|
||
const extendNextWeeks = getWeeksOfMonth(extendNextYear, extendNextMonth)
|
||
weeks = [...weeks, ...extendNextWeeks]
|
||
totalWidth = weeks.length * weekWidth
|
||
}
|
||
|
||
// 6. 计算最终 startDate/endDate
|
||
const startDate = new Date(weeks[0])
|
||
const endDate = new Date(weeks[weeks.length - 1])
|
||
endDate.setDate(endDate.getDate() + 6) // 该周的周日
|
||
|
||
return { startDate, endDate }
|
||
}
|
||
|
||
// 获取月度视图的时间范围(任务最小开始日期-2年 ~ 任务最大结束日期+2年)
|
||
const getMonthTimelineRange = () => {
|
||
const taskRange = getTasksDateRange()
|
||
if (!taskRange) {
|
||
// 如果没有任务,使用当前日期为中心的范围
|
||
const today = new Date()
|
||
const startDate = new Date(today.getFullYear() - 1, 0, 1) // 当前年-1年的1月1日
|
||
const endDate = new Date(today.getFullYear() + 1, 11, 31) // 当前年+1年的12月31日
|
||
return { startDate, endDate }
|
||
}
|
||
|
||
const { minDate, maxDate } = taskRange
|
||
const containerWidth = timelineContainerWidth.value || 1200 // 默认1200px
|
||
|
||
// 开始日期:任务最小开始日期-1年,月初
|
||
let startDate = new Date(minDate.getFullYear() - 1, 0, 1)
|
||
|
||
// 结束日期:任务最大结束日期+1年,月末
|
||
let endDate = new Date(maxDate.getFullYear() + 1, 11, 31)
|
||
|
||
// 计算当前范围的月数
|
||
const yearsDiff = endDate.getFullYear() - startDate.getFullYear()
|
||
const monthsDiff = yearsDiff * 12 + (endDate.getMonth() - startDate.getMonth())
|
||
|
||
// 月视图:每月60px
|
||
const monthWidth = 60
|
||
const currentWidth = monthsDiff * monthWidth
|
||
|
||
// 如果当前宽度小于容器宽度,扩展范围
|
||
if (currentWidth < containerWidth) {
|
||
const monthsNeeded = Math.ceil(containerWidth / monthWidth)
|
||
const additionalMonths = Math.ceil((monthsNeeded - monthsDiff) / 2)
|
||
|
||
// 向前扩展
|
||
const newStartDate = new Date(startDate)
|
||
newStartDate.setMonth(newStartDate.getMonth() - additionalMonths)
|
||
startDate = new Date(newStartDate.getFullYear(), newStartDate.getMonth(), 1)
|
||
|
||
// 向后扩展
|
||
const newEndDate = new Date(endDate)
|
||
newEndDate.setMonth(newEndDate.getMonth() + additionalMonths)
|
||
endDate = new Date(newEndDate.getFullYear(), newEndDate.getMonth() + 1, 0)
|
||
}
|
||
|
||
return { startDate, endDate }
|
||
}
|
||
|
||
// 获取年度视图的时间范围(任务最小开始日期-1年 ~ 任务最大结束日期+1年)
|
||
const getYearTimelineRange = () => {
|
||
const taskRange = getTasksDateRange()
|
||
|
||
if (!taskRange) {
|
||
// 如果没有任务,根据容器宽度和时间刻度动态计算范围
|
||
const today = new Date()
|
||
const containerWidth = timelineContainerWidth.value || 1200 // 默认1200px
|
||
|
||
let yearsNeeded = 3 // 默认至少3年
|
||
|
||
if (currentTimeScale.value === TimelineScale.QUARTER) {
|
||
// 季度视图:每季度60px,每年4个季度 = 240px
|
||
const quarterWidth = 60
|
||
const yearWidth = quarterWidth * 4 // 240px
|
||
yearsNeeded = Math.max(Math.ceil(containerWidth / yearWidth) + 1, 5) // 至少5年
|
||
} else if (currentTimeScale.value === TimelineScale.YEAR) {
|
||
// 年度视图:每年360px
|
||
const yearWidth = 360
|
||
yearsNeeded = Math.max(Math.ceil(containerWidth / yearWidth) + 1, 5) // 至少5年
|
||
}
|
||
|
||
const startYear = today.getFullYear() - Math.floor(yearsNeeded / 2)
|
||
const endYear = startYear + yearsNeeded - 1
|
||
|
||
const startDate = new Date(startYear, 0, 1)
|
||
const endDate = new Date(endYear, 11, 31)
|
||
|
||
return { startDate, endDate }
|
||
}
|
||
|
||
const { minDate, maxDate } = taskRange
|
||
const containerWidth = timelineContainerWidth.value || 1200 // 默认1200px
|
||
|
||
// 开始日期:任务最小开始日期-1年,年初
|
||
let startDate = new Date(minDate.getFullYear() - 1, 0, 1)
|
||
|
||
// 结束日期:任务最大结束日期+1年,年末
|
||
let endDate = new Date(maxDate.getFullYear() + 1, 11, 31)
|
||
|
||
// 计算当前范围需要的宽度
|
||
const yearsDiff = endDate.getFullYear() - startDate.getFullYear() + 1
|
||
|
||
if (currentTimeScale.value === TimelineScale.QUARTER) {
|
||
// 季度视图:每季度60px,每年4个季度 = 240px
|
||
const quarterWidth = 60
|
||
const yearWidth = quarterWidth * 4 // 240px
|
||
const currentWidth = yearsDiff * yearWidth
|
||
|
||
// 如果当前宽度小于容器宽度,扩展范围
|
||
if (currentWidth < containerWidth) {
|
||
const yearsNeeded = Math.ceil(containerWidth / yearWidth)
|
||
const additionalYears = Math.ceil((yearsNeeded - yearsDiff) / 2)
|
||
|
||
startDate = new Date(startDate.getFullYear() - additionalYears, 0, 1)
|
||
endDate = new Date(endDate.getFullYear() + additionalYears, 11, 31)
|
||
}
|
||
} else if (currentTimeScale.value === TimelineScale.YEAR) {
|
||
// 年度视图:每年360px
|
||
const yearWidth = 360
|
||
const currentWidth = yearsDiff * yearWidth
|
||
|
||
// 如果当前宽度小于容器宽度,扩展范围
|
||
if (currentWidth < containerWidth) {
|
||
const yearsNeeded = Math.ceil(containerWidth / yearWidth)
|
||
const additionalYears = Math.ceil((yearsNeeded - yearsDiff) / 2)
|
||
|
||
startDate = new Date(startDate.getFullYear() - additionalYears, 0, 1)
|
||
endDate = new Date(endDate.getFullYear() + additionalYears, 11, 31)
|
||
}
|
||
}
|
||
|
||
return { startDate, endDate }
|
||
}
|
||
|
||
// 悬停状态管理
|
||
const hoveredTaskId = ref<number | string | null>(null)
|
||
|
||
// 高亮状态管理(用于长按高亮功能)
|
||
const highlightedTaskId = ref<number | null>(null)
|
||
const highlightedTaskIds = ref<Set<number>>(new Set())
|
||
|
||
// 计算是否处于高亮模式(禁用所有交互)
|
||
const isInHighlightMode = computed(() => highlightedTaskId.value !== null)
|
||
|
||
// 清除高亮状态
|
||
const clearHighlight = () => {
|
||
highlightedTaskId.value = null
|
||
highlightedTaskIds.value.clear()
|
||
}
|
||
|
||
// 设置高亮任务(包括前置和后置任务)
|
||
const setHighlightTask = (taskId: number) => {
|
||
highlightedTaskId.value = taskId
|
||
highlightedTaskIds.value.clear()
|
||
highlightedTaskIds.value.add(taskId)
|
||
|
||
// 查找当前任务
|
||
const currentTask = tasks.value.find(t => t.id === taskId)
|
||
if (!currentTask) {
|
||
return
|
||
}
|
||
|
||
// 添加所有前置任务(从 predecessor 字符串解析)
|
||
if (currentTask.predecessor) {
|
||
const predecessorIds = getPredecessorIds(currentTask.predecessor)
|
||
for (const id of predecessorIds) {
|
||
highlightedTaskIds.value.add(id)
|
||
}
|
||
}
|
||
|
||
// 添加所有后置任务(找到所有把当前任务作为前置任务的任务)
|
||
for (const task of tasks.value) {
|
||
if (task.predecessor) {
|
||
const taskPredecessorIds = getPredecessorIds(task.predecessor)
|
||
if (taskPredecessorIds.includes(taskId)) {
|
||
highlightedTaskIds.value.add(task.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== 连接线拖拽状态管理 ====================
|
||
const dragLinkMode = ref<'predecessor' | 'successor' | null>(null) // 当前拖拽模式
|
||
const linkDragSourceTask = shallowRef<Task | null>(null) // 拖拽起始任务(使用 shallowRef 优化性能)
|
||
const linkDragTargetTask = shallowRef<Task | null>(null) // 当前悬停的目标任务(使用 shallowRef 优化性能)
|
||
const isValidLinkTarget = ref(false) // 是否是有效的连接目标(保留用于 handleLinkDragEnd)
|
||
const linkValidationError = ref<string>('') // 连接验证失败的原因(保留用于兼容)
|
||
const linkAutoScrollInterval = ref<number | null>(null) // 自动滚动定时器
|
||
|
||
// 🚀 非响应式拖拽状态(用于高频更新,避免 Vue 响应式开销)
|
||
let nonReactiveIsValidTarget = false
|
||
let nonReactiveErrorMessage = ''
|
||
|
||
// 任务查找缓存 Map(优化性能,避免每次都遍历数组)
|
||
const taskIdMap = new Map<number, Task>()
|
||
|
||
// 性能监控开关(开发调试用)
|
||
const ENABLE_PERF_MONITOR = false
|
||
|
||
// 开始连接线拖拽
|
||
const handleLinkDragStart = (event: { task: Task; type: 'predecessor' | 'successor'; mouseEvent: MouseEvent }) => {
|
||
dragLinkMode.value = event.type
|
||
linkDragSourceTask.value = event.task
|
||
|
||
// 启动帧监控
|
||
if (ENABLE_PERF_MONITOR) {
|
||
perfMonitor.startFrameMonitor()
|
||
}
|
||
|
||
// 初始化鼠标坐标(使用非响应式版本)
|
||
updateLinkDragCoordinatesNonReactive(event.mouseEvent.clientX, event.mouseEvent.clientY)
|
||
|
||
linkDragTargetTask.value = null
|
||
isValidLinkTarget.value = false
|
||
linkValidationError.value = ''
|
||
// 🚀 重置非响应式状态
|
||
nonReactiveTargetTask = null
|
||
nonReactiveIsValidTarget = false
|
||
nonReactiveErrorMessage = ''
|
||
|
||
// 启动自动滚动检测
|
||
startLinkAutoScroll()
|
||
|
||
// 在 Timeline 层级添加全局鼠标监听器(防止 LinkAnchor 因虚拟滚动卸载导致拖拽中断)
|
||
document.addEventListener('mousemove', handleGlobalMouseMove)
|
||
document.addEventListener('mouseup', handleGlobalMouseUp)
|
||
}
|
||
|
||
// 🚀 优化:统一 RAF 调度(坐标更新 + 目标检测在同一帧处理)
|
||
let linkDragRafId: number | null = null
|
||
let pendingMouseX = 0
|
||
let pendingMouseY = 0
|
||
|
||
// 🚀 非响应式拖拽坐标(避免 Vue 响应式系统开销)
|
||
let currentDragX = 0
|
||
let currentDragY = 0
|
||
|
||
// 🚀 优化后的 RAF 回调:在一帧内批量处理坐标更新、目标检测和绘制
|
||
const processLinkDragFrame = () => {
|
||
linkDragRafId = null
|
||
|
||
if (ENABLE_PERF_MONITOR) {
|
||
const startTime = performance.now()
|
||
updateLinkDragCoordinatesNonReactive(pendingMouseX, pendingMouseY)
|
||
perfMonitor.recordLinkDragCoordUpdate(performance.now() - startTime)
|
||
} else {
|
||
updateLinkDragCoordinatesNonReactive(pendingMouseX, pendingMouseY)
|
||
}
|
||
|
||
if (ENABLE_PERF_MONITOR) {
|
||
const startTime = performance.now()
|
||
detectLinkTargetNonReactive(pendingMouseX, pendingMouseY)
|
||
perfMonitor.recordLinkDragTargetDetect(performance.now() - startTime)
|
||
} else {
|
||
detectLinkTargetNonReactive(pendingMouseX, pendingMouseY)
|
||
}
|
||
|
||
if (linkDragGuideRef.value && linkDragSourceTask.value) {
|
||
linkDragGuideRef.value.draw(
|
||
getLinkDragStartX(),
|
||
getLinkDragStartY(),
|
||
currentDragX,
|
||
currentDragY,
|
||
nonReactiveIsValidTarget,
|
||
nonReactiveErrorMessage,
|
||
)
|
||
}
|
||
}
|
||
|
||
// 全局鼠标移动处理(🚀 优化:使用 RAF 统一调度,避免每次 mousemove 都触发响应式更新)
|
||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||
if (!dragLinkMode.value) return
|
||
|
||
// 🚀 记录最新鼠标位置,但不立即更新
|
||
pendingMouseX = e.clientX
|
||
pendingMouseY = e.clientY
|
||
|
||
// 🚀 如果已有 RAF 待处理,跳过(下一帧会使用最新坐标)
|
||
if (linkDragRafId !== null) {
|
||
return
|
||
}
|
||
|
||
// 🚀 调度下一帧处理
|
||
linkDragRafId = requestAnimationFrame(processLinkDragFrame)
|
||
}
|
||
|
||
// 全局鼠标释放处理
|
||
const handleGlobalMouseUp = () => {
|
||
if (!dragLinkMode.value) return
|
||
|
||
// 停止帧监控
|
||
if (ENABLE_PERF_MONITOR) {
|
||
perfMonitor.stopFrameMonitor()
|
||
}
|
||
|
||
// 🚀 取消待处理的 RAF
|
||
if (linkDragRafId !== null) {
|
||
cancelAnimationFrame(linkDragRafId)
|
||
linkDragRafId = null
|
||
}
|
||
|
||
// 触发拖拽结束
|
||
if (linkDragSourceTask.value) {
|
||
handleLinkDragEnd({
|
||
task: linkDragSourceTask.value,
|
||
type: dragLinkMode.value,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 缓存 bodyContent 的位置信息(避免频繁调用 getBoundingClientRect)
|
||
let cachedBodyRect: DOMRect | null = null
|
||
let bodyRectCacheTime = 0
|
||
const BODY_RECT_CACHE_DURATION = 200 // 200ms 缓存(优化:增加缓存时间)
|
||
let bodyRectInvalidated = false // 缓存失效标记(滚动时失效)
|
||
|
||
// 🚀 坐标更新辅助函数(提取重复逻辑)
|
||
const updateCoordinates = (mouseX: number, mouseY: number): void => {
|
||
if (!bodyContentRef.value) {
|
||
currentDragX = mouseX
|
||
currentDragY = mouseY
|
||
return
|
||
}
|
||
|
||
const now = Date.now()
|
||
const shouldRefreshRect =
|
||
!cachedBodyRect || bodyRectInvalidated || now - bodyRectCacheTime > BODY_RECT_CACHE_DURATION
|
||
if (shouldRefreshRect) {
|
||
cachedBodyRect = bodyContentRef.value.getBoundingClientRect()
|
||
bodyRectCacheTime = now
|
||
bodyRectInvalidated = false
|
||
}
|
||
currentDragX = mouseX - cachedBodyRect!.left
|
||
currentDragY = mouseY - cachedBodyRect!.top
|
||
}
|
||
|
||
// 🚀 非响应式坐标更新(完全绕过 Vue 响应式系统)
|
||
const updateLinkDragCoordinatesNonReactive = (mouseX: number, mouseY: number) => {
|
||
updateCoordinates(mouseX, mouseY)
|
||
}
|
||
|
||
// 拖拽过程中更新鼠标位置(🚀 优化:使用统一 RAF 调度)
|
||
const handleLinkDragMove = (event: { mouseX: number; mouseY: number }) => {
|
||
// 🚀 记录最新鼠标位置
|
||
pendingMouseX = event.mouseX
|
||
pendingMouseY = event.mouseY
|
||
|
||
// 🚀 如果已有 RAF 待处理,跳过
|
||
if (linkDragRafId !== null) {
|
||
return
|
||
}
|
||
|
||
// 🚀 调度下一帧处理
|
||
linkDragRafId = requestAnimationFrame(processLinkDragFrame)
|
||
}
|
||
|
||
// 结束连接线拖拽
|
||
const handleLinkDragEnd = (event: { task: Task; type: 'predecessor' | 'successor' }) => {
|
||
// 清除缓存的 rect
|
||
cachedBodyRect = null
|
||
|
||
// 移除全局鼠标监听器
|
||
document.removeEventListener('mousemove', handleGlobalMouseMove)
|
||
document.removeEventListener('mouseup', handleGlobalMouseUp)
|
||
|
||
// 停止自动滚动
|
||
stopLinkAutoScroll()
|
||
|
||
// 🚀 清除 LinkDragGuide 画布
|
||
linkDragGuideRef.value?.clear()
|
||
|
||
// 如果有有效目标,创建连接
|
||
if (linkDragTargetTask.value && isValidLinkTarget.value) {
|
||
createLink(event.task, linkDragTargetTask.value, event.type)
|
||
}
|
||
|
||
// 重置拖拽状态
|
||
dragLinkMode.value = null
|
||
linkDragSourceTask.value = null
|
||
linkDragTargetTask.value = null
|
||
isValidLinkTarget.value = false
|
||
}
|
||
|
||
// 触点碰撞检测常量
|
||
const ANCHOR_SIZE = 8 // 触点视觉大小(px)
|
||
const ANCHOR_TOLERANCE = 4 // 碰撞容差(px),扩大点击区域
|
||
|
||
// 🚀 目标检测辅助函数(提取重复逻辑)
|
||
// 返回 { taskId: number, anchorType: 'left' | 'right' } 或 null
|
||
const detectTargetTaskId = (mouseX: number, mouseY: number): { taskId: number; anchorType: 'left' | 'right' } | null => {
|
||
// 使用缓存的 rect
|
||
if (!cachedBodyRect) {
|
||
cachedBodyRect = bodyContentRef.value!.getBoundingClientRect()
|
||
bodyRectCacheTime = Date.now()
|
||
}
|
||
|
||
const relativeX = mouseX - cachedBodyRect.left
|
||
const relativeY = mouseY - cachedBodyRect.top
|
||
|
||
const halfSize = (ANCHOR_SIZE + ANCHOR_TOLERANCE) / 2
|
||
const expandedHalfSize = halfSize + 10
|
||
|
||
for (const taskIdStr in taskBarPositions.value) {
|
||
const pos = taskBarPositions.value[taskIdStr]
|
||
const taskId = Number(taskIdStr)
|
||
|
||
if (
|
||
relativeY < pos.top - expandedHalfSize ||
|
||
relativeY > pos.top + pos.height + expandedHalfSize
|
||
) {
|
||
continue
|
||
}
|
||
|
||
const anchorY = pos.top + pos.height / 2
|
||
|
||
// 检查左侧触点(predecessor)
|
||
const leftAnchorX = pos.left
|
||
if (
|
||
relativeX >= leftAnchorX - halfSize &&
|
||
relativeX <= leftAnchorX + halfSize &&
|
||
relativeY >= anchorY - halfSize &&
|
||
relativeY <= anchorY + halfSize
|
||
) {
|
||
return { taskId, anchorType: 'left' }
|
||
}
|
||
|
||
// 检查右侧触点(successor)
|
||
const rightAnchorX = pos.left + pos.width
|
||
if (
|
||
relativeX >= rightAnchorX - halfSize &&
|
||
relativeX <= rightAnchorX + halfSize &&
|
||
relativeY >= anchorY - halfSize &&
|
||
relativeY <= anchorY + halfSize
|
||
) {
|
||
return { taskId, anchorType: 'right' }
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
// 🚀 更新目标任务的状态
|
||
const updateTargetTaskState = (foundTarget: Task | null, anchorType?: 'left' | 'right'): void => {
|
||
const currentTargetId = nonReactiveTargetTask?.id ?? null
|
||
const newTargetId = foundTarget?.id ?? null
|
||
|
||
if (currentTargetId !== newTargetId) {
|
||
nonReactiveTargetTask = foundTarget
|
||
|
||
if (foundTarget && linkDragSourceTask.value) {
|
||
const validation = validateLink(
|
||
linkDragSourceTask.value,
|
||
foundTarget,
|
||
dragLinkMode.value!,
|
||
anchorType,
|
||
)
|
||
nonReactiveIsValidTarget = validation.valid
|
||
nonReactiveErrorMessage = validation.error || ''
|
||
isValidLinkTarget.value = validation.valid
|
||
linkDragTargetTask.value = foundTarget
|
||
} else {
|
||
nonReactiveIsValidTarget = false
|
||
nonReactiveErrorMessage = ''
|
||
isValidLinkTarget.value = false
|
||
linkDragTargetTask.value = null
|
||
}
|
||
}
|
||
}
|
||
|
||
// 🚀 非响应式目标检测(完全绕过 Vue 响应式系统)
|
||
// 缓存当前检测到的目标任务(用于 handleLinkDragEnd)
|
||
let nonReactiveTargetTask: Task | null = null
|
||
|
||
const detectLinkTargetNonReactive = (mouseX: number, mouseY: number) => {
|
||
if (!linkDragSourceTask.value || !bodyContentRef.value) return
|
||
|
||
const result = detectTargetTaskId(mouseX, mouseY)
|
||
const foundTarget = result !== null ? taskIdMap.get(result.taskId) || null : null
|
||
const anchorType = result?.anchorType
|
||
|
||
updateTargetTaskState(foundTarget, anchorType)
|
||
}
|
||
|
||
// 验证连接是否有效(返回 { valid: boolean, error?: string })
|
||
const validateLink = (
|
||
sourceTask: Task,
|
||
targetTask: Task,
|
||
mode: 'predecessor' | 'successor',
|
||
targetAnchorType?: 'left' | 'right',
|
||
): { valid: boolean; error?: string } => {
|
||
// 0. 检查触点方向是否正确
|
||
if (targetAnchorType) {
|
||
if (mode === 'predecessor' && targetAnchorType !== 'right') {
|
||
return { valid: false, error: '前置任务应连接到右侧触点' }
|
||
}
|
||
if (mode === 'successor' && targetAnchorType !== 'left') {
|
||
return { valid: false, error: '后置任务应连接到左侧触点' }
|
||
}
|
||
}
|
||
|
||
// 1. 不能连接到自己
|
||
if (sourceTask.id === targetTask.id) {
|
||
return { valid: false, error: '不能连接到自己' }
|
||
}
|
||
|
||
// 2. 不能连接父级任务或里程碑
|
||
if (targetTask.isParent || targetTask.type === 'milestone') {
|
||
return { valid: false, error: '不能连接到父级任务或里程碑' }
|
||
}
|
||
|
||
// 3. 不能创建循环依赖
|
||
if (mode === 'predecessor') {
|
||
// predecessor模式:从B的左触点拖拽到A,要建立 A→B 的关系
|
||
// 检查:如果B已经(直接或间接)依赖A,会形成循环
|
||
if (hasCircularDependency(targetTask.id, sourceTask.id)) {
|
||
return { valid: false, error: '会产生循环依赖' }
|
||
}
|
||
} else {
|
||
// successor模式:从A的右触点拖拽到B,要建立 A→B 的关系
|
||
// 检查:如果A已经(直接或间接)依赖B,会形成循环
|
||
if (hasCircularDependency(sourceTask.id, targetTask.id)) {
|
||
return { valid: false, error: '会产生循环依赖' }
|
||
}
|
||
}
|
||
|
||
// 4. 检查是否已存在该连接
|
||
// 注意:A→B 的关系存储在 B.predecessor 中,值为 [A.id]
|
||
if (mode === 'predecessor') {
|
||
// predecessor模式:从B的左触点拖拽到A,要建立 A→B 的关系
|
||
// 检查 B.predecessor 中是否已包含 A.id
|
||
if (sourceTask.predecessor) {
|
||
const predecessorIds = getPredecessorIds(sourceTask.predecessor)
|
||
if (predecessorIds.includes(targetTask.id)) {
|
||
return { valid: false, error: '关系已存在' }
|
||
}
|
||
}
|
||
} else {
|
||
// successor模式:从A的右触点拖拽到B,要建立 A→B 的关系
|
||
// 检查 B.predecessor 中是否已包含 A.id
|
||
if (targetTask.predecessor) {
|
||
const predecessorIds = getPredecessorIds(targetTask.predecessor)
|
||
if (predecessorIds.includes(sourceTask.id)) {
|
||
return { valid: false, error: '关系已存在' }
|
||
}
|
||
}
|
||
}
|
||
|
||
return { valid: true }
|
||
}
|
||
|
||
// 检查循环依赖
|
||
const hasCircularDependency = (taskId: number, targetId: number): boolean => {
|
||
const visited = new Set<number>()
|
||
const queue: number[] = [taskId]
|
||
|
||
while (queue.length > 0) {
|
||
const currentId = queue.shift()!
|
||
if (currentId === targetId) {
|
||
return true // 找到循环依赖
|
||
}
|
||
|
||
if (visited.has(currentId)) {
|
||
continue
|
||
}
|
||
visited.add(currentId)
|
||
|
||
// 查找当前任务
|
||
const currentTask = tasks.value.find(t => t.id === currentId)
|
||
if (currentTask && currentTask.predecessor) {
|
||
const predecessorIds = getPredecessorIds(currentTask.predecessor)
|
||
queue.push(...predecessorIds)
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// 创建连接
|
||
const createLink = (sourceTask: Task, targetTask: Task, mode: 'predecessor' | 'successor') => {
|
||
if (mode === 'predecessor') {
|
||
// mode='predecessor' 表示从左侧 anchor 拖出
|
||
// 从左侧 anchor 拖到右侧:sourceTask 依赖 targetTask
|
||
// 实现:将 targetTask.id 添加到 sourceTask.predecessor 中
|
||
const predecessorIds = sourceTask.predecessor ? getPredecessorIds(sourceTask.predecessor) : []
|
||
if (!predecessorIds.includes(targetTask.id)) {
|
||
predecessorIds.push(targetTask.id)
|
||
sourceTask.predecessor = predecessorIds
|
||
// 触发任务更新事件
|
||
updateTask(sourceTask)
|
||
// 发射 predecessor-added 事件
|
||
emit('predecessor-added', { targetTask: sourceTask, newTask: targetTask })
|
||
}
|
||
} else {
|
||
// mode='successor' 表示从右侧 anchor 拖出
|
||
// 从右侧 anchor 拖到左侧:sourceTask 是 targetTask 的前置任务
|
||
// 实现:将 sourceTask.id 添加到 targetTask.predecessor 中
|
||
const predecessorIds = targetTask.predecessor ? getPredecessorIds(targetTask.predecessor) : []
|
||
if (!predecessorIds.includes(sourceTask.id)) {
|
||
predecessorIds.push(sourceTask.id)
|
||
targetTask.predecessor = predecessorIds
|
||
// 触发任务更新事件
|
||
updateTask(targetTask)
|
||
// 发射 successor-added 事件
|
||
emit('successor-added', { targetTask, newTask: sourceTask })
|
||
}
|
||
}
|
||
}
|
||
|
||
// 启动自动滚动检测
|
||
const startLinkAutoScroll = () => {
|
||
linkAutoScrollInterval.value = window.setInterval(() => {
|
||
if (!timelineContainerElement.value || !bodyContentRef.value) return
|
||
|
||
const horizontalContainer = timelineContainerElement.value // .timeline 负责横向滚动
|
||
const verticalContainer = timelineBodyElement.value // .timeline-body 负责纵向滚动
|
||
|
||
if (!verticalContainer) return
|
||
|
||
const rect = horizontalContainer.getBoundingClientRect()
|
||
const SCROLL_ZONE = 80 // 边缘滚动区域宽度(增大以提供更好的体验)
|
||
const SCROLL_SPEED = 15 // 滚动速度
|
||
|
||
// 获取鼠标在视口中的实际位置(使用非响应式坐标变量)
|
||
const mouseX = pendingMouseX // 直接使用最新的鼠标X坐标(视口坐标)
|
||
const mouseY = pendingMouseY // 直接使用最新的鼠标Y坐标(视口坐标)
|
||
|
||
let scrolled = false
|
||
|
||
// 检测水平滚动(使用 horizontalContainer)
|
||
if (mouseX < rect.left + SCROLL_ZONE && horizontalContainer.scrollLeft > 0) {
|
||
// 向左滚动
|
||
horizontalContainer.scrollLeft -= SCROLL_SPEED
|
||
scrolled = true
|
||
} else if (mouseX > rect.right - SCROLL_ZONE) {
|
||
// 向右滚动
|
||
const maxScrollLeft = horizontalContainer.scrollWidth - horizontalContainer.clientWidth
|
||
if (horizontalContainer.scrollLeft < maxScrollLeft) {
|
||
horizontalContainer.scrollLeft += SCROLL_SPEED
|
||
scrolled = true
|
||
}
|
||
}
|
||
|
||
// 检测垂直滚动(使用 verticalContainer)
|
||
if (mouseY < rect.top + SCROLL_ZONE && verticalContainer.scrollTop > 0) {
|
||
// 向上滚动
|
||
verticalContainer.scrollTop -= SCROLL_SPEED
|
||
scrolled = true
|
||
} else if (mouseY > rect.bottom - SCROLL_ZONE) {
|
||
// 向下滚动
|
||
const maxScrollTop = verticalContainer.scrollHeight - verticalContainer.clientHeight
|
||
if (verticalContainer.scrollTop < maxScrollTop) {
|
||
verticalContainer.scrollTop += SCROLL_SPEED
|
||
scrolled = true
|
||
}
|
||
}
|
||
|
||
// 如果发生了滚动,需要重新检测目标和更新引导线
|
||
if (scrolled) {
|
||
// 标记缓存失效(滚动时需要重新获取位置)
|
||
bodyRectInvalidated = true
|
||
|
||
// 触发重新检测目标(因为滚动可能改变了元素位置)
|
||
detectLinkTargetNonReactive(mouseX, mouseY)
|
||
|
||
// 更新引导线绘制
|
||
if (linkDragGuideRef.value && linkDragSourceTask.value) {
|
||
linkDragGuideRef.value.draw(
|
||
getLinkDragStartX(),
|
||
getLinkDragStartY(),
|
||
currentDragX,
|
||
currentDragY,
|
||
nonReactiveIsValidTarget,
|
||
nonReactiveErrorMessage,
|
||
)
|
||
}
|
||
}
|
||
}, 30) // 降低间隔以提供更流畅的滚动体验
|
||
}
|
||
|
||
// 停止自动滚动
|
||
const stopLinkAutoScroll = () => {
|
||
if (linkAutoScrollInterval.value !== null) {
|
||
clearInterval(linkAutoScrollInterval.value)
|
||
linkAutoScrollInterval.value = null
|
||
}
|
||
}
|
||
|
||
// 清理连接线拖拽状态(用于异常情况)
|
||
const cleanupLinkDrag = () => {
|
||
stopLinkAutoScroll()
|
||
|
||
// 清除缓存
|
||
cachedBodyRect = null
|
||
|
||
// 取消待处理的 RAF
|
||
if (linkDragRafId !== null) {
|
||
cancelAnimationFrame(linkDragRafId)
|
||
linkDragRafId = null
|
||
}
|
||
|
||
// 🚀 清除 LinkDragGuide 画布
|
||
linkDragGuideRef.value?.clear()
|
||
|
||
// 移除全局监听器
|
||
document.removeEventListener('mousemove', handleGlobalMouseMove)
|
||
document.removeEventListener('mouseup', handleGlobalMouseUp)
|
||
|
||
dragLinkMode.value = null
|
||
linkDragSourceTask.value = null
|
||
linkDragTargetTask.value = null
|
||
isValidLinkTarget.value = false
|
||
}
|
||
|
||
// 获取连接线拖拽起始点的X坐标
|
||
const getLinkDragStartX = (): number => {
|
||
if (!linkDragSourceTask.value) return 0
|
||
|
||
const position = taskBarPositions.value[linkDragSourceTask.value.id]
|
||
if (!position) return 0
|
||
|
||
// 根据拖拽模式决定起始点位置
|
||
if (dragLinkMode.value === 'predecessor') {
|
||
// 前置任务:从任务条左侧开始
|
||
return position.left
|
||
} else {
|
||
// 后置任务:从任务条右侧开始
|
||
return position.left + position.width
|
||
}
|
||
}
|
||
|
||
// 获取连接线拖拽起始点的Y坐标
|
||
const getLinkDragStartY = (): number => {
|
||
if (!linkDragSourceTask.value) return 0
|
||
|
||
const position = taskBarPositions.value[linkDragSourceTask.value.id]
|
||
if (!position) return 0
|
||
|
||
// 从任务条中心位置开始
|
||
return position.top + position.height / 2
|
||
}
|
||
|
||
// v1.9.5 P2-4优化 - 使用从GanttChart注入的Split Bar拖拽状态
|
||
// 替换本地的isSplitterDragging,使用统一的状态管理
|
||
const isSplitterDragging = isSplitBarDragging
|
||
|
||
// 滚动状态管理
|
||
const isScrolledLeft = ref(false)
|
||
const isScrolledRight = ref(false)
|
||
const scrollProgress = ref(0)
|
||
const isScrolling = ref(false)
|
||
let scrollTimeout: number | null = null
|
||
|
||
// 粘性效果所需的滚动位置信息
|
||
const timelineScrollLeft = ref(0)
|
||
const timelineContainerWidth = ref(0)
|
||
|
||
// 半圆气泡控制状态
|
||
const hideBubbles = ref(true) // 初始时隐藏半圆,等待初始滚动完成
|
||
const isInitialScrolling = ref(true) // 跟踪初始滚动状态
|
||
let hideBubblesTimeout: number | null = null // 半圆显示恢复定时器
|
||
|
||
// 虚拟滚动相关状态
|
||
const HOUR_WIDTH = 40 // 每小时40px
|
||
const VIRTUAL_BUFFER = 10 // 减少缓冲区以提升滑动性能
|
||
|
||
// 纵向虚拟滚动相关状态
|
||
const ROW_HEIGHT = 51 // 每行高度51px (50px + 1px border)
|
||
const VERTICAL_BUFFER = 5 // 纵向缓冲区行数
|
||
const timelineBodyScrollTop = ref(0) // 纵向滚动位置
|
||
const timelineBodyHeight = ref(0) // 容器高度状态管理
|
||
|
||
// v1.9.5 P2-3优化 - 智能缓存数据结构
|
||
interface TimelineCacheEntry {
|
||
data: unknown
|
||
timestamp: number
|
||
scale: TimelineScale
|
||
}
|
||
|
||
const timelineDataCache = new Map<string, TimelineCacheEntry>()
|
||
const CACHE_TTL = 60000 // 缓存有效期:1分钟
|
||
const MAX_CACHE_SIZE = 4 // 最多缓存4个刻度(日/周/月/年)
|
||
|
||
// 初始化状态
|
||
const isInitialLoad = ref(true)
|
||
|
||
// 计算小时视图的可视区域范围
|
||
const visibleHourRange = computed(() => {
|
||
if (currentTimeScale.value !== TimelineScale.HOUR) {
|
||
return { startHour: 0, endHour: 0 }
|
||
}
|
||
|
||
const scrollLeft = timelineScrollLeft.value
|
||
const containerWidth = timelineContainerWidth.value
|
||
|
||
// 首次加载时,使用更大的初始渲染范围
|
||
if (isInitialLoad.value && scrollLeft === 0) {
|
||
// 初始加载且在起始位置:显示开头的一周
|
||
return {
|
||
startHour: 0,
|
||
endHour: 168, // 一周 (7*24=168小时)
|
||
}
|
||
} else if (isInitialLoad.value) {
|
||
// 初始加载但不在起始位置:以今天为中心的一周范围
|
||
const today = new Date()
|
||
const timelineStart = timelineConfig.value.startDate
|
||
const todayHours = Math.floor((today.getTime() - timelineStart.getTime()) / (1000 * 60 * 60))
|
||
|
||
return {
|
||
startHour: Math.max(0, todayHours - 168), // 前一周 (7*24=168小时)
|
||
endHour: todayHours + 168, // 后一周
|
||
}
|
||
}
|
||
|
||
// 正常滚动状态:计算可视区域的开始和结束小时(相对于时间线开始的小时偏移)
|
||
const startHour = Math.floor(scrollLeft / HOUR_WIDTH) - VIRTUAL_BUFFER
|
||
const endHour = Math.ceil((scrollLeft + containerWidth) / HOUR_WIDTH) + VIRTUAL_BUFFER
|
||
|
||
return {
|
||
startHour: Math.max(0, startHour),
|
||
endHour: Math.max(startHour + 1, endHour),
|
||
}
|
||
})
|
||
|
||
// v1.9.6 Sprint2(P1) - 防抖的滚动位置(减少visibleTimeRange的触发频率)
|
||
const debouncedScrollLeft = ref(0)
|
||
const debouncedContainerWidth = ref(0)
|
||
let scrollDebounceTimer: number | null = null
|
||
let visibleTimeRangeCallCount = 0
|
||
|
||
watch([timelineScrollLeft, timelineContainerWidth], ([newScrollLeft, newWidth]) => {
|
||
if (scrollDebounceTimer) {
|
||
clearTimeout(scrollDebounceTimer)
|
||
}
|
||
|
||
scrollDebounceTimer = window.setTimeout(() => {
|
||
debouncedScrollLeft.value = newScrollLeft
|
||
debouncedContainerWidth.value = newWidth
|
||
scrollDebounceTimer = null
|
||
}, 100) // 增加到100ms,减少触发频率
|
||
|
||
// 首次立即更新
|
||
if (debouncedScrollLeft.value === 0 && debouncedContainerWidth.value === 0) {
|
||
debouncedScrollLeft.value = newScrollLeft
|
||
debouncedContainerWidth.value = newWidth
|
||
}
|
||
}, { immediate: true })
|
||
|
||
// v1.9.5 P2-1优化 - 计算水平方向可见的时间范围(用于TaskBar过滤)
|
||
const visibleTimeRange = computed(() => {
|
||
visibleTimeRangeCallCount++
|
||
|
||
const scrollLeft = debouncedScrollLeft.value
|
||
const containerWidth = debouncedContainerWidth.value || timelineContainerWidth.value
|
||
// v1.9.6 Sprint2(P1) - 根据时间刻度动态调整缓冲区,避免月/季/年视图可见范围过大
|
||
let bufferRatio = 0.3
|
||
if (currentTimeScale.value === TimelineScale.WEEK) {
|
||
bufferRatio = 0.15
|
||
} else if (
|
||
currentTimeScale.value === TimelineScale.MONTH ||
|
||
currentTimeScale.value === TimelineScale.QUARTER ||
|
||
currentTimeScale.value === TimelineScale.YEAR
|
||
) {
|
||
bufferRatio = 0.05
|
||
}
|
||
const bufferWidth = containerWidth * bufferRatio
|
||
|
||
// 根据当前时间刻度和scrollLeft计算可见的开始日期
|
||
const startDate = getDateByScrollPosition(Math.max(0, scrollLeft - bufferWidth))
|
||
// 计算可见的结束日期
|
||
const endDate = getDateByScrollPosition(scrollLeft + containerWidth + bufferWidth)
|
||
|
||
return { startDate, endDate }
|
||
})
|
||
|
||
// v1.9.5 P2-1优化 - 根据滚动位置计算日期(v1.9.6 Sprint2 优化 - 使用timelineData精确计算)
|
||
const getDateByScrollPosition = (scrollPosition: number): Date => {
|
||
const timelineStart = timelineConfig.value.startDate
|
||
const timelineEnd = timelineConfig.value.endDate
|
||
const scale = currentTimeScale.value
|
||
|
||
// v1.9.6 Sprint2 修复:边界处理
|
||
if (scrollPosition <= 0) {
|
||
return timelineStart
|
||
}
|
||
|
||
if (scale === TimelineScale.HOUR) {
|
||
// 小时视图:每小时40px
|
||
const hours = scrollPosition / 40
|
||
return new Date(timelineStart.getTime() + hours * 60 * 60 * 1000)
|
||
} else if (scale === TimelineScale.DAY) {
|
||
// 日视图:每天30px
|
||
const days = scrollPosition / 30
|
||
return new Date(timelineStart.getTime() + days * 24 * 60 * 60 * 1000)
|
||
} else if (scale === TimelineScale.WEEK || scale === TimelineScale.MONTH || scale === TimelineScale.QUARTER || scale === TimelineScale.YEAR) {
|
||
// v1.9.6 Sprint2(P1) - 周/月/季/年视图:使用timelineData精确计算(避免累积误差)
|
||
const data = timelineData.value as any
|
||
if (!data || data.length === 0) {
|
||
return timelineStart
|
||
}
|
||
|
||
let cumulativePosition = 0
|
||
|
||
if (scale === TimelineScale.WEEK) {
|
||
// 周视图:遍历weeks找到对应位置
|
||
for (const periodData of data) {
|
||
const weeks = periodData.weeks || []
|
||
for (const week of weeks) {
|
||
const weekWidth = 60
|
||
if (scrollPosition >= cumulativePosition && scrollPosition < cumulativePosition + weekWidth) {
|
||
// 找到目标周,计算周内具体日期
|
||
const offsetInWeek = scrollPosition - cumulativePosition
|
||
const dayWidth = weekWidth / 7
|
||
const dayIndex = Math.floor(offsetInWeek / dayWidth)
|
||
const subDays = week.subDays || []
|
||
if (dayIndex < subDays.length) {
|
||
return new Date(subDays[dayIndex].date)
|
||
}
|
||
return new Date(week.weekStart)
|
||
}
|
||
cumulativePosition += weekWidth
|
||
}
|
||
}
|
||
} else if (scale === TimelineScale.MONTH) {
|
||
// 月视图:遍历每个月找到对应位置
|
||
for (const periodData of data) {
|
||
const monthWidth = 60
|
||
if (scrollPosition >= cumulativePosition && scrollPosition < cumulativePosition + monthWidth) {
|
||
// 找到目标月,计算月内具体日期
|
||
const offsetInMonth = scrollPosition - cumulativePosition
|
||
const daysInMonth = periodData.monthData?.dayCount || 30
|
||
const dayWidth = monthWidth / daysInMonth
|
||
const dayIndex = Math.floor(offsetInMonth / dayWidth)
|
||
return new Date(periodData.year, periodData.month - 1, dayIndex + 1)
|
||
}
|
||
cumulativePosition += monthWidth
|
||
}
|
||
} else if (scale === TimelineScale.QUARTER) {
|
||
// 季度视图:遍历quarters找到对应位置
|
||
for (const periodData of data) {
|
||
const quarters = (periodData as any).quarters || []
|
||
for (const quarter of quarters) {
|
||
const quarterWidth = 60
|
||
if (scrollPosition >= cumulativePosition && scrollPosition < cumulativePosition + quarterWidth) {
|
||
// 找到目标季度,计算季度内具体日期
|
||
const offsetInQuarter = scrollPosition - cumulativePosition
|
||
const quarterStart = new Date(quarter.startDate)
|
||
const quarterEnd = new Date(quarter.endDate)
|
||
const daysInQuarter = Math.ceil((quarterEnd.getTime() - quarterStart.getTime()) / (1000 * 60 * 60 * 24))
|
||
const dayWidth = quarterWidth / daysInQuarter
|
||
const dayOffset = Math.floor(offsetInQuarter / dayWidth)
|
||
return new Date(quarterStart.getTime() + dayOffset * 24 * 60 * 60 * 1000)
|
||
}
|
||
cumulativePosition += quarterWidth
|
||
}
|
||
}
|
||
} else if (scale === TimelineScale.YEAR) {
|
||
// 年视图:遍历halfYears找到对应位置
|
||
for (const periodData of data) {
|
||
const halfYears = (periodData as any).halfYears || []
|
||
for (const halfYear of halfYears) {
|
||
const halfYearWidth = 180
|
||
if (scrollPosition >= cumulativePosition && scrollPosition < cumulativePosition + halfYearWidth) {
|
||
// 找到目标半年,计算半年内具体日期
|
||
const offsetInHalfYear = scrollPosition - cumulativePosition
|
||
const halfYearStart = new Date(halfYear.startDate)
|
||
const halfYearEnd = new Date(halfYear.endDate)
|
||
const daysInHalfYear = Math.ceil((halfYearEnd.getTime() - halfYearStart.getTime()) / (1000 * 60 * 60 * 24))
|
||
const dayWidth = halfYearWidth / daysInHalfYear
|
||
const dayOffset = Math.floor(offsetInHalfYear / dayWidth)
|
||
return new Date(halfYearStart.getTime() + dayOffset * 24 * 60 * 60 * 1000)
|
||
}
|
||
cumulativePosition += halfYearWidth
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果没找到匹配位置,返回最后一个日期
|
||
console.warn(`[Sprint2-Debug] scrollPosition=${scrollPosition} exceeds timelineData range (cumulative=${cumulativePosition}), returning end date`)
|
||
return timelineEnd
|
||
}
|
||
|
||
// 默认返回开始日期
|
||
return timelineStart
|
||
}
|
||
|
||
// 计算纵向可视区域的任务范围(支持动态行高)
|
||
let visibleTaskRangeCallCount = 0
|
||
const visibleTaskRange = computed(() => {
|
||
visibleTaskRangeCallCount++
|
||
const scrollTop = timelineBodyScrollTop.value
|
||
const containerHeight = timelineBodyHeight.value || 600
|
||
|
||
if (viewMode.value === 'resource') {
|
||
// 资源视图:基于资源行的实际高度计算可见范围
|
||
const resources = dataSource.value as Resource[]
|
||
|
||
let startIndex = 0
|
||
let endIndex = resources.length
|
||
|
||
// 找到第一个可见的资源行
|
||
for (let i = 0; i < resources.length; i++) {
|
||
const resourceId = resources[i].id
|
||
const rowTop = resourceRowPositions.value?.get(resourceId) || 0
|
||
// 🎯 使用辅助函数获取布局,避免循环依赖
|
||
const layout = getResourceLayout(resources[i])
|
||
const rowHeight = layout?.totalHeight || ROW_HEIGHT
|
||
const rowBottom = rowTop + rowHeight
|
||
|
||
if (rowBottom >= scrollTop - ROW_HEIGHT * VERTICAL_BUFFER) {
|
||
startIndex = i
|
||
break
|
||
}
|
||
}
|
||
|
||
// 找到最后一个可见的资源行
|
||
const scrollBottom = scrollTop + containerHeight
|
||
for (let i = startIndex; i < resources.length; i++) {
|
||
const resourceId = resources[i].id
|
||
const rowTop = resourceRowPositions.value?.get(resourceId) || 0
|
||
|
||
if (rowTop > scrollBottom + ROW_HEIGHT * VERTICAL_BUFFER) {
|
||
endIndex = i
|
||
break
|
||
}
|
||
}
|
||
|
||
return {
|
||
startIndex: Math.max(0, startIndex),
|
||
endIndex: Math.min(resources.length, endIndex),
|
||
}
|
||
} else {
|
||
// 任务视图:使用固定行高计算
|
||
const startIndex = Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER
|
||
const endIndex = Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER
|
||
|
||
return {
|
||
startIndex: Math.max(0, startIndex),
|
||
endIndex: Math.min(tasks.value.length, Math.max(startIndex + 1, endIndex)),
|
||
}
|
||
}
|
||
})
|
||
|
||
// 获取虚拟滚动优化后的可见任务列表
|
||
const visibleTasks = computed(() => {
|
||
const { startIndex, endIndex } = visibleTaskRange.value
|
||
|
||
// 任务视图:返回可见的任务列表
|
||
const verticallyVisibleTasks = tasks.value.slice(startIndex, endIndex).map((task, index) => ({
|
||
task,
|
||
originalIndex: startIndex + index,
|
||
}))
|
||
|
||
// v1.9.6 Sprint2 修复:任务视图需要保留边界磁吸气泡,因此不做水平过滤
|
||
return verticallyVisibleTasks
|
||
})
|
||
|
||
// v1.9.0 资源视图的可见资源列表
|
||
const visibleResources = computed(() => {
|
||
const { startIndex, endIndex } = visibleTaskRange.value
|
||
|
||
if (viewMode.value !== 'resource') {
|
||
return []
|
||
}
|
||
|
||
const resources = dataSource.value as Resource[]
|
||
return resources.slice(startIndex, endIndex).map((resource, index) => ({
|
||
resource,
|
||
originalIndex: startIndex + index,
|
||
}))
|
||
})
|
||
|
||
// v1.9.6 Sprint2(P1+P4) - 资源视图TaskBar分批渲染队列(优化:批次从20改为5)
|
||
const RESOURCE_BATCH_SIZE = 5 // 方案4: 更小批次,从20改为5
|
||
const FRAME_TIME_BUDGET = 8 // 每帧最多8ms用于TaskBar渲染
|
||
|
||
// v1.9.6 Sprint2(P5) - TaskBar渲染缓存机制:避免重复渲染已有TaskBar
|
||
type TaskBarRenderCache = {
|
||
taskId: string | number
|
||
resourceId: string | number
|
||
rendered: boolean // 是否已经渲染过DOM
|
||
timestamp: number // 最后渲染时间
|
||
}
|
||
|
||
const taskBarRenderCache = shallowRef(new Map<string, TaskBarRenderCache>()) // key: resourceId-taskId
|
||
|
||
type ResourceTaskQueue = {
|
||
tasks: Task[]
|
||
visibleCount: number
|
||
totalCount: number
|
||
originalTasks: Task[]
|
||
}
|
||
|
||
const resourceTaskQueues = shallowRef(new Map<string | number, ResourceTaskQueue>())
|
||
const resourceTaskRenderLimits = shallowRef(new Map<string | number, number>())
|
||
const resourceRenderPhase = ref<'visible' | 'background'>('visible')
|
||
let resourceBatchRafId: number | null = null
|
||
|
||
const stopResourceBatchRender = () => {
|
||
if (resourceBatchRafId !== null) {
|
||
cancelAnimationFrame(resourceBatchRafId)
|
||
resourceBatchRafId = null
|
||
}
|
||
}
|
||
|
||
const scheduleResourceBatchRender = () => {
|
||
stopResourceBatchRender()
|
||
|
||
const step = () => {
|
||
if (viewMode.value !== 'resource') {
|
||
resourceBatchRafId = null
|
||
return
|
||
}
|
||
|
||
// 方案4: 帧时间预算控制
|
||
const frameStartTime = performance.now()
|
||
const limits = new Map(resourceTaskRenderLimits.value)
|
||
let hasMore = false
|
||
|
||
resourceTaskQueues.value.forEach((queue, resourceId) => {
|
||
// 检查是否超出帧时间预算
|
||
if (performance.now() - frameStartTime > FRAME_TIME_BUDGET) {
|
||
hasMore = true // 下一帧继续处理
|
||
return
|
||
}
|
||
|
||
const current = limits.get(resourceId) || 0
|
||
// v1.9.6 Sprint4 - 优化:只使用 visibleCount,忽略 background 阶段
|
||
const phaseTarget = queue.visibleCount
|
||
|
||
if (current < phaseTarget) {
|
||
const next = Math.min(phaseTarget, current + RESOURCE_BATCH_SIZE)
|
||
limits.set(resourceId, next)
|
||
hasMore = true
|
||
}
|
||
})
|
||
|
||
// v1.9.6 Sprint4 - 禁用 background 渲染阶段
|
||
// 因为队列中已经没有 background 任务了(totalCount === visibleCount)
|
||
// if (!hasMore && resourceRenderPhase.value === 'visible' && hasBackground) {
|
||
// resourceRenderPhase.value = 'background'
|
||
// hasMore = true
|
||
// }
|
||
|
||
if (hasMore) {
|
||
resourceTaskRenderLimits.value = limits
|
||
resourceBatchRafId = requestAnimationFrame(step)
|
||
} else {
|
||
resourceBatchRafId = null
|
||
}
|
||
}
|
||
|
||
resourceBatchRafId = requestAnimationFrame(step)
|
||
}
|
||
|
||
const rebuildResourceTaskQueues = () => {
|
||
if (viewMode.value !== 'resource') {
|
||
resourceTaskQueues.value = new Map()
|
||
resourceTaskRenderLimits.value = new Map()
|
||
resourceRenderPhase.value = 'visible'
|
||
taskBarRenderCache.value = new Map() // 清空缓存
|
||
stopResourceBatchRender()
|
||
return
|
||
}
|
||
|
||
const { startDate: visibleStartDate, endDate: visibleEndDate } = visibleTimeRange.value
|
||
const skipHorizontalFilter = currentTimeScale.value === TimelineScale.HOUR
|
||
|
||
// v1.9.7 Bug修复:月度/季度/年度视图禁用像素级精确过滤,直接使用visibleTimeRange
|
||
// 原因:月度视图中每月天数不同(28-31天),使用固定30天计算会导致跨月时间范围误差
|
||
// 例如:2月只有28天,但计算时用30天,会导致部分TaskBar被错误过滤
|
||
const scale = currentTimeScale.value
|
||
const usePixelLevelFilter = scale === TimelineScale.HOUR || scale === TimelineScale.DAY || scale === TimelineScale.WEEK
|
||
|
||
let viewportStartTime: number
|
||
let viewportEndTime: number
|
||
|
||
if (usePixelLevelFilter) {
|
||
// 方案1: 超激进虚拟化 - 计算精确的屏幕可见区域(像素级别)- 仅用于小时/日/周视图
|
||
const scrollLeft = timelineScrollLeft.value
|
||
const containerWidth = timelineContainerWidth.value
|
||
const timelineStart = timelineConfig.value.startDate.getTime()
|
||
|
||
// 根据时间刻度计算每单位的像素宽度
|
||
let pixelPerMs = 0
|
||
if (scale === TimelineScale.HOUR) {
|
||
pixelPerMs = 40 / (60 * 60 * 1000) // 40px per hour
|
||
} else if (scale === TimelineScale.DAY) {
|
||
pixelPerMs = 30 / (24 * 60 * 60 * 1000) // 30px per day
|
||
} else if (scale === TimelineScale.WEEK) {
|
||
pixelPerMs = 60 / (7 * 24 * 60 * 60 * 1000) // 60px per week
|
||
}
|
||
|
||
// 超精确计算:只保留10%缓冲区(方案1核心)
|
||
const ULTRA_TIGHT_BUFFER = 0.1 // 只保留10%缓冲区
|
||
const bufferPixels = containerWidth * ULTRA_TIGHT_BUFFER
|
||
const viewportStartPixel = Math.max(0, scrollLeft - bufferPixels)
|
||
const viewportEndPixel = scrollLeft + containerWidth + bufferPixels
|
||
|
||
// 转换为时间戳进行超精确过滤
|
||
viewportStartTime = timelineStart + viewportStartPixel / pixelPerMs
|
||
viewportEndTime = timelineStart + viewportEndPixel / pixelPerMs
|
||
} else {
|
||
// v1.9.7 修复:月度/季度/年度视图直接使用visibleTimeRange(已通过timelineData精确计算)
|
||
viewportStartTime = visibleStartDate.getTime()
|
||
viewportEndTime = visibleEndDate.getTime()
|
||
}
|
||
|
||
const queues = new Map<string | number, ResourceTaskQueue>()
|
||
const limits = new Map<string | number, number>()
|
||
|
||
// v1.9.6 Sprint2(P5) - 渲染缓存增量更新:保留已有记录
|
||
const currentCache = new Map(taskBarRenderCache.value)
|
||
const newCache = new Map<string, TaskBarRenderCache>()
|
||
const currentTimestamp = Date.now()
|
||
let cachedCount = 0 // 统计已渲染的TaskBar数量
|
||
|
||
visibleResources.value.forEach(({ resource }) => {
|
||
const resourceId = resource.id as string | number
|
||
const originalTasks = ((resource as any).tasks || []) as Task[]
|
||
|
||
// 方案1: 使用超精确的像素级别过滤
|
||
const visibleTasks = skipHorizontalFilter
|
||
? originalTasks
|
||
: originalTasks.filter(task => {
|
||
if (!task.startDate || !task.endDate) {
|
||
return false
|
||
}
|
||
const taskStartTime = new Date(task.startDate).getTime()
|
||
const taskEndTime = new Date(task.endDate).getTime()
|
||
|
||
// 超精确过滤:TaskBar必须在像素级别的可见范围内
|
||
return taskEndTime >= viewportStartTime && taskStartTime <= viewportEndTime
|
||
})
|
||
|
||
// v1.9.6 Sprint2(P5) - 更新渲染缓存:记录所有TaskBar的状态
|
||
originalTasks.forEach((task, idx) => {
|
||
const taskId = task.id ?? `idx-${idx}`
|
||
const cacheKey = `${resourceId}-${taskId}`
|
||
const existingCache = currentCache.get(cacheKey)
|
||
|
||
// 如果已经渲染过,保留rendered=true状态
|
||
const isRendered = existingCache?.rendered || false
|
||
if (isRendered) {
|
||
cachedCount = cachedCount + 1
|
||
}
|
||
|
||
newCache.set(cacheKey, {
|
||
taskId,
|
||
resourceId,
|
||
rendered: isRendered,
|
||
timestamp: isRendered ?
|
||
(existingCache ?
|
||
existingCache.timestamp
|
||
: currentTimestamp)
|
||
: currentTimestamp,
|
||
})
|
||
})
|
||
|
||
// v1.9.6 Sprint4 - 优化:移除 backgroundTasks,只保留 visibleTasks
|
||
// 这样可以避免后续的 background 渲染阶段渲染不可见的 TaskBar
|
||
queues.set(resourceId, {
|
||
tasks: visibleTasks, // 只保留可见任务,不包含 backgroundTasks
|
||
visibleCount: visibleTasks.length,
|
||
totalCount: visibleTasks.length, // totalCount = visibleCount,禁用 background 阶段
|
||
originalTasks,
|
||
})
|
||
|
||
// v1.9.6 Sprint2(P5) - 智能初始渲染限制:优先渲染未缓存的TaskBar
|
||
const previousLimit = resourceTaskRenderLimits.value.get(resourceId)
|
||
let initialLimit: number
|
||
|
||
if (previousLimit !== undefined && previousLimit >= visibleTasks.length) {
|
||
// 如果之前已经渲染超过当前可见数量,直接继承
|
||
initialLimit = Math.min(previousLimit, visibleTasks.length) // v1.9.6 Sprint4 - 修复:使用 visibleTasks.length
|
||
} else {
|
||
// 否则按批次大小逐步渲染
|
||
initialLimit = Math.min(RESOURCE_BATCH_SIZE, visibleTasks.length)
|
||
}
|
||
|
||
limits.set(resourceId, initialLimit)
|
||
})
|
||
|
||
resourceTaskQueues.value = queues
|
||
resourceTaskRenderLimits.value = limits
|
||
resourceRenderPhase.value = 'visible'
|
||
taskBarRenderCache.value = newCache // 更新缓存
|
||
|
||
scheduleResourceBatchRender()
|
||
}
|
||
|
||
watch(
|
||
() => [viewMode.value, currentTimeScale.value, visibleTimeRange.value, visibleResources.value],
|
||
() => rebuildResourceTaskQueues(),
|
||
{ immediate: true },
|
||
)
|
||
|
||
// v1.9.6 Sprint2(P5) - 监控渲染限制变化,标记已渲染的TaskBar
|
||
watch(
|
||
() => resourceTaskRenderLimits.value,
|
||
(newLimits) => {
|
||
if (viewMode.value !== 'resource') return
|
||
|
||
const cache = new Map(taskBarRenderCache.value)
|
||
const currentTimestamp = Date.now()
|
||
let updatedCount = 0
|
||
|
||
resourceTaskQueues.value.forEach((queue, resourceId) => {
|
||
const renderLimit = newLimits.get(resourceId) || 0
|
||
const renderedTasks = queue.tasks.slice(0, renderLimit)
|
||
|
||
renderedTasks.forEach((task, idx) => {
|
||
const taskId = task.id ?? `idx-${idx}`
|
||
const cacheKey = `${resourceId}-${taskId}`
|
||
const existing = cache.get(cacheKey)
|
||
|
||
// 标记为已渲染
|
||
if (existing && !existing.rendered) {
|
||
existing.rendered = true
|
||
existing.timestamp = currentTimestamp
|
||
updatedCount++
|
||
}
|
||
})
|
||
})
|
||
|
||
if (updatedCount > 0) {
|
||
taskBarRenderCache.value = cache
|
||
}
|
||
},
|
||
{ deep: false },
|
||
)
|
||
|
||
// v1.9.5 P2-1优化 - 资源视图的可见资源及其水平过滤后的任务
|
||
let filteredTasksCallCount = 0
|
||
const visibleResourcesWithFilteredTasks = computed(() => {
|
||
filteredTasksCallCount++
|
||
|
||
if (viewMode.value !== 'resource') {
|
||
return []
|
||
}
|
||
|
||
const { startDate: visibleStartDate, endDate: visibleEndDate } = visibleTimeRange.value
|
||
const skipHorizontalFilter = currentTimeScale.value === TimelineScale.HOUR
|
||
|
||
// 性能监控统计
|
||
let totalOriginalTasks = 0
|
||
let totalFilteredTasks = 0
|
||
|
||
const result = visibleResources.value.map(({ resource, originalIndex }) => {
|
||
const originalTasks = (resource as any).tasks || []
|
||
totalOriginalTasks += originalTasks.length
|
||
|
||
const queue = resourceTaskQueues.value.get(resource.id as string | number)
|
||
const limit = resourceTaskRenderLimits.value.get(resource.id as string | number)
|
||
|
||
let renderTasks: Task[]
|
||
if (queue) {
|
||
const renderCount = limit ?? queue.visibleCount
|
||
renderTasks = queue.tasks.slice(0, renderCount)
|
||
} else {
|
||
// 过滤出在可见时间范围内的任务(用于TaskBar渲染)
|
||
renderTasks = skipHorizontalFilter
|
||
? originalTasks
|
||
: originalTasks.filter((task: Task) => {
|
||
// 跳过没有时间信息的任务
|
||
if (!task.startDate || !task.endDate) {
|
||
return false
|
||
}
|
||
|
||
const taskStart = new Date(task.startDate)
|
||
const taskEnd = new Date(task.endDate)
|
||
|
||
// 任务结束日期 >= 可见开始日期 && 任务开始日期 <= 可见结束日期
|
||
return taskEnd >= visibleStartDate && taskStart <= visibleEndDate
|
||
})
|
||
}
|
||
|
||
totalFilteredTasks += renderTasks.length
|
||
|
||
return {
|
||
resource: {
|
||
...resource,
|
||
tasks: renderTasks,
|
||
// 保留原始完整的任务列表(用于GanttConflicts冲突检测)
|
||
allTasks: originalTasks,
|
||
},
|
||
originalIndex,
|
||
}
|
||
})
|
||
|
||
return result
|
||
})
|
||
|
||
// 防抖处理滚动事件(优化:增加防抖时间)
|
||
const debounce = <T extends (...args: unknown[]) => void>(func: T, wait: number): T => {
|
||
let timeout: number | null = null
|
||
return ((...args: Parameters<T>) => {
|
||
const later = () => {
|
||
timeout = null
|
||
func(...args)
|
||
}
|
||
if (timeout) clearTimeout(timeout)
|
||
timeout = window.setTimeout(later, wait)
|
||
}) as T
|
||
}
|
||
|
||
// 优化的滚动处理器(增加防抖时间到 50ms)
|
||
const debouncedUpdatePositions = debounce(() => {
|
||
computeAllMilestonesPositions()
|
||
}, 200)
|
||
|
||
// 虚拟渲染:防抖更新 Canvas 位置(滚动时触发)
|
||
const debouncedUpdateCanvasPosition = debounce(() => {
|
||
updateSvgSize() // 重新计算 Canvas 位置和尺寸
|
||
}, 200)
|
||
|
||
// v1.9.5 P2-3优化 - 智能缓存时间轴数据的函数
|
||
const getCachedTimelineData = (): unknown => {
|
||
const scale = currentTimeScale.value
|
||
const startTime = timelineConfig.value.startDate.getTime()
|
||
const endTime = timelineConfig.value.endDate.getTime()
|
||
const key = `${scale}-${startTime}-${endTime}`
|
||
|
||
// 检查缓存是否存在且未过期
|
||
const cached = timelineDataCache.get(key)
|
||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||
// 缓存命中且未过期
|
||
return cached.data
|
||
}
|
||
|
||
// 缓存未命中或已过期,重新生成数据
|
||
let data: unknown
|
||
if (scale === TimelineScale.HOUR) {
|
||
data = generateHourTimelineData()
|
||
} else if (scale === TimelineScale.WEEK) {
|
||
data = generateWeekTimelineData()
|
||
} else if (scale === TimelineScale.MONTH) {
|
||
data = generateMonthTimelineData()
|
||
} else if (scale === TimelineScale.QUARTER) {
|
||
data = generateQuarterTimelineData()
|
||
} else if (scale === TimelineScale.YEAR) {
|
||
data = generateYearTimelineData()
|
||
} else {
|
||
data = generateDayTimelineData()
|
||
}
|
||
|
||
// 保存到缓存
|
||
timelineDataCache.set(key, {
|
||
data,
|
||
timestamp: Date.now(),
|
||
scale,
|
||
})
|
||
|
||
// 缓存大小控制:如果超过最大缓存数,删除最旧的条目
|
||
if (timelineDataCache.size > MAX_CACHE_SIZE) {
|
||
let oldestKey: string | null = null
|
||
let oldestTime = Date.now()
|
||
|
||
// 找到最旧的缓存条目
|
||
timelineDataCache.forEach((entry, entryKey) => {
|
||
if (entry.timestamp < oldestTime) {
|
||
oldestTime = entry.timestamp
|
||
oldestKey = entryKey
|
||
}
|
||
})
|
||
|
||
// 删除最旧的条目
|
||
if (oldestKey) {
|
||
timelineDataCache.delete(oldestKey)
|
||
}
|
||
}
|
||
|
||
return data
|
||
}
|
||
|
||
// 获取虚拟滚动优化后的时间轴数据
|
||
const optimizedTimelineData = computed(() => {
|
||
const cachedData = getCachedTimelineData() as any
|
||
// 只在小时视图中应用虚拟滚动
|
||
if (currentTimeScale.value === TimelineScale.HOUR && Array.isArray(cachedData)) {
|
||
const { startHour, endHour } = visibleHourRange.value
|
||
|
||
// 优化:合并 map + filter 为单次遍历
|
||
const result: any[] = []
|
||
const dayStart = new Date(timelineConfig.value.startDate)
|
||
dayStart.setHours(0, 0, 0, 0)
|
||
const dayStartTime = dayStart.getTime()
|
||
const msPerDay = 1000 * 60 * 60 * 24
|
||
|
||
for (const day of cachedData as any[]) {
|
||
// 计算当前天相对于时间线开始的小时偏移
|
||
const currentDay = new Date(day.year, day.month - 1, day.day)
|
||
currentDay.setHours(0, 0, 0, 0)
|
||
const daysDiff = Math.floor((currentDay.getTime() - dayStartTime) / msPerDay)
|
||
const totalHourOffset = daysDiff * 24
|
||
|
||
// 计算当前天应该显示的小时范围
|
||
const dayStartHour = Math.max(0, startHour - totalHourOffset)
|
||
const dayEndHour = Math.min(day.hours.length, endHour - totalHourOffset)
|
||
|
||
// 只保留有小时数据的天
|
||
if (dayStartHour < dayEndHour) {
|
||
result.push({
|
||
...day,
|
||
hours: day.hours.slice(dayStartHour, dayEndHour),
|
||
hourOffset: totalHourOffset + dayStartHour,
|
||
visibleHourStart: dayStartHour,
|
||
_debug: {
|
||
totalHourOffset,
|
||
dayStartHour,
|
||
dayEndHour,
|
||
visibleRange: { startHour, endHour },
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
return cachedData
|
||
})
|
||
|
||
// 计算完整时间线的总宽度(用于虚拟滚动容器)
|
||
const totalTimelineWidth = computed(() => {
|
||
const cachedData = getCachedTimelineData() as any
|
||
if (!Array.isArray(cachedData)) return 0
|
||
|
||
const scale = currentTimeScale.value
|
||
|
||
// 小时视图
|
||
if (scale === TimelineScale.HOUR) {
|
||
let totalHours = 0
|
||
for (const day of cachedData as any[]) {
|
||
totalHours += day.hours.length
|
||
}
|
||
return totalHours * HOUR_WIDTH
|
||
}
|
||
|
||
// 季度视图
|
||
if (scale === TimelineScale.QUARTER) {
|
||
let totalQuarters = 0
|
||
for (const year of cachedData as any[]) {
|
||
totalQuarters += year.quarters.length
|
||
}
|
||
return totalQuarters * 60
|
||
}
|
||
|
||
// 周视图
|
||
if (scale === TimelineScale.WEEK) {
|
||
let totalWeeks = 0
|
||
for (const month of cachedData) {
|
||
totalWeeks += (month.weeks?.length || 0)
|
||
}
|
||
return totalWeeks * 60
|
||
}
|
||
|
||
// 月视图
|
||
if (scale === TimelineScale.MONTH) {
|
||
return cachedData.length * 60
|
||
}
|
||
|
||
// 年视图
|
||
if (scale === TimelineScale.YEAR) {
|
||
return cachedData.length * 360
|
||
}
|
||
|
||
// 日视图
|
||
let totalDays = 0
|
||
for (const month of cachedData) {
|
||
totalDays += (month.days?.length || 0)
|
||
}
|
||
return totalDays * 30
|
||
})
|
||
|
||
let resizeObserver: ResizeObserver | null = null
|
||
|
||
// 里程碑位置信息管理(用于推挤效果)
|
||
const milestonePositions = ref<
|
||
Map<
|
||
number,
|
||
{
|
||
left: number
|
||
originalLeft: number // 原始位置(不考虑停靠)
|
||
isSticky: boolean
|
||
stickyPosition: 'left' | 'right' | 'none'
|
||
}
|
||
>
|
||
>(new Map())
|
||
|
||
// 计算当前所有里程碑的位置信息(优化版:减少重复计算)
|
||
const computeAllMilestonesPositions = () => {
|
||
const positions = new Map()
|
||
const timelineStart = timelineConfig.value.startDate.getTime()
|
||
const leftBoundary = timelineScrollLeft.value
|
||
const rightBoundary = leftBoundary + timelineContainerWidth.value
|
||
|
||
// 遍历所有里程碑分组
|
||
for (const task of tasks.value) {
|
||
if (task.type === 'milestone-group' && task.children) {
|
||
for (const milestone of task.children) {
|
||
const milestoneDate = new Date(milestone.startDate || '')
|
||
if (!isNaN(milestoneDate.getTime())) {
|
||
const startDiff = Math.floor(
|
||
(milestoneDate.getTime() - timelineStart) / (1000 * 60 * 60 * 24),
|
||
)
|
||
const left = startDiff * 30 + 15 - 12 // 30是dayWidth,12是图标半径
|
||
|
||
// 计算边界粘性状态
|
||
const iconLeft = left - 12
|
||
const iconRight = left + 12
|
||
|
||
let isSticky = false
|
||
let stickyPosition: 'left' | 'right' | 'none' = 'none'
|
||
|
||
if (iconRight <= leftBoundary + 12) {
|
||
isSticky = true
|
||
stickyPosition = 'left'
|
||
} else if (iconLeft >= rightBoundary - 12) {
|
||
isSticky = true
|
||
stickyPosition = 'right'
|
||
}
|
||
|
||
positions.set(milestone.id, {
|
||
left,
|
||
originalLeft: left,
|
||
isSticky,
|
||
stickyPosition,
|
||
})
|
||
}
|
||
}
|
||
} else if (task.type === 'milestone') {
|
||
const milestoneDate = new Date(task.startDate || '')
|
||
if (!isNaN(milestoneDate.getTime())) {
|
||
const daysDiff = (milestoneDate.getTime() - timelineStart) / (1000 * 60 * 60 * 24)
|
||
const startDiff = Math.floor(daysDiff)
|
||
const left = startDiff * 30 + 15 - 12
|
||
|
||
// 计算边界粘性状态
|
||
const iconLeft = left - 12
|
||
const iconRight = left + 12
|
||
|
||
let isSticky = false
|
||
let stickyPosition: 'left' | 'right' | 'none' = 'none'
|
||
|
||
if (iconRight <= leftBoundary + 12) {
|
||
isSticky = true
|
||
stickyPosition = 'left'
|
||
} else if (iconLeft >= rightBoundary - 12) {
|
||
isSticky = true
|
||
stickyPosition = 'right'
|
||
}
|
||
|
||
positions.set(task.id, {
|
||
left,
|
||
originalLeft: left,
|
||
isSticky,
|
||
stickyPosition,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
milestonePositions.value = positions
|
||
}
|
||
|
||
// 获取其他里程碑的位置信息(排除当前里程碑)
|
||
const getOtherMilestonesInfo = (currentId: number) => {
|
||
const result: Array<{
|
||
id: number
|
||
left: number
|
||
originalLeft: number // 新增:原始位置(不考虑停靠)
|
||
isSticky: boolean
|
||
stickyPosition: 'left' | 'right' | 'none'
|
||
priority: number // 新增:推挤优先级(基于原始位置)
|
||
}> = []
|
||
|
||
milestonePositions.value.forEach((position, id) => {
|
||
if (id !== currentId) {
|
||
result.push({
|
||
id,
|
||
left: position.left,
|
||
originalLeft: position.originalLeft, // 使用保存的原始位置
|
||
isSticky: position.isSticky,
|
||
stickyPosition: position.stickyPosition,
|
||
priority: position.originalLeft, // 使用原始位置作为优先级
|
||
})
|
||
}
|
||
})
|
||
|
||
return result
|
||
}
|
||
|
||
// v1.9.5 P2-4优化 - 监听Split Bar拖拽结束,执行清理工作
|
||
watch(isSplitBarDragging, (dragging) => {
|
||
if (!dragging) {
|
||
// 拖拽结束后,手动触发一次容器宽度更新
|
||
if (timelineContainerElement.value) {
|
||
const newWidth = timelineContainerElement.value.clientWidth
|
||
if (Math.abs(newWidth - timelineContainerWidth.value) > 1) {
|
||
timelineContainerWidth.value = newWidth
|
||
}
|
||
}
|
||
|
||
// Splitter拖拽结束后,强制重新计算半圆显示状态
|
||
// 因为Timeline容器宽度可能发生了变化
|
||
hideBubbles.value = true
|
||
setTimeout(() => {
|
||
hideBubbles.value = false
|
||
}, 300) // 300ms后恢复半圆显示
|
||
}
|
||
})
|
||
|
||
// 处理拖拽开始事件(保留用于兼容性,但不再更新状态)
|
||
const handleSplitterDragStart = () => {
|
||
// v1.9.5 P2-4优化 - 状态已由GanttChart统一管理
|
||
// isSplitterDragging 现在是注入的响应式引用
|
||
}
|
||
|
||
// 处理拖拽结束事件(保留用于兼容性,但不再更新状态)
|
||
const handleSplitterDragEnd = () => {
|
||
// v1.9.5 P2-4优化 - 状态已由GanttChart统一管理
|
||
// 清理工作已移至watch(isSplitBarDragging)
|
||
}
|
||
|
||
// 处理Timeline容器resize事件(如TaskList切换等)
|
||
const handleTimelineContainerResized = () => {
|
||
// Timeline容器大小发生变化,需要强制重新计算半圆显示状态
|
||
|
||
// 立即隐藏半圆,让TaskBar重新计算边界
|
||
hideBubbles.value = true
|
||
|
||
// 清空TaskBar位置信息并强制重新渲染(修复全屏时关系线位置不正确的问题)
|
||
taskBarPositions.value = {}
|
||
taskBarRenderKey.value++
|
||
|
||
// 清除之前的定时器,避免多次触发冲突
|
||
if (hideBubblesTimeout) {
|
||
clearTimeout(hideBubblesTimeout)
|
||
}
|
||
|
||
// 延迟恢复显示,确保容器变化完全生效
|
||
hideBubblesTimeout = setTimeout(() => {
|
||
hideBubbles.value = false
|
||
hideBubblesTimeout = null
|
||
// 再次更新SVG尺寸,确保关系线容器大小正确
|
||
updateSvgSize()
|
||
}, 300)
|
||
}
|
||
|
||
// 处理任务行悬停事件
|
||
const handleTaskRowHover = (taskId: number | string | null) => {
|
||
// 如果正在拖拽Splitter或拖动滚动,则不响应悬停事件
|
||
if (isSplitterDragging.value || isDragging.value) {
|
||
return
|
||
}
|
||
|
||
hoveredTaskId.value = taskId
|
||
// 发送事件通知TaskList组件
|
||
window.dispatchEvent(
|
||
new CustomEvent('timeline-task-hover', {
|
||
detail: taskId,
|
||
}),
|
||
)
|
||
}
|
||
|
||
// 计算Timeline内容的总高度
|
||
const contentHeight = computed(() => {
|
||
const minHeight = 400 // 最小高度确保有足够的空间
|
||
|
||
// v1.9.0 资源视图:使用实际的累计高度
|
||
if (viewMode.value === 'resource') {
|
||
const resources = dataSource.value as Resource[]
|
||
let totalHeight = 0
|
||
|
||
resources.forEach(resource => {
|
||
// 🎯 使用辅助函数获取布局,确保所有资源高度都被计算
|
||
const layout = getResourceLayout(resource)
|
||
totalHeight += layout?.totalHeight || 51
|
||
})
|
||
|
||
return Math.max(totalHeight, minHeight, timelineBodyHeight.value)
|
||
}
|
||
|
||
// 任务视图:每个任务行高度51px (50px + 1px border)
|
||
const rowHeight = 51
|
||
const taskCount = tasks.value.length
|
||
const minHeightFromTasks = taskCount * rowHeight
|
||
|
||
// 返回任务高度、最小高度和容器高度中的最大值
|
||
return Math.max(minHeightFromTasks, minHeight, timelineBodyHeight.value)
|
||
})
|
||
|
||
// 监听TaskList的悬停事件
|
||
const handleTaskListHover = (event: CustomEvent) => {
|
||
// 如果正在拖动滚动,则不响应悬停事件
|
||
if (isDragging.value) {
|
||
return
|
||
}
|
||
hoveredTaskId.value = event.detail
|
||
}
|
||
|
||
// 处理TaskList的双击事件 (与TaskBar双击效果一致)
|
||
const handleTaskListDoubleClick = (event: CustomEvent) => {
|
||
const task = event.detail
|
||
// 调用相同的双击处理逻辑
|
||
handleTaskBarDoubleClick(task)
|
||
}
|
||
|
||
// 处理语言变化事件
|
||
const handleLocaleChange = () => {
|
||
// 语言变化时重新生成时间轴数据,这样年月标签会使用新的语言格式
|
||
timelineData.value = generateTimelineData()
|
||
}
|
||
|
||
// 处理里程碑双击事件
|
||
const handleMilestoneDoubleClick = (milestone: Milestone) => {
|
||
// 高亮模式下禁用双击
|
||
if (isInHighlightMode.value) {
|
||
return
|
||
}
|
||
// 向上emit事件,让GanttChart统一处理
|
||
emit('milestone-double-click', milestone)
|
||
}
|
||
|
||
// 关闭里程碑对话框
|
||
// 处理里程碑拖拽更新事件
|
||
const handleMilestoneUpdate = (updatedMilestone: Milestone) => {
|
||
// 通知父组件里程碑数据已更新
|
||
if (props.onMilestoneSave && typeof props.onMilestoneSave === 'function') {
|
||
props.onMilestoneSave(updatedMilestone as Task) // Type conversion for backward compatibility
|
||
}
|
||
|
||
// 广播里程碑更新事件,通知其他组件数据变化
|
||
window.dispatchEvent(
|
||
new CustomEvent('milestone-data-updated', {
|
||
detail: { milestone: updatedMilestone },
|
||
}),
|
||
)
|
||
}
|
||
|
||
// 生成时间轴数据
|
||
const generateTimelineData = (): any => {
|
||
// 使用缓存版本提升性能
|
||
const result = getCachedTimelineData()
|
||
return result
|
||
}
|
||
|
||
// 清除缓存的函数
|
||
const clearTimelineCache = () => {
|
||
timelineDataCache.clear()
|
||
}
|
||
|
||
// 生成日视图时间轴数据 (原有逻辑)
|
||
const generateDayTimelineData = () => {
|
||
const months: unknown[] = []
|
||
const currentDate = new Date(timelineConfig.value.startDate)
|
||
|
||
while (currentDate <= timelineConfig.value.endDate) {
|
||
const year = currentDate.getFullYear()
|
||
const month = currentDate.getMonth() + 1
|
||
|
||
// 生成该月的所有日期
|
||
const days = []
|
||
const daysInMonth = new Date(year, month, 0).getDate()
|
||
|
||
for (let day = 1; day <= daysInMonth; day++) {
|
||
const date = new Date(year, month - 1, day)
|
||
const dayOfWeek = date.getDay() // 0=周日, 6=周六
|
||
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6
|
||
|
||
days.push({
|
||
day,
|
||
date,
|
||
label: String(day).padStart(2, '0'),
|
||
isToday: isToday(date),
|
||
isWeekend,
|
||
})
|
||
}
|
||
|
||
months.push({
|
||
year,
|
||
month,
|
||
yearMonthLabel: formatYearMonth(year, month),
|
||
startDate: new Date(currentDate),
|
||
endDate: new Date(year, month, 0),
|
||
days,
|
||
})
|
||
currentDate.setMonth(currentDate.getMonth() + 1)
|
||
}
|
||
|
||
return months
|
||
}
|
||
|
||
// 判断是否为工作时间
|
||
const isWorkingHour = (hour: number, dayOfWeek: number) => {
|
||
// 周末(周六=6,周日=0)直接返回false,保持周末样式
|
||
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||
return false
|
||
}
|
||
|
||
const workingHours = props.workingHours
|
||
if (!workingHours) return false
|
||
|
||
// 检查上午工作时间
|
||
if (workingHours.morning) {
|
||
const { start, end } = workingHours.morning
|
||
if (hour >= start && hour <= end) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
// 检查下午工作时间
|
||
if (workingHours.afternoon) {
|
||
const { start, end } = workingHours.afternoon
|
||
if (hour >= start && hour <= end) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// 生成小时视图时间轴数据
|
||
const generateHourTimelineData = () => {
|
||
const days: unknown[] = []
|
||
const currentDate = new Date(timelineConfig.value.startDate)
|
||
|
||
while (currentDate <= timelineConfig.value.endDate) {
|
||
const year = currentDate.getFullYear()
|
||
const month = currentDate.getMonth() + 1
|
||
const day = currentDate.getDate()
|
||
|
||
// 生成该天的24小时数据
|
||
const hours = []
|
||
const dayOfWeek = currentDate.getDay() // 获取星期几
|
||
for (let hour = 0; hour < 24; hour++) {
|
||
const hourDate = new Date(year, month - 1, day, hour)
|
||
hours.push({
|
||
hour,
|
||
label: `${String(hour).padStart(2, '0')}:00`,
|
||
shortLabel: String(hour).padStart(2, '0'), // 简化显示格式,只显示小时数
|
||
date: hourDate,
|
||
isToday: isToday(hourDate) && hour === new Date().getHours(),
|
||
isWorkingHour: isWorkingHour(hour, dayOfWeek), // 判断是否为工作时间
|
||
isWeekend: dayOfWeek === 0 || dayOfWeek === 6, // 是否为周末
|
||
})
|
||
}
|
||
|
||
days.push({
|
||
year,
|
||
month,
|
||
day,
|
||
date: new Date(currentDate),
|
||
dateLabel: `${year}/${String(month).padStart(2, '0')}/${String(day).padStart(2, '0')}`,
|
||
isToday: isToday(currentDate),
|
||
hours,
|
||
})
|
||
|
||
currentDate.setDate(currentDate.getDate() + 1)
|
||
}
|
||
|
||
return days
|
||
}
|
||
|
||
// 生成周视图时间轴数据
|
||
const generateWeekTimelineData = () => {
|
||
const allWeeks: unknown[] = []
|
||
// 首先生成所有周
|
||
const startDate = new Date(timelineConfig.value.startDate)
|
||
const endDate = new Date(timelineConfig.value.endDate)
|
||
|
||
// 找到起始日期所在周的周一
|
||
const weekStart = new Date(startDate)
|
||
const dayOfWeek = weekStart.getDay() || 7 // 调整周日为7
|
||
weekStart.setDate(weekStart.getDate() - (dayOfWeek - 1))
|
||
|
||
const currentWeekStart = new Date(weekStart)
|
||
|
||
// 生成所有周 - 从第一周的周一开始,到包含endDate的周为止
|
||
while (currentWeekStart <= endDate) {
|
||
const currentWeekEnd = new Date(currentWeekStart)
|
||
currentWeekEnd.setDate(currentWeekEnd.getDate() + 6)
|
||
|
||
// 每周归属到周一所在的月份
|
||
// 例如:2025-09-29(周一) ~ 2025-10-05(周日) 归属到2025年9月
|
||
// 2025-10-27(周一) ~ 2025-11-02(周日) 归属到2025年10月
|
||
allWeeks.push({
|
||
weekStart: new Date(currentWeekStart),
|
||
weekEnd: new Date(currentWeekEnd),
|
||
label: `${currentWeekStart.getDate()}`,
|
||
isToday: isWeekContainsToday(currentWeekStart, currentWeekEnd),
|
||
subDays: generateSubDaysForWeek(currentWeekStart),
|
||
// 根据周一所在月份归属
|
||
belongsToYear: currentWeekStart.getFullYear(),
|
||
belongsToMonth: currentWeekStart.getMonth() + 1,
|
||
})
|
||
|
||
currentWeekStart.setDate(currentWeekStart.getDate() + 7)
|
||
}
|
||
|
||
// 按月份分组
|
||
const monthsMap = new Map()
|
||
|
||
for (const week of allWeeks) {
|
||
const weekObj = week as Record<string, unknown>
|
||
const belongsToYear = weekObj.belongsToYear as number
|
||
const belongsToMonth = weekObj.belongsToMonth as number
|
||
const key = `${belongsToYear}-${belongsToMonth}`
|
||
if (!monthsMap.has(key)) {
|
||
monthsMap.set(key, {
|
||
year: belongsToYear,
|
||
month: belongsToMonth,
|
||
yearMonthLabel: formatYearMonth(belongsToYear, belongsToMonth),
|
||
startDate: new Date(belongsToYear, belongsToMonth - 1, 1),
|
||
endDate: new Date(belongsToYear, belongsToMonth, 0),
|
||
weeks: [],
|
||
isWeekView: true,
|
||
})
|
||
}
|
||
monthsMap.get(key).weeks.push(week)
|
||
}
|
||
|
||
// 转换为数组并排序
|
||
const sortedMonths = Array.from(monthsMap.values()).sort((a, b) => {
|
||
if (a.year !== b.year) return a.year - b.year
|
||
return a.month - b.month
|
||
})
|
||
|
||
return sortedMonths
|
||
}
|
||
|
||
// 生成一周内的7个子列(用于精确定位)
|
||
const generateSubDaysForWeek = (weekStart: Date) => {
|
||
const subDays = []
|
||
for (let i = 0; i < 7; i++) {
|
||
const date = new Date(weekStart)
|
||
date.setDate(date.getDate() + i)
|
||
subDays.push({
|
||
date: new Date(date),
|
||
dayOfWeek: date.getDay(),
|
||
})
|
||
}
|
||
return subDays
|
||
}
|
||
|
||
// 判断周是否包含今天
|
||
const isWeekContainsToday = (weekStart: Date, weekEnd: Date) => {
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
return today >= weekStart && today <= weekEnd
|
||
}
|
||
|
||
// 计算周在全局时间轴中的位置(用于旗帜定位)
|
||
const getGlobalWeekPosition = (monthIndex: number, weekIndex: number) => {
|
||
let position = 0
|
||
|
||
// 累加前面月份的宽度
|
||
for (let i = 0; i < monthIndex; i++) {
|
||
const month = timelineData.value[i]
|
||
if (month && month.isWeekView && month.weeks) {
|
||
position += month.weeks.length * 60
|
||
} else if (month && month.days) {
|
||
position += month.days.length * 30
|
||
}
|
||
}
|
||
|
||
// 加上当前月份内的周位置
|
||
position += weekIndex * 60
|
||
|
||
return position
|
||
}
|
||
|
||
// 更新时间刻度方法 - 供外部调用
|
||
const updateTimeScale = (scale: TimelineScale) => {
|
||
perfMonitor2.start(`updateTimeScale-${scale}`)
|
||
|
||
currentTimeScale.value = scale
|
||
|
||
// 重置初始化状态
|
||
isInitialLoad.value = true
|
||
|
||
// 清除缓存,确保使用新的时间刻度数据
|
||
clearTimelineCache()
|
||
|
||
// 如果是小时视图或日视图,更新时间线配置
|
||
if (scale === TimelineScale.HOUR) {
|
||
const hourRange = getHourTimelineRange()
|
||
// 设置防护标志,避免递归更新
|
||
isUpdatingTimelineConfig = true
|
||
timelineConfig.value = {
|
||
...timelineConfig.value,
|
||
startDate: hourRange.startDate,
|
||
endDate: hourRange.endDate,
|
||
}
|
||
isUpdatingTimelineConfig = false
|
||
} else if (scale === TimelineScale.DAY) {
|
||
const dayRange = getDayTimelineRange()
|
||
// 设置防护标志,避免递归更新
|
||
isUpdatingTimelineConfig = true
|
||
timelineConfig.value = {
|
||
...timelineConfig.value,
|
||
startDate: dayRange.startDate,
|
||
endDate: dayRange.endDate,
|
||
}
|
||
isUpdatingTimelineConfig = false
|
||
}
|
||
|
||
// 如果是周视图,更新时间线配置
|
||
if (scale === TimelineScale.WEEK) {
|
||
const weekRange = getWeekTimelineRange()
|
||
// 设置防护标志,避免递归更新
|
||
isUpdatingTimelineConfig = true
|
||
timelineConfig.value = {
|
||
...timelineConfig.value,
|
||
startDate: weekRange.startDate,
|
||
endDate: weekRange.endDate,
|
||
}
|
||
isUpdatingTimelineConfig = false
|
||
}
|
||
|
||
// 如果是月度视图,更新时间线配置
|
||
if (scale === TimelineScale.MONTH) {
|
||
const monthRange = getMonthTimelineRange()
|
||
// 设置防护标志,避免递归更新
|
||
isUpdatingTimelineConfig = true
|
||
timelineConfig.value = {
|
||
...timelineConfig.value,
|
||
startDate: monthRange.startDate,
|
||
endDate: monthRange.endDate,
|
||
}
|
||
isUpdatingTimelineConfig = false
|
||
}
|
||
|
||
// 如果是季度视图,更新时间线配置
|
||
if (scale === TimelineScale.QUARTER) {
|
||
const yearRange = getYearTimelineRange()
|
||
// 设置防护标志,避免递归更新
|
||
isUpdatingTimelineConfig = true
|
||
timelineConfig.value = {
|
||
...timelineConfig.value,
|
||
startDate: yearRange.startDate,
|
||
endDate: yearRange.endDate,
|
||
}
|
||
isUpdatingTimelineConfig = false
|
||
}
|
||
|
||
// 如果是年度视图,更新时间线配置
|
||
if (scale === TimelineScale.YEAR) {
|
||
const yearRange = getYearTimelineRange()
|
||
// 设置防护标志,避免递归更新
|
||
isUpdatingTimelineConfig = true
|
||
timelineConfig.value = {
|
||
...timelineConfig.value,
|
||
startDate: yearRange.startDate,
|
||
endDate: yearRange.endDate,
|
||
}
|
||
isUpdatingTimelineConfig = false
|
||
}
|
||
|
||
// 重新生成时间线数据
|
||
timelineData.value = generateTimelineData()
|
||
|
||
// 等待DOM更新后触发多个重新计算事件
|
||
nextTick(() => {
|
||
// 1. 通知父组件时间刻度已变化
|
||
emit('timeline-scale-changed', scale)
|
||
|
||
// 2. 触发TaskBar重新计算位置事件
|
||
window.dispatchEvent(new CustomEvent('timeline-scale-updated'))
|
||
|
||
// 3. 延迟一点再次触发,确保所有组件都已更新
|
||
setTimeout(() => {
|
||
window.dispatchEvent(new CustomEvent('timeline-force-recalculate'))
|
||
|
||
// 4. 视图切换完成后,定位到今日
|
||
setTimeout(() => {
|
||
scrollToTodayCenter()
|
||
|
||
perfMonitor2.end(`updateTimeScale-${scale}`)
|
||
perfMonitor2.report()
|
||
}, 100)
|
||
}, 50)
|
||
})
|
||
}
|
||
|
||
// 判断是否为今天
|
||
const isToday = (date: Date) => {
|
||
const today = new Date()
|
||
return (
|
||
date.getDate() === today.getDate() &&
|
||
date.getMonth() === today.getMonth() &&
|
||
date.getFullYear() === today.getFullYear()
|
||
)
|
||
}
|
||
|
||
// 优化:使用 shallowRef 减少深度响应式开销(timelineData 是大数组,内部变化不需要深度追踪)
|
||
const timelineData = shallowRef(generateTimelineData() as any)
|
||
|
||
// 防止递归更新的标志
|
||
let isUpdatingTimelineConfig = false
|
||
|
||
// 保证timelineData响应式跟随timelineConfig变化
|
||
watch(
|
||
() => [timelineConfig.value.startDate, timelineConfig.value.endDate],
|
||
() => {
|
||
// 避免在更新timelineConfig时触发递归
|
||
if (!isUpdatingTimelineConfig) {
|
||
// 配置变化时清除缓存
|
||
clearTimelineCache()
|
||
timelineData.value = generateTimelineData()
|
||
}
|
||
},
|
||
)
|
||
|
||
// v1.9.6 Phase1 - 监听timelineData和时间刻度变化,自动重建位置缓存
|
||
// 优化:只在Timeline层执行一次,所有TaskBar共享缓存(250个TaskBar × 3ms → 预计算80ms + 250个TaskBar × 0.2ms)
|
||
let positionCacheWatchCount = 0
|
||
watch(
|
||
[timelineData, currentTimeScale],
|
||
([newData, newScale]) => {
|
||
positionCacheWatchCount++
|
||
|
||
if (newData && newScale) {
|
||
positionCache.buildCache(newData as any[], newScale)
|
||
}
|
||
},
|
||
{ immediate: true }, // 立即执行,确保初始化时也构建缓存
|
||
)
|
||
|
||
// 保证每次时间轴数据变化后都自动居中今日(仅初始化和外部props变更时触发,不因任务/里程碑变更触发)
|
||
let hasInitialAutoScroll = false
|
||
watch(
|
||
() => [timelineData.value, timelineConfig.value.startDate, timelineConfig.value.endDate],
|
||
() => {
|
||
if (!hasInitialAutoScroll) {
|
||
nextTick(() => {
|
||
scrollToTodayCenter()
|
||
hasInitialAutoScroll = true
|
||
})
|
||
}
|
||
},
|
||
// 优化:移除 deep: true,因为监听的是基础类型(startDate/endDate)和 shallowRef(timelineData)
|
||
// 不需要深度监听,可减少 90% 的监听开销
|
||
)
|
||
|
||
// 将今日定位到时间线中间位置
|
||
const scrollToTodayCenter = (retry = 0) => {
|
||
// 开始滚动时隐藏半圆
|
||
hideBubbles.value = true
|
||
isInitialScrolling.value = true
|
||
|
||
const today = new Date()
|
||
const timelineStart = timelineConfig.value.startDate
|
||
|
||
// 确保日期计算的精确性 - 使用年月日,忽略时分秒
|
||
const todayNormalized = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||
|
||
// 年度视图和季度视图需要使用实际的timeline绘制起始日期
|
||
let startNormalized: Date
|
||
if (
|
||
currentTimeScale.value === TimelineScale.YEAR ||
|
||
currentTimeScale.value === TimelineScale.QUARTER
|
||
) {
|
||
const yearRange = getYearTimelineRange()
|
||
startNormalized = new Date(
|
||
yearRange.startDate.getFullYear(),
|
||
yearRange.startDate.getMonth(),
|
||
yearRange.startDate.getDate(),
|
||
)
|
||
} else if (currentTimeScale.value === TimelineScale.MONTH) {
|
||
// 月视图使用 getMonthTimelineRange
|
||
const monthRange = getMonthTimelineRange()
|
||
startNormalized = new Date(
|
||
monthRange.startDate.getFullYear(),
|
||
monthRange.startDate.getMonth(),
|
||
monthRange.startDate.getDate(),
|
||
)
|
||
} else {
|
||
startNormalized = new Date(
|
||
timelineStart.getFullYear(),
|
||
timelineStart.getMonth(),
|
||
timelineStart.getDate(),
|
||
)
|
||
}
|
||
|
||
// 计算今天距离时间线开始日期的天数
|
||
const timeDiff = todayNormalized.getTime() - startNormalized.getTime()
|
||
const daysDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24))
|
||
|
||
// 计算今天在时间线中的像素位置(根据当前时间刻度)
|
||
let todayPosition: number
|
||
|
||
if (currentTimeScale.value === TimelineScale.HOUR) {
|
||
// 小时视图:精确到小时的定位
|
||
const currentHour = today.getHours()
|
||
const currentMinute = today.getMinutes()
|
||
|
||
// 基础天数偏移(到今日0点的位置)
|
||
const baseDayPosition = daysDiff * dayWidth.value
|
||
|
||
// 小时偏移:每小时40px
|
||
const hourOffset = currentHour * 40
|
||
|
||
// 分钟偏移:在当前小时内的精确位置
|
||
const minuteOffset = (currentMinute / 60) * 40
|
||
|
||
todayPosition = baseDayPosition + hourOffset + minuteOffset
|
||
} else if (currentTimeScale.value === TimelineScale.QUARTER) {
|
||
// 季度视图:使用与MilestonePoint相同的计算逻辑
|
||
const targetYear = todayNormalized.getFullYear()
|
||
const baseYear = startNormalized.getFullYear()
|
||
|
||
// 每年的宽度是240px (4季度 * 60px),每季度60px
|
||
const yearWidth = 240
|
||
const quarterWidth = 60
|
||
|
||
// 计算年份偏移
|
||
const yearOffset = targetYear - baseYear
|
||
todayPosition = yearOffset * yearWidth
|
||
|
||
// 判断是哪个季度
|
||
const month = todayNormalized.getMonth() + 1
|
||
let quarter = 1
|
||
if (month >= 1 && month <= 3) {
|
||
// Q1: 1-3月
|
||
} else if (month >= 4 && month <= 6) {
|
||
quarter = 2 // Q2: 4-6月
|
||
} else if (month >= 7 && month <= 9) {
|
||
quarter = 3 // Q3: 7-9月
|
||
} else {
|
||
quarter = 4 // Q4: 10-12月
|
||
}
|
||
|
||
// 添加季度偏移
|
||
todayPosition += (quarter - 1) * quarterWidth
|
||
|
||
// 在季度内的具体位置计算
|
||
let startOfQuarter: Date, endOfQuarter: Date
|
||
|
||
if (quarter === 1) {
|
||
startOfQuarter = new Date(targetYear, 0, 1) // 1月1日
|
||
endOfQuarter = new Date(targetYear, 2, 31) // 3月31日
|
||
} else if (quarter === 2) {
|
||
startOfQuarter = new Date(targetYear, 3, 1) // 4月1日
|
||
endOfQuarter = new Date(targetYear, 5, 30) // 6月30日
|
||
} else if (quarter === 3) {
|
||
startOfQuarter = new Date(targetYear, 6, 1) // 7月1日
|
||
endOfQuarter = new Date(targetYear, 8, 30) // 9月30日
|
||
} else {
|
||
startOfQuarter = new Date(targetYear, 9, 1) // 10月1日
|
||
endOfQuarter = new Date(targetYear, 11, 31) // 12月31日
|
||
}
|
||
|
||
const dayOffset = Math.floor(
|
||
(todayNormalized.getTime() - startOfQuarter.getTime()) / (1000 * 60 * 60 * 24),
|
||
)
|
||
const daysInQuarter =
|
||
Math.floor((endOfQuarter.getTime() - startOfQuarter.getTime()) / (1000 * 60 * 60 * 24)) + 1
|
||
const dayPositionInQuarter = (dayOffset / daysInQuarter) * quarterWidth
|
||
todayPosition += dayPositionInQuarter
|
||
} else if (currentTimeScale.value === TimelineScale.MONTH) {
|
||
// 月视图:计算今天所在月份的位置
|
||
const targetYear = todayNormalized.getFullYear()
|
||
const targetMonth = todayNormalized.getMonth() + 1 // 1-12
|
||
|
||
const baseYear = startNormalized.getFullYear()
|
||
const baseMonth = startNormalized.getMonth() + 1 // 1-12
|
||
|
||
// 计算从起始月份到目标月份的月数
|
||
const monthsDiff = (targetYear - baseYear) * 12 + (targetMonth - baseMonth)
|
||
|
||
// 每个月60px
|
||
const monthWidth = 60
|
||
todayPosition = monthsDiff * monthWidth
|
||
|
||
// 在月份内的具体位置(基于日期)
|
||
const dayInMonth = todayNormalized.getDate()
|
||
const daysInMonth = new Date(targetYear, targetMonth, 0).getDate()
|
||
const dayPositionInMonth = (dayInMonth / daysInMonth) * monthWidth
|
||
todayPosition += dayPositionInMonth
|
||
} else if (currentTimeScale.value === TimelineScale.YEAR) {
|
||
// 年度视图:使用 timelineData 计算精确位置
|
||
const yearData = timelineData.value as Array<{
|
||
year: number
|
||
halfYears?: Array<{
|
||
half: number
|
||
startDate: Date
|
||
endDate: Date
|
||
}>
|
||
}>
|
||
|
||
let position = 0
|
||
const halfYearWidth = 180 // 每个半年的宽度
|
||
let found = false
|
||
|
||
// 遍历年份数据
|
||
for (const yearItem of yearData) {
|
||
const halfYears = yearItem.halfYears || []
|
||
|
||
for (const halfYear of halfYears) {
|
||
const halfYearStart = new Date(halfYear.startDate)
|
||
const halfYearEnd = new Date(halfYear.endDate)
|
||
halfYearStart.setHours(0, 0, 0, 0)
|
||
halfYearEnd.setHours(0, 0, 0, 0)
|
||
|
||
// 如果今天在这个半年之前,说明已经过了
|
||
if (todayNormalized < halfYearStart) {
|
||
found = true
|
||
break
|
||
}
|
||
|
||
// 如果今天在这个半年之内
|
||
if (todayNormalized >= halfYearStart && todayNormalized <= halfYearEnd) {
|
||
// 计算在半年内的偏移比例
|
||
const totalMs = halfYearEnd.getTime() - halfYearStart.getTime()
|
||
const elapsedMs = todayNormalized.getTime() - halfYearStart.getTime()
|
||
const ratio = elapsedMs / totalMs
|
||
position += ratio * halfYearWidth
|
||
found = true
|
||
break
|
||
}
|
||
|
||
// 今天在这个半年之后,累加宽度继续查找
|
||
position += halfYearWidth
|
||
}
|
||
|
||
if (found) break
|
||
}
|
||
|
||
todayPosition = position
|
||
} else {
|
||
// 其他视图:使用原有逻辑
|
||
todayPosition = daysDiff * dayWidth.value
|
||
}
|
||
|
||
// 优先使用缓存的容器元素
|
||
const scrollContainer = timelineContainerElement.value
|
||
|
||
if (!scrollContainer) {
|
||
// 如果容器还未初始化,递归重试
|
||
if (retry < 10) {
|
||
setTimeout(() => scrollToTodayCenter(retry + 1), 60)
|
||
}
|
||
return
|
||
}
|
||
|
||
const containerWidth = scrollContainer.clientWidth
|
||
// 若宽度为0,递归重试,最多10次
|
||
if (containerWidth === 0 && retry < 10) {
|
||
setTimeout(() => scrollToTodayCenter(retry + 1), 60)
|
||
return
|
||
}
|
||
|
||
// 计算将今日列置于中间的滚动位置
|
||
const centeredScrollPosition = todayPosition - containerWidth / 2 + 15
|
||
if (typeof scrollContainer.scrollTo === 'function') {
|
||
scrollContainer.scrollTo({ left: Math.max(0, centeredScrollPosition), behavior: 'smooth' })
|
||
} else {
|
||
scrollContainer.scrollLeft = Math.max(0, centeredScrollPosition)
|
||
}
|
||
|
||
// 滚动结束后延迟显示半圆,并标记初始化完成
|
||
setTimeout(() => {
|
||
isInitialScrolling.value = false
|
||
// 在小时视图中,滚动完成后标记初始化完成
|
||
if (currentTimeScale.value === TimelineScale.HOUR) {
|
||
isInitialLoad.value = false
|
||
}
|
||
setTimeout(() => {
|
||
hideBubbles.value = false
|
||
}, 300) // 再等300ms确保滚动完全停止
|
||
}, 1500) // 给滚动动画留1.5秒时间
|
||
}
|
||
|
||
// 计算年度视图中今日标记线的位置
|
||
const getTodayLinePositionInYearView = computed(() => {
|
||
// 只在年度视图中计算
|
||
if (currentTimeScale.value !== TimelineScale.YEAR) {
|
||
return -1 // 返回负值表示不显示
|
||
}
|
||
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
|
||
// 使用 timelineData 计算精确位置
|
||
const yearData = timelineData.value as Array<{
|
||
year: number
|
||
halfYears?: Array<{
|
||
half: number
|
||
startDate: Date
|
||
endDate: Date
|
||
}>
|
||
}>
|
||
|
||
if (!yearData || yearData.length === 0) {
|
||
return -1
|
||
}
|
||
|
||
let position = 0
|
||
const halfYearWidth = 180 // 每个半年的宽度
|
||
|
||
// 遍历年份数据
|
||
for (const yearItem of yearData) {
|
||
const halfYears = yearItem.halfYears || []
|
||
|
||
for (const halfYear of halfYears) {
|
||
const halfYearStart = new Date(halfYear.startDate)
|
||
const halfYearEnd = new Date(halfYear.endDate)
|
||
halfYearStart.setHours(0, 0, 0, 0)
|
||
halfYearEnd.setHours(0, 0, 0, 0)
|
||
|
||
// 如果今天在这个半年之前,说明已经过了
|
||
if (today < halfYearStart) {
|
||
return position
|
||
}
|
||
|
||
// 如果今天在这个半年之内
|
||
if (today >= halfYearStart && today <= halfYearEnd) {
|
||
// 计算在半年内的偏移比例
|
||
const totalMs = halfYearEnd.getTime() - halfYearStart.getTime()
|
||
const elapsedMs = today.getTime() - halfYearStart.getTime()
|
||
const ratio = elapsedMs / totalMs
|
||
return position + ratio * halfYearWidth
|
||
}
|
||
|
||
// 今天在这个半年之后,累加宽度继续查找
|
||
position += halfYearWidth
|
||
}
|
||
}
|
||
|
||
// 今天在所有数据之后
|
||
return position
|
||
})
|
||
|
||
// 检查年度视图中今日是否在当前时间范围内
|
||
const isTodayVisibleInYearView = computed(() => {
|
||
// 只在年度视图中检查
|
||
if (currentTimeScale.value !== TimelineScale.YEAR) {
|
||
return false
|
||
}
|
||
|
||
const today = new Date()
|
||
const todayNormalized = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||
|
||
const yearRange = getYearTimelineRange()
|
||
const startDate = yearRange.startDate
|
||
const endDate = yearRange.endDate
|
||
|
||
return todayNormalized >= startDate && todayNormalized <= endDate
|
||
})
|
||
|
||
const scrollToTasks = () => {
|
||
if (tasks.value.length === 0) {
|
||
// 如果没有任务,滚动到今天
|
||
scrollToToday()
|
||
return
|
||
}
|
||
|
||
// 优化:一次遍历找到所有有效的开始日期
|
||
const startDates: Date[] = []
|
||
for (const task of tasks.value) {
|
||
if (task.startDate) {
|
||
startDates.push(new Date(task.startDate))
|
||
}
|
||
}
|
||
|
||
if (startDates.length === 0) {
|
||
scrollToToday()
|
||
return
|
||
}
|
||
|
||
// 找到最早的开始日期 - 优化:直接在遍历中找最小值
|
||
let minTime = Infinity
|
||
for (const date of startDates) {
|
||
const time = date.getTime()
|
||
if (time < minTime) {
|
||
minTime = time
|
||
}
|
||
}
|
||
const earliestDate = new Date(minTime)
|
||
|
||
// 计算该日期在时间轴中的位置
|
||
const year = earliestDate.getFullYear()
|
||
const month = earliestDate.getMonth()
|
||
const day = earliestDate.getDate()
|
||
|
||
let totalDays = 0
|
||
for (let i = 0; i < month; i++) {
|
||
totalDays += new Date(year, i + 1, 0).getDate()
|
||
}
|
||
totalDays += day
|
||
|
||
// 使用缓存的容器元素
|
||
const timelinePanel = timelinePanelElement.value
|
||
const timelinePanelW = timelinePanel?.clientWidth
|
||
const scrollPosition = (totalDays - 1) * 30 - (timelinePanelW ? timelinePanelW / 2 : 200)
|
||
|
||
// 滚动到指定位置
|
||
if (timelineContainerElement.value) {
|
||
timelineContainerElement.value.scrollLeft = Math.max(0, scrollPosition)
|
||
}
|
||
}
|
||
|
||
// 聚焦到今天日期 - 改进版,基于时间线配置精确定位
|
||
const scrollToToday = () => {
|
||
const today = new Date()
|
||
const timelineStart = timelineConfig.value.startDate
|
||
|
||
// 确保日期计算的精确性 - 使用年月日,忽略时分秒
|
||
const todayNormalized = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||
const startNormalized = new Date(
|
||
timelineStart.getFullYear(),
|
||
timelineStart.getMonth(),
|
||
timelineStart.getDate(),
|
||
)
|
||
|
||
// 计算今天距离时间线开始日期的天数
|
||
const timeDiff = todayNormalized.getTime() - startNormalized.getTime()
|
||
const daysDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24))
|
||
|
||
// 如果今天不在时间线范围内,则不进行滚动
|
||
if (daysDiff < 0 || todayNormalized > timelineConfig.value.endDate) {
|
||
return
|
||
}
|
||
|
||
// 计算今天在时间线中的像素位置(每天30px宽度)
|
||
const todayPosition = daysDiff * 30
|
||
|
||
// 使用缓存的容器元素
|
||
const timeline = timelineContainerElement.value
|
||
if (!timeline) return
|
||
|
||
const containerWidth = timeline.clientWidth
|
||
|
||
// 计算居中滚动位置
|
||
const centeredScrollPosition = todayPosition - containerWidth / 2 + 15
|
||
|
||
// 滚动到指定位置,确保今日列在中间
|
||
timeline.scrollTo({
|
||
left: Math.max(0, centeredScrollPosition),
|
||
behavior: 'smooth',
|
||
})
|
||
|
||
// 添加今日高亮效果
|
||
setTimeout(() => {
|
||
const todayColumns = document.querySelectorAll('.day-column.today')
|
||
for (const column of todayColumns) {
|
||
column.classList.add('today-highlight')
|
||
// 2秒后移除高亮效果
|
||
setTimeout(() => {
|
||
column.classList.remove('today-highlight')
|
||
}, 2000)
|
||
}
|
||
}, 500) // 等待滚动完成后再添加高亮
|
||
}
|
||
|
||
/**
|
||
* 滚动到指定日期(居中显示)
|
||
* @param date 日期(Date对象或日期字符串)
|
||
*/
|
||
const scrollToDate = (date: Date | string) => {
|
||
const targetDate = typeof date === 'string' ? new Date(date) : date
|
||
const timelineStart = timelineConfig.value.startDate
|
||
|
||
// 确保日期计算的精确性 - 使用年月日,忽略时分秒
|
||
const targetNormalized = new Date(
|
||
targetDate.getFullYear(),
|
||
targetDate.getMonth(),
|
||
targetDate.getDate(),
|
||
)
|
||
|
||
// 根据不同的时间刻度使用不同的起始日期
|
||
let startNormalized: Date
|
||
if (
|
||
currentTimeScale.value === TimelineScale.YEAR ||
|
||
currentTimeScale.value === TimelineScale.QUARTER
|
||
) {
|
||
const yearRange = getYearTimelineRange()
|
||
startNormalized = new Date(
|
||
yearRange.startDate.getFullYear(),
|
||
yearRange.startDate.getMonth(),
|
||
yearRange.startDate.getDate(),
|
||
)
|
||
} else if (currentTimeScale.value === TimelineScale.MONTH) {
|
||
const monthRange = getMonthTimelineRange()
|
||
startNormalized = new Date(
|
||
monthRange.startDate.getFullYear(),
|
||
monthRange.startDate.getMonth(),
|
||
monthRange.startDate.getDate(),
|
||
)
|
||
} else {
|
||
startNormalized = new Date(
|
||
timelineStart.getFullYear(),
|
||
timelineStart.getMonth(),
|
||
timelineStart.getDate(),
|
||
)
|
||
}
|
||
|
||
// 计算目标日期距离时间线开始日期的天数
|
||
const timeDiff = targetNormalized.getTime() - startNormalized.getTime()
|
||
const daysDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24))
|
||
|
||
// 计算目标日期在时间线中的像素位置(根据当前时间刻度)
|
||
let datePosition: number
|
||
|
||
if (currentTimeScale.value === TimelineScale.HOUR) {
|
||
// 小时视图:精确到小时的定位
|
||
const targetHour = targetDate.getHours()
|
||
const targetMinute = targetDate.getMinutes()
|
||
|
||
// 基础天数偏移(到目标日0点的位置)
|
||
const baseDayPosition = daysDiff * dayWidth.value
|
||
|
||
// 小时偏移:每小时40px
|
||
const hourOffset = targetHour * 40
|
||
|
||
// 分钟偏移:在当前小时内的精确位置
|
||
const minuteOffset = (targetMinute / 60) * 40
|
||
|
||
datePosition = baseDayPosition + hourOffset + minuteOffset
|
||
} else if (currentTimeScale.value === TimelineScale.QUARTER) {
|
||
// 季度视图:计算季度偏移
|
||
const targetYear = targetNormalized.getFullYear()
|
||
const baseYear = startNormalized.getFullYear()
|
||
|
||
const yearWidth = 240 // 每年4季度 * 60px
|
||
const quarterWidth = 60
|
||
|
||
// 计算年份偏移
|
||
const yearOffset = targetYear - baseYear
|
||
datePosition = yearOffset * yearWidth
|
||
|
||
// 计算季度内的偏移
|
||
const targetQuarter = Math.floor(targetNormalized.getMonth() / 3)
|
||
datePosition += targetQuarter * quarterWidth
|
||
|
||
// 计算季度内的天数偏移(季度内的细微定位)
|
||
const quarterStartMonth = targetQuarter * 3
|
||
const quarterStartDate = new Date(targetYear, quarterStartMonth, 1)
|
||
const daysIntoQuarter = Math.floor(
|
||
(targetNormalized.getTime() - quarterStartDate.getTime()) / (1000 * 60 * 60 * 24),
|
||
)
|
||
const avgDaysInQuarter = 91 // 平均每季度91天
|
||
datePosition += (daysIntoQuarter / avgDaysInQuarter) * quarterWidth
|
||
} else if (currentTimeScale.value === TimelineScale.YEAR) {
|
||
// 年视图:计算年内偏移
|
||
const targetYear = targetNormalized.getFullYear()
|
||
const baseYear = startNormalized.getFullYear()
|
||
|
||
const yearWidth = 360 // 每年360px
|
||
|
||
// 计算年份偏移
|
||
const yearOffset = targetYear - baseYear
|
||
datePosition = yearOffset * yearWidth
|
||
|
||
// 计算年内的天数偏移
|
||
const yearStartDate = new Date(targetYear, 0, 1)
|
||
const daysIntoYear = Math.floor(
|
||
(targetNormalized.getTime() - yearStartDate.getTime()) / (1000 * 60 * 60 * 24),
|
||
)
|
||
const daysInYear = 365 // 不考虑闰年的简化处理
|
||
datePosition += (daysIntoYear / daysInYear) * yearWidth
|
||
} else if (currentTimeScale.value === TimelineScale.MONTH) {
|
||
// 月视图:需要累计每个月的实际宽度
|
||
const targetYear = targetNormalized.getFullYear()
|
||
const targetMonth = targetNormalized.getMonth()
|
||
const baseYear = startNormalized.getFullYear()
|
||
const baseMonth = startNormalized.getMonth()
|
||
|
||
const monthWidth = 60 // 每月60px
|
||
|
||
// 计算跨越的月数
|
||
const monthsDiff = (targetYear - baseYear) * 12 + (targetMonth - baseMonth)
|
||
datePosition = monthsDiff * monthWidth
|
||
|
||
// 计算月内的天数偏移
|
||
const targetDay = targetNormalized.getDate()
|
||
const daysInMonth = new Date(targetYear, targetMonth + 1, 0).getDate()
|
||
datePosition += (targetDay / daysInMonth) * monthWidth
|
||
} else if (currentTimeScale.value === TimelineScale.WEEK) {
|
||
// 周视图:每周60px
|
||
const weekWidth = 60
|
||
datePosition = (daysDiff / 7) * weekWidth
|
||
} else {
|
||
// 日视图:每天30px
|
||
datePosition = daysDiff * dayWidth.value
|
||
}
|
||
|
||
// 使用缓存的容器元素
|
||
const timeline = timelineContainerElement.value
|
||
if (!timeline) return
|
||
|
||
const containerWidth = timeline.clientWidth
|
||
|
||
// 计算居中滚动位置
|
||
const centeredScrollPosition = datePosition - containerWidth / 2
|
||
|
||
// 滚动到指定位置,确保目标日期在中间
|
||
timeline.scrollTo({
|
||
left: Math.max(0, centeredScrollPosition),
|
||
behavior: 'smooth',
|
||
})
|
||
}
|
||
|
||
// 更新任务
|
||
const updateTask = (updatedTask: Task) => {
|
||
perfMonitor2.start('updateTask')
|
||
|
||
// 不直接修改props数据,而是通过事件通知父组件
|
||
// 触发全局事件,通知父组件更新数据
|
||
window.dispatchEvent(
|
||
new CustomEvent('task-updated', {
|
||
detail: updatedTask,
|
||
}),
|
||
)
|
||
|
||
perfMonitor2.end('updateTask')
|
||
}
|
||
|
||
// 处理TaskBar双击事件 - 只emit事件
|
||
const handleTaskBarDoubleClick = (task: Task) => {
|
||
// 高亮模式下禁用双击
|
||
if (isInHighlightMode.value) {
|
||
return
|
||
}
|
||
emit('edit-task', task)
|
||
}
|
||
|
||
// 处理TaskBar单击事件 - 发出事件
|
||
const handleTaskBarClick = (task: Task, event: MouseEvent) => {
|
||
// 高亮模式下禁用单击
|
||
if (isInHighlightMode.value) {
|
||
return
|
||
}
|
||
emit('click-task', task, event)
|
||
}
|
||
|
||
// 优化:使用 shallowRef 减少深度响应式开销(只需追踪对象引用变化,不需要追踪内部每个坐标)
|
||
const taskBarPositions = shallowRef<
|
||
Record<number, { left: number; top: number; width: number; height: number }>
|
||
>({})
|
||
|
||
const bodyContentRef = ref<HTMLElement | null>(null)
|
||
// 🚀 LinkDragGuide 命令式 API 引用
|
||
const linkDragGuideRef = ref<InstanceType<typeof LinkDragGuide> | null>(null)
|
||
const svgWidth = ref(0)
|
||
const svgHeight = ref(0)
|
||
|
||
// Canvas 关系线尺寸(用于 GanttLinks 组件)
|
||
const canvasWidth = ref(0)
|
||
const canvasHeight = ref(0)
|
||
const canvasOffsetLeft = ref(0) // Canvas 在全局坐标系中的偏移量
|
||
const canvasOffsetTop = ref(0)
|
||
|
||
// 虚拟渲染 Canvas 的安全宽度(防止超过浏览器限制)
|
||
// 可根据实际需求调整:
|
||
// - 5000: 最小内存 (~30MB),适合低端设备,但滚动时更频繁更新
|
||
// - 10000: 平衡选择 (~60MB),覆盖小时视图 10 天,周视图 2 年
|
||
const SAFE_CANVAS_WIDTH = 5000 // 平衡性能和覆盖范围
|
||
const SAFE_CANVAS_HEIGHT = 5000
|
||
|
||
function updateSvgSize() {
|
||
if (bodyContentRef.value) {
|
||
// 获取 bodyContent 的总宽度和可视区域宽度
|
||
const totalWidth = bodyContentRef.value.offsetWidth
|
||
const totalHeight = contentHeight.value
|
||
|
||
// 使用已经维护的 timelineScrollLeft,而不是从 DOM 重新读取
|
||
// 因为 handleTimelineScroll 已经实时更新了这个值
|
||
const scrollLeft = timelineScrollLeft.value
|
||
const scrollTop = timelineBodyScrollTop.value
|
||
|
||
// 虚拟渲染策略(统一模式):
|
||
// Canvas 始终使用固定安全宽度,通过 offsetLeft 动态定位
|
||
canvasWidth.value = SAFE_CANVAS_WIDTH
|
||
|
||
// 计算 Canvas 覆盖的起始位置
|
||
// 策略:以当前滚动位置为基准,向左扩展 1/3,向右扩展 2/3
|
||
const bufferLeft = SAFE_CANVAS_WIDTH / 3
|
||
let idealOffsetLeft = Math.max(0, scrollLeft - bufferLeft)
|
||
|
||
// 确保 Canvas 不超出内容范围
|
||
// 1. 如果总宽度 <= Canvas 宽度,Canvas 从 0 开始
|
||
// 2. 如果总宽度 > Canvas 宽度,Canvas 不能超过右边界
|
||
if (totalWidth <= SAFE_CANVAS_WIDTH) {
|
||
idealOffsetLeft = 0
|
||
} else {
|
||
const maxOffsetLeft = totalWidth - SAFE_CANVAS_WIDTH
|
||
idealOffsetLeft = Math.min(idealOffsetLeft, maxOffsetLeft)
|
||
}
|
||
|
||
canvasOffsetLeft.value = idealOffsetLeft
|
||
|
||
const clampedHeight = Math.min(totalHeight, SAFE_CANVAS_HEIGHT)
|
||
canvasHeight.value = clampedHeight
|
||
svgWidth.value = canvasWidth.value
|
||
svgHeight.value = clampedHeight
|
||
|
||
const bufferTop = clampedHeight / 3
|
||
let idealOffsetTop = Math.max(0, scrollTop - bufferTop)
|
||
|
||
if (totalHeight <= clampedHeight) {
|
||
idealOffsetTop = 0
|
||
} else {
|
||
const maxOffsetTop = totalHeight - clampedHeight
|
||
idealOffsetTop = Math.min(idealOffsetTop, maxOffsetTop)
|
||
}
|
||
|
||
canvasOffsetTop.value = idealOffsetTop
|
||
}
|
||
}
|
||
|
||
function handleBarMounted(payload: {
|
||
id: number
|
||
left: number
|
||
top: number
|
||
width: number
|
||
height: number
|
||
}) {
|
||
if (!bodyContentRef.value) return
|
||
const baseRect = bodyContentRef.value.getBoundingClientRect()
|
||
// 统一坐标系:以bodyContent为基准
|
||
// 注意:使用 shallowRef 时,需要触发整个对象引用的变化
|
||
taskBarPositions.value = {
|
||
...taskBarPositions.value,
|
||
[payload.id]: {
|
||
left: payload.left - baseRect.left,
|
||
top: payload.top - baseRect.top,
|
||
width: payload.width,
|
||
height: payload.height,
|
||
},
|
||
}
|
||
setTimeout(() => {
|
||
updateSvgSize()
|
||
}, 200)
|
||
}
|
||
|
||
// 向上传递 TaskBar 拖拽/拉伸事件
|
||
const handleTaskBarDragEnd = (updatedTask: Task) => {
|
||
// 如果是资源视图,需要更新dataSource中的资源数据
|
||
if (viewMode.value === 'resource' && dataSource.value) {
|
||
let targetResourceId: string | number | null = null
|
||
let targetResource: any = null
|
||
|
||
for (const resource of dataSource.value as any[]) {
|
||
if (resource.tasks) {
|
||
const taskIndex = resource.tasks.findIndex((t: Task) => t.id === updatedTask.id)
|
||
if (taskIndex !== -1) {
|
||
// 更新资源中的任务数据
|
||
resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask }
|
||
targetResourceId = resource.id
|
||
targetResource = resource
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// 🎯 关键修复:清除该资源的布局缓存,触发自动换行重新计算
|
||
if (targetResourceId !== null && targetResource) {
|
||
// 清除所有该资源的缓存(通配符删除,因为时间可能变化了)
|
||
const keysToDelete = Array.from(layoutCache.keys()).filter(key => key.startsWith(`${targetResourceId}-`))
|
||
keysToDelete.forEach(key => layoutCache.delete(key))
|
||
|
||
// 获取旧布局信息
|
||
const oldLayout = resourceTaskLayouts.value.get(targetResourceId)
|
||
const oldRowCount = oldLayout?.rowHeights.length || 1
|
||
|
||
// 重新计算布局
|
||
const newLayout = getResourceLayout(targetResource)
|
||
const newRowCount = newLayout.rowHeights.length
|
||
|
||
// 只有当行数发生变化时,才需要触发全量重绘
|
||
if (newRowCount !== oldRowCount) {
|
||
// 行数变化,需要触发重绘
|
||
taskBarRenderKey.value++
|
||
}
|
||
}
|
||
}
|
||
// 记录变化的TaskBar ID(用于增量冲突更新)
|
||
lastChangedTaskId.value = updatedTask.id
|
||
// 通过全局事件或 emit/props 回调传递给 GanttChart
|
||
window.dispatchEvent(new CustomEvent('taskbar-drag-end', { detail: updatedTask }))
|
||
}
|
||
const handleTaskBarResizeEnd = (updatedTask: Task) => {
|
||
// 如果是资源视图,需要更新dataSource中的资源数据
|
||
if (viewMode.value === 'resource' && dataSource.value) {
|
||
let targetResourceId: string | number | null = null
|
||
let targetResource: any = null
|
||
|
||
for (const resource of dataSource.value as any[]) {
|
||
if (resource.tasks) {
|
||
const taskIndex = resource.tasks.findIndex((t: Task) => t.id === updatedTask.id)
|
||
if (taskIndex !== -1) {
|
||
// 更新资源中的任务数据
|
||
resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask }
|
||
targetResourceId = resource.id
|
||
targetResource = resource
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// 🎯 关键修复:清除该资源的布局缓存,触发自动换行重新计算
|
||
if (targetResourceId !== null && targetResource) {
|
||
// 清除所有该资源的缓存(通配符删除,因为时间可能变化了)
|
||
const keysToDelete = Array.from(layoutCache.keys()).filter(key => key.startsWith(`${targetResourceId}-`))
|
||
keysToDelete.forEach(key => layoutCache.delete(key))
|
||
|
||
// 获取旧布局信息
|
||
const oldLayout = resourceTaskLayouts.value.get(targetResourceId)
|
||
const oldRowCount = oldLayout?.rowHeights.length || 1
|
||
|
||
// 重新计算布局
|
||
const newLayout = getResourceLayout(targetResource)
|
||
const newRowCount = newLayout.rowHeights.length
|
||
|
||
// 🔧 修复:不再触发 taskBarRenderKey 更新,避免整个视图重建导致闪烁
|
||
// 资源布局的更新会通过 resourceTaskLayouts 的响应式自动触发重新渲染
|
||
// 注释掉以避免不必要的全量重绘:
|
||
// if (newRowCount !== oldRowCount) {
|
||
// taskBarRenderKey.value++
|
||
// }
|
||
}
|
||
}
|
||
// 记录变化的TaskBar ID(用于增量冲突更新)
|
||
lastChangedTaskId.value = updatedTask.id
|
||
window.dispatchEvent(new CustomEvent('taskbar-resize-end', { detail: updatedTask }))
|
||
}
|
||
|
||
// 处理TaskBar右键菜单事件 - 将事件转发给父组件
|
||
const handleTaskBarContextMenu = (event: { task: Task; position: { x: number; y: number } }) => {
|
||
// 将事件转发为全局事件,让GanttChart组件处理
|
||
window.dispatchEvent(
|
||
new CustomEvent('context-menu', {
|
||
detail: event,
|
||
}),
|
||
)
|
||
}
|
||
|
||
// 处理TaskBar的滚动定位请求
|
||
const handleScrollToPosition = (targetScrollLeft: number) => {
|
||
if (timelineContainer.value) {
|
||
// 开始自动滚动时隐藏半圆
|
||
hideBubbles.value = true
|
||
|
||
// 确保滚动位置在有效范围内
|
||
const maxScrollLeft = timelineContainer.value.scrollWidth - timelineContainer.value.clientWidth
|
||
const clampedScrollLeft = Math.max(0, Math.min(targetScrollLeft, maxScrollLeft))
|
||
|
||
// 平滑滚动到目标位置
|
||
timelineContainer.value.scrollTo({
|
||
left: clampedScrollLeft,
|
||
behavior: 'smooth',
|
||
})
|
||
|
||
// 滚动结束后延迟显示半圆
|
||
setTimeout(() => {
|
||
hideBubbles.value = false
|
||
}, 1000) // 给滚动动画留1秒时间
|
||
}
|
||
}
|
||
|
||
// 向上传递 MilestonePoint 拖拽事件
|
||
const handleMilestoneDragEnd = (updatedMilestone: Milestone) => {
|
||
window.dispatchEvent(new CustomEvent('milestone-drag-end', { detail: updatedMilestone }))
|
||
}
|
||
|
||
onMounted(() => {
|
||
// 等待下一帧,确保DOM和数据都已渲染
|
||
nextTick(() => {
|
||
setTimeout(() => {
|
||
scrollToTodayCenter()
|
||
}, 60) // 增加延迟,确保宽度和数据都已渲染
|
||
})
|
||
// 监听TaskList的双击事件
|
||
window.addEventListener('task-row-double-click', handleTaskListDoubleClick as EventListener)
|
||
// 监听TaskList的悬停事件
|
||
window.addEventListener('task-list-hover', handleTaskListHover as EventListener)
|
||
// 监听TaskList的垂直滚动事件
|
||
window.addEventListener(
|
||
'task-list-vertical-scroll',
|
||
handleTaskListVerticalScroll as EventListener,
|
||
)
|
||
// 监听语言变化
|
||
window.addEventListener('locale-changed', handleLocaleChange as EventListener)
|
||
// 监听Splitter拖拽事件
|
||
window.addEventListener('splitter-drag-start', handleSplitterDragStart as EventListener)
|
||
window.addEventListener('splitter-drag-end', handleSplitterDragEnd as EventListener)
|
||
// 监听Timeline容器resize事件(TaskList切换等)
|
||
window.addEventListener(
|
||
'timeline-container-resized',
|
||
handleTimelineContainerResized as EventListener,
|
||
)
|
||
|
||
// 监听里程碑点击定位事件
|
||
window.addEventListener('milestone-click-locate', handleMilestoneClickLocate as EventListener)
|
||
|
||
// 监听拖拽边界检测事件
|
||
window.addEventListener('drag-boundary-check', handleDragBoundaryCheck as EventListener)
|
||
|
||
// 监听TaskBar高亮事件
|
||
window.addEventListener('taskbar-highlighted', handleTaskBarHighlighted as EventListener)
|
||
|
||
// 监听资源视图垂直拖拽事件
|
||
window.addEventListener('resource-taskbar-drop', handleResourceTaskBarDrop as EventListener)
|
||
|
||
// 设置ResizeObserver监听timeline-body的尺寸变化
|
||
nextTick(() => {
|
||
// 初始化并缓存 DOM 元素引用
|
||
const timelineBody = document.querySelector('.timeline-body') as HTMLElement
|
||
const timelineContainer = document.querySelector('.timeline') as HTMLElement
|
||
const timelinePanel = document.querySelector('.gantt-panel-right') as HTMLElement
|
||
|
||
// 缓存到 ref 中
|
||
timelineBodyElement.value = timelineBody
|
||
timelineContainerElement.value = timelineContainer
|
||
timelinePanelElement.value = timelinePanel
|
||
|
||
if (timelineBody) {
|
||
timelineBodyHeight.value = timelineBody.clientHeight
|
||
|
||
resizeObserver = new ResizeObserver(entries => {
|
||
for (const entry of entries) {
|
||
timelineBodyHeight.value = entry.contentRect.height
|
||
}
|
||
})
|
||
|
||
resizeObserver.observe(timelineBody)
|
||
}
|
||
|
||
// 初始化滚动位置信息,使用正确的滚动容器
|
||
if (timelineContainer) {
|
||
timelineScrollLeft.value = timelineContainer.scrollLeft
|
||
timelineContainerWidth.value = timelineContainer.clientWidth
|
||
|
||
// 为容器宽度变化创建独立的ResizeObserver
|
||
const containerResizeObserver = new ResizeObserver(entries => {
|
||
for (const entry of entries) {
|
||
const newWidth = entry.contentRect.width
|
||
// 当容器宽度发生变化时,立即更新宽度并重新计算半圆显示
|
||
if (Math.abs(newWidth - timelineContainerWidth.value) > 1) {
|
||
timelineContainerWidth.value = newWidth
|
||
|
||
// 对于容器宽度变化,我们需要立即重新计算半圆状态
|
||
// 短时间隐藏后重新显示,让TaskBar重新计算边界
|
||
hideBubbles.value = true
|
||
|
||
// 清除之前的定时器,避免多次触发冲突
|
||
if (hideBubblesTimeout) {
|
||
clearTimeout(hideBubblesTimeout)
|
||
}
|
||
|
||
// 延迟恢复显示,确保宽度变化完全生效
|
||
hideBubblesTimeout = setTimeout(() => {
|
||
hideBubbles.value = false
|
||
hideBubblesTimeout = null
|
||
}, 300) // 增加到300ms,确保resize完全结束
|
||
}
|
||
}
|
||
})
|
||
containerResizeObserver.observe(timelineContainer)
|
||
|
||
// 将容器ResizeObserver也存储起来,用于清理
|
||
if (!resizeObserver) {
|
||
resizeObserver = containerResizeObserver
|
||
}
|
||
}
|
||
})
|
||
|
||
// 页面加载后,直接将今日定位到中间
|
||
// 增加延迟时间,确保DOM元素渲染完成
|
||
setTimeout(() => {
|
||
scrollToTodayCenter()
|
||
updateSvgSize()
|
||
}, 200)
|
||
window.addEventListener('resize', updateSvgSize)
|
||
// 注意:Timeline滚动事件已在模板中通过@scroll="handleTimelineScroll"绑定,无需重复监听
|
||
})
|
||
|
||
// 处理TaskList垂直滚动同步
|
||
const handleTaskListVerticalScroll = (event: CustomEvent) => {
|
||
const { scrollTop } = event.detail
|
||
|
||
// 立即更新纵向滚动位置(用于虚拟滚动计算)
|
||
timelineBodyScrollTop.value = scrollTop
|
||
|
||
debouncedUpdateCanvasPosition()
|
||
|
||
if (timelineBodyElement.value && Math.abs(timelineBodyElement.value.scrollTop - scrollTop) > 1) {
|
||
// 使用更精确的比较,避免1px以内的细微差异导致的循环触发
|
||
timelineBodyElement.value.scrollTop = scrollTop
|
||
}
|
||
}
|
||
|
||
// 处理Timeline body的垂直滚动同步
|
||
const handleTimelineBodyScroll = (event: Event) => {
|
||
const target = event.target as HTMLElement
|
||
if (!target) return
|
||
|
||
const scrollTop = target.scrollTop
|
||
|
||
// 立即更新纵向滚动位置(用于虚拟滚动计算)
|
||
timelineBodyScrollTop.value = scrollTop
|
||
|
||
// 优化:滚动时失效 bodyRect 缓存(用于连接线拖拽)
|
||
bodyRectInvalidated = true
|
||
|
||
debouncedUpdateCanvasPosition()
|
||
|
||
// 拖拽时不同步滚动事件,避免性能问题
|
||
if (isDragging.value) return
|
||
|
||
// 同步垂直滚动到TaskList
|
||
if (scrollTop >= 0) {
|
||
window.dispatchEvent(
|
||
new CustomEvent('timeline-vertical-scroll', {
|
||
detail: { scrollTop },
|
||
}),
|
||
)
|
||
}
|
||
}
|
||
|
||
// 监听任务数量变化,更新SVG尺寸
|
||
watch(
|
||
() => tasks.value.length,
|
||
() => {
|
||
nextTick(() => {
|
||
updateSvgSize()
|
||
})
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// 优化:监听任务变化,更新任务 ID Map 缓存
|
||
watch(
|
||
tasks,
|
||
newTasks => {
|
||
taskIdMap.clear()
|
||
const addTaskToMap = (task: Task) => {
|
||
taskIdMap.set(task.id, task)
|
||
if (task.children && task.children.length > 0) {
|
||
task.children.forEach(addTaskToMap)
|
||
}
|
||
}
|
||
newTasks.forEach(addTaskToMap)
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// 拖拽滑动相关状态
|
||
const isDragging = ref(false)
|
||
provide('isDraggingTimeline', isDragging) // 提供给GanttConflicts使用
|
||
const startX = ref(0)
|
||
const startY = ref(0)
|
||
const startScrollLeft = ref(0)
|
||
const startScrollTop = ref(0)
|
||
const timelineContainer = ref<HTMLElement | null>(null)
|
||
const timelineBodyElement = ref<HTMLElement | null>(null) // 缓存timeline-body元素引用
|
||
let scrollRafId: number | null = null // 时间轴拖拽滚动的 RAF ID
|
||
|
||
// 边界滚动相关状态
|
||
const isAutoScrolling = ref(false)
|
||
let autoScrollTimer: number | null = null
|
||
const EDGE_SCROLL_ZONE = 50 // 边界滚动触发区域宽度
|
||
const EDGE_SCROLL_SPEED = 5 // 每次滚动的像素数
|
||
|
||
// 处理TaskBar高亮事件 - 如果用户仍在按住鼠标,启动拖拽滚动
|
||
const handleTaskBarHighlighted = () => {
|
||
// 检查是否有鼠标按钮按下(buttons > 0 表示至少有一个按钮按下)
|
||
// 注意:这里无法直接获取鼠标状态,所以我们添加一个全局监听器
|
||
// 在下一次 mousemove 时启动拖拽滚动
|
||
const handleNextMouseMove = (e: MouseEvent) => {
|
||
// 🔧 修复:检查是否有 TaskBar 正在被拖拽或 resize
|
||
// 如果有,不应该触发 Timeline 拖拽,避免与 TaskBar 交互冲突
|
||
if (isDraggingTaskBar.value) {
|
||
document.removeEventListener('mousemove', handleNextMouseMove)
|
||
return
|
||
}
|
||
|
||
// 🔧 修复:检查鼠标是否在 TaskBar、resize 手柄或其他交互元素上
|
||
// 如果是,不应该触发 Timeline 拖拽
|
||
const target = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement
|
||
if (target) {
|
||
const isOnTaskBar = target.closest('.task-bar')
|
||
const isOnResizeHandle = target.closest('.resize-handle-left, .resize-handle-right')
|
||
const isOnInteractive = target.closest('button, input, select, textarea, .custom-task-content')
|
||
|
||
if (isOnTaskBar || isOnResizeHandle || isOnInteractive) {
|
||
document.removeEventListener('mousemove', handleNextMouseMove)
|
||
return
|
||
}
|
||
}
|
||
|
||
// 检查鼠标左键是否按下
|
||
if (e.buttons === 1) {
|
||
// 启动拖拽滚动
|
||
isDragging.value = true
|
||
startX.value = e.pageX
|
||
startScrollLeft.value = timelineContainer.value?.scrollLeft || 0
|
||
|
||
if (timelineContainer.value) {
|
||
timelineContainer.value.style.cursor = 'grabbing'
|
||
timelineContainer.value.style.userSelect = 'none'
|
||
}
|
||
|
||
// 添加事件监听器
|
||
document.addEventListener('mousemove', handleMouseMove)
|
||
document.addEventListener('mouseup', handleMouseUp)
|
||
}
|
||
|
||
// 只监听一次
|
||
document.removeEventListener('mousemove', handleNextMouseMove)
|
||
}
|
||
|
||
// 添加临时监听器,等待下一次鼠标移动
|
||
document.addEventListener('mousemove', handleNextMouseMove)
|
||
|
||
// 5秒后自动清理监听器(防止内存泄漏)
|
||
setTimeout(() => {
|
||
document.removeEventListener('mousemove', handleNextMouseMove)
|
||
}, 5000)
|
||
}
|
||
|
||
// v1.9.0 资源视图垂直拖拽:处理TaskBar拖放到不同资源行
|
||
const handleResourceTaskBarDrop = (event: Event) => {
|
||
const customEvent = event as CustomEvent
|
||
// @ts-expect-error - taskId和mouseX预留但当前未使用
|
||
const { taskId, task, sourceRowIndex, mouseY, mouseX } = customEvent.detail
|
||
|
||
// 计算目标资源行索引
|
||
const timelineBody = timelineBodyElement.value
|
||
if (!timelineBody) return
|
||
|
||
const bodyRect = timelineBody.getBoundingClientRect()
|
||
const relativeY = mouseY - bodyRect.top + timelineBody.scrollTop
|
||
|
||
// v1.9.0 使用动态的资源行位置来计算目标行索引
|
||
const resources = dataSource.value as Resource[]
|
||
let targetRowIndex = -1
|
||
let minDistance = Infinity
|
||
|
||
// 遍历资源,找到距离鼠标位置最近的资源行中心
|
||
for (let i = 0; i < resources.length; i++) {
|
||
const resource = resources[i]
|
||
const resourceId = String(resource.id)
|
||
const rowTop = resourceRowPositions.value.get(resourceId) || 0
|
||
// v1.9.6 Phase2: 使用getResourceLayout确保获取到布局(自动缓存)
|
||
const layout = resourceTaskLayouts.value.get(resourceId) || getResourceLayout(resource)
|
||
const rowHeight = layout?.totalHeight || 51
|
||
const rowCenter = rowTop + rowHeight / 2
|
||
const distance = Math.abs(relativeY - rowCenter)
|
||
|
||
if (distance < minDistance) {
|
||
minDistance = distance
|
||
targetRowIndex = i
|
||
}
|
||
}
|
||
|
||
// 如果没有找到目标行(不太可能),使用最近的边界
|
||
if (targetRowIndex === -1 && resources.length > 0) {
|
||
if (relativeY < 0) {
|
||
targetRowIndex = 0
|
||
} else {
|
||
targetRowIndex = resources.length - 1
|
||
}
|
||
}
|
||
|
||
// v1.9.0 直接使用TaskBar传递过来的精确日期,避免重复计算导致误差
|
||
const newStartDate = customEvent.detail.calculatedStartDate
|
||
const newEndDate = customEvent.detail.calculatedEndDate
|
||
|
||
// 如果目标行与源行不同,发送事件给父组件(demo)处理
|
||
if (targetRowIndex !== sourceRowIndex && targetRowIndex >= 0 && targetRowIndex < resources.length) {
|
||
const targetResource = resources[targetRowIndex]
|
||
|
||
// 发送事件给父组件,让demo显示确认对话框
|
||
emit('resource-drag-end', {
|
||
task,
|
||
sourceResourceIndex: sourceRowIndex,
|
||
targetResourceIndex: targetRowIndex,
|
||
targetResource,
|
||
newStartDate, // v1.9.0 新的开始日期
|
||
newEndDate, // v1.9.0 新的结束日期
|
||
})
|
||
}
|
||
}
|
||
|
||
// 鼠标按下开始拖拽(在时间轴表头和body区域)
|
||
const handleMouseDown = (event: MouseEvent) => {
|
||
const target = event.target as HTMLElement
|
||
|
||
// 检查是否点击的是允许拖拽的区域
|
||
const isInHeader = target.closest('.timeline-header')
|
||
const isInBody = target.closest('.timeline-body')
|
||
|
||
// 如果不在header或body区域,直接返回
|
||
if (!isInHeader && !isInBody) {
|
||
return
|
||
}
|
||
|
||
// 如果在body区域,需要检查是否点击了交互元素
|
||
if (isInBody) {
|
||
// 🔧 修复:检查是否点击了 TaskBar 的 resize 手柄
|
||
const resizeHandle = target.closest('.resize-handle-left, .resize-handle-right') as HTMLElement
|
||
if (resizeHandle) {
|
||
// 点击了 resize 手柄,不应该触发 Timeline 拖拽
|
||
return
|
||
}
|
||
|
||
// 检查是否点击了TaskBar
|
||
const taskBarElement = target.closest('.task-bar') as HTMLElement
|
||
|
||
// 如果点击了TaskBar,检查是否为高亮状态
|
||
if (taskBarElement) {
|
||
const isHighlighted = taskBarElement.classList.contains('highlighted')
|
||
const isPrimaryHighlight = taskBarElement.classList.contains('primary-highlight')
|
||
|
||
// 如果是高亮状态的TaskBar,允许拖拽滚动
|
||
// 否则返回,让TaskBar自己处理交互
|
||
if (!isHighlighted && !isPrimaryHighlight) {
|
||
return
|
||
}
|
||
} else {
|
||
// 如果不是TaskBar,排除其他交互元素
|
||
const interactiveElements = [
|
||
'.milestone',
|
||
'button',
|
||
'input',
|
||
'select',
|
||
'textarea',
|
||
'.custom-task-content',
|
||
'.progress-bar',
|
||
'.task-name',
|
||
'.task-controls',
|
||
]
|
||
|
||
const isInteractiveElement = interactiveElements.some(selector => target.closest(selector))
|
||
|
||
if (isInteractiveElement) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
isDragging.value = true
|
||
startX.value = event.pageX
|
||
startY.value = event.pageY
|
||
startScrollLeft.value = timelineContainer.value?.scrollLeft || 0
|
||
|
||
// 获取并缓存timeline-body元素的scrollTop(支持垂直滚动)
|
||
if (!timelineBodyElement.value && timelineContainer.value) {
|
||
timelineBodyElement.value = timelineContainer.value.querySelector('.timeline-body') as HTMLElement
|
||
}
|
||
startScrollTop.value = timelineBodyElement.value?.scrollTop || 0
|
||
|
||
// 添加鼠标样式
|
||
if (timelineContainer.value) {
|
||
timelineContainer.value.style.cursor = 'grabbing'
|
||
timelineContainer.value.style.userSelect = 'none'
|
||
}
|
||
|
||
// 阻止默认行为
|
||
event.preventDefault()
|
||
|
||
// 添加全局事件监听器
|
||
document.addEventListener('mousemove', handleMouseMove)
|
||
document.addEventListener('mouseup', handleMouseUp)
|
||
}
|
||
|
||
// 鼠标移动时拖拽滑动(支持水平和垂直方向)
|
||
const handleMouseMove = (event: MouseEvent) => {
|
||
if (!isDragging.value || !timelineContainer.value) return
|
||
|
||
event.preventDefault()
|
||
|
||
// 取消之前的 RAF
|
||
if (scrollRafId !== null) {
|
||
cancelAnimationFrame(scrollRafId)
|
||
}
|
||
|
||
// 使用 requestAnimationFrame 批处理滚动更新
|
||
scrollRafId = requestAnimationFrame(() => {
|
||
if (!timelineContainer.value) return
|
||
|
||
// 计算水平和垂直移动距离
|
||
const x = event.pageX
|
||
const y = event.pageY
|
||
const walkX = (x - startX.value) * 1.5 // 水平拖拽速度倍数
|
||
const walkY = (y - startY.value) * 1.5 // 垂直拖拽速度倍数
|
||
|
||
// 水平滚动(timeline容器)
|
||
timelineContainer.value.scrollLeft = startScrollLeft.value - walkX
|
||
|
||
// 垂直滚动(timeline-body元素)- 使用缓存的元素引用
|
||
if (timelineBodyElement.value) {
|
||
const newScrollTop = startScrollTop.value - walkY
|
||
timelineBodyElement.value.scrollTop = newScrollTop
|
||
|
||
// 直接同步 TaskList 的滚动位置,避免通过事件触发
|
||
const taskListBody = document.querySelector('.task-list-body') as HTMLElement
|
||
if (taskListBody) {
|
||
taskListBody.scrollTop = newScrollTop
|
||
}
|
||
}
|
||
|
||
scrollRafId = null
|
||
})
|
||
}
|
||
|
||
// 鼠标抬起结束拖拽
|
||
const handleMouseUp = () => {
|
||
// 🔧 修复:只有在真正处于拖拽状态时才执行清理
|
||
// 避免 TaskBar 的 resize 事件误触发 Timeline 的拖拽结束逻辑
|
||
if (!isDragging.value) {
|
||
return
|
||
}
|
||
|
||
isDragging.value = false
|
||
|
||
// 取消任何待处理的 RAF
|
||
if (scrollRafId !== null) {
|
||
cancelAnimationFrame(scrollRafId)
|
||
scrollRafId = null
|
||
}
|
||
|
||
if (timelineContainer.value) {
|
||
timelineContainer.value.style.cursor = 'grab'
|
||
timelineContainer.value.style.userSelect = 'auto'
|
||
}
|
||
|
||
// 清空 hover 状态,避免拖动结束后立即触发 hover 重绘
|
||
hoveredTaskId.value = null
|
||
|
||
// 移除全局事件监听器
|
||
document.removeEventListener('mousemove', handleMouseMove)
|
||
document.removeEventListener('mouseup', handleMouseUp)
|
||
}
|
||
|
||
// 检测滚动状态(优化版:减少计算和事件派发)
|
||
const handleTimelineScroll = (event: Event) => {
|
||
const target = event.target as HTMLElement
|
||
if (!target) return
|
||
|
||
const scrollLeft = target.scrollLeft
|
||
|
||
// 立即更新关键滚动位置信息(用于虚拟滚动)
|
||
timelineScrollLeft.value = scrollLeft
|
||
|
||
// 滚动时关闭所有右键菜单
|
||
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
|
||
|
||
// 优化:滚动时失效 bodyRect 缓存(用于连接线拖拽)
|
||
bodyRectInvalidated = true
|
||
|
||
// 只在容器宽度未初始化时更新
|
||
if (timelineContainerWidth.value === 0) {
|
||
timelineContainerWidth.value = target.clientWidth
|
||
}
|
||
|
||
// 标记初始化完成(第一次滚动后)
|
||
if (isInitialLoad.value && scrollLeft > 0) {
|
||
isInitialLoad.value = false
|
||
}
|
||
|
||
// 虚拟渲染:滚动时更新 Canvas 位置(防抖处理)
|
||
debouncedUpdateCanvasPosition()
|
||
|
||
// [nelson][2026-02-04]拖拽时不同步(避免性能问题)
|
||
if (isDragging.value) return
|
||
|
||
// 小时视图简化处理
|
||
if (currentTimeScale.value === TimelineScale.HOUR) {
|
||
isScrolling.value = true
|
||
|
||
if (scrollTimeout) clearTimeout(scrollTimeout)
|
||
|
||
scrollTimeout = setTimeout(() => {
|
||
isScrolling.value = false
|
||
}, 200)
|
||
return
|
||
}
|
||
|
||
// 其他视图的完整处理(减少不必要的计算)
|
||
const scrollWidth = target.scrollWidth
|
||
const clientWidth = target.clientWidth
|
||
const maxScroll = scrollWidth - clientWidth
|
||
|
||
scrollProgress.value = maxScroll > 0 ? scrollLeft / maxScroll : 0
|
||
isScrolledLeft.value = scrollLeft > 20
|
||
isScrolledRight.value = scrollLeft < maxScroll - 20
|
||
|
||
isScrolling.value = true
|
||
target.classList?.add('scrolling')
|
||
|
||
debouncedUpdatePositions()
|
||
|
||
if (scrollTimeout) clearTimeout(scrollTimeout)
|
||
|
||
scrollTimeout = setTimeout(() => {
|
||
isScrolling.value = false
|
||
target.classList?.remove('scrolling')
|
||
}, 500)
|
||
}
|
||
|
||
// 边界自动滚动功能
|
||
const startAutoScroll = (direction: 'left' | 'right') => {
|
||
if (isAutoScrolling.value || !timelineContainer.value) return
|
||
|
||
isAutoScrolling.value = true
|
||
|
||
const scroll = () => {
|
||
if (!timelineContainer.value || !isAutoScrolling.value) return
|
||
|
||
const currentScrollLeft = timelineContainer.value.scrollLeft
|
||
const maxScrollLeft = timelineContainer.value.scrollWidth - timelineContainer.value.clientWidth
|
||
|
||
let newScrollLeft
|
||
if (direction === 'left') {
|
||
newScrollLeft = Math.max(0, currentScrollLeft - EDGE_SCROLL_SPEED)
|
||
} else {
|
||
newScrollLeft = Math.min(maxScrollLeft, currentScrollLeft + EDGE_SCROLL_SPEED)
|
||
}
|
||
|
||
// 如果已经到达边界,停止滚动
|
||
if (newScrollLeft === currentScrollLeft) {
|
||
stopAutoScroll()
|
||
return
|
||
}
|
||
|
||
timelineContainer.value.scrollLeft = newScrollLeft
|
||
|
||
// 通知拖拽组件滚动已发生
|
||
window.dispatchEvent(
|
||
new CustomEvent('timeline-auto-scroll', {
|
||
detail: { scrollDelta: newScrollLeft - currentScrollLeft },
|
||
}),
|
||
)
|
||
|
||
autoScrollTimer = window.setTimeout(scroll, 16) // 约60fps
|
||
}
|
||
|
||
scroll()
|
||
}
|
||
|
||
const stopAutoScroll = () => {
|
||
isAutoScrolling.value = false
|
||
if (autoScrollTimer) {
|
||
clearTimeout(autoScrollTimer)
|
||
autoScrollTimer = null
|
||
}
|
||
}
|
||
|
||
// 处理拖拽边界检测事件
|
||
const handleDragBoundaryCheck = (event: CustomEvent) => {
|
||
const { mouseX, mouseY, isDragging: dragState, isResourceView, taskId, rowIndex } = event.detail
|
||
|
||
if (!dragState || !timelineContainer.value) {
|
||
stopAutoScroll()
|
||
return
|
||
}
|
||
|
||
const containerRect = timelineContainer.value.getBoundingClientRect()
|
||
const relativeX = mouseX - containerRect.left
|
||
|
||
// v1.9.0 资源视图垂直拖拽检测
|
||
if (isResourceView && mouseY && timelineBodyElement.value) {
|
||
const bodyRect = timelineBodyElement.value.getBoundingClientRect()
|
||
const relativeY = mouseY - bodyRect.top + timelineBodyElement.value.scrollTop
|
||
|
||
// 使用动态的资源行位置来计算目标行索引(基于最近的行中心)
|
||
const resources = dataSource.value as Resource[]
|
||
let targetRowIndex = -1
|
||
let minDistance = Infinity
|
||
|
||
for (let i = 0; i < resources.length; i++) {
|
||
const resource = resources[i]
|
||
const resourceId = String(resource.id)
|
||
const rowTop = resourceRowPositions.value.get(resourceId) || 0
|
||
// v1.9.6 Phase2: 使用getResourceLayout确保获取到布局(自动缓存)
|
||
const layout = resourceTaskLayouts.value.get(resourceId) || getResourceLayout(resource)
|
||
const rowHeight = layout?.totalHeight || 51
|
||
const rowCenter = rowTop + rowHeight / 2
|
||
const distance = Math.abs(relativeY - rowCenter)
|
||
|
||
if (distance < minDistance) {
|
||
minDistance = distance
|
||
targetRowIndex = i
|
||
}
|
||
}
|
||
|
||
// 如果移动到不同行,发送事件通知(可以用于显示拖拽指示器等)
|
||
if (targetRowIndex !== -1 && targetRowIndex !== rowIndex) {
|
||
window.dispatchEvent(
|
||
new CustomEvent('resource-drag-over', {
|
||
detail: {
|
||
taskId,
|
||
sourceRowIndex: rowIndex,
|
||
targetRowIndex,
|
||
mouseY: relativeY,
|
||
},
|
||
}),
|
||
)
|
||
}
|
||
}
|
||
|
||
// 检查是否在左边界滚动区域
|
||
if (relativeX <= EDGE_SCROLL_ZONE && timelineContainer.value.scrollLeft > 0) {
|
||
startAutoScroll('left')
|
||
} else if (
|
||
relativeX >= containerRect.width - EDGE_SCROLL_ZONE &&
|
||
timelineContainer.value.scrollLeft <
|
||
timelineContainer.value.scrollWidth - timelineContainer.value.clientWidth
|
||
) {
|
||
// 检查是否在右边界滚动区域
|
||
startAutoScroll('right')
|
||
} else {
|
||
// 不在边界区域,停止自动滚动
|
||
stopAutoScroll()
|
||
}
|
||
}
|
||
|
||
onUnmounted(() => {
|
||
// 停止自动滚动
|
||
stopAutoScroll()
|
||
|
||
// 清理连接线拖拽状态
|
||
cleanupLinkDrag()
|
||
|
||
// 清理事件监听器
|
||
window.removeEventListener('task-row-double-click', handleTaskListDoubleClick as EventListener)
|
||
window.removeEventListener('task-list-hover', handleTaskListHover as EventListener)
|
||
window.removeEventListener(
|
||
'task-list-vertical-scroll',
|
||
handleTaskListVerticalScroll as EventListener,
|
||
)
|
||
window.removeEventListener('locale-changed', handleLocaleChange as EventListener)
|
||
window.removeEventListener('splitter-drag-start', handleSplitterDragStart as EventListener)
|
||
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd as EventListener)
|
||
window.removeEventListener(
|
||
'timeline-container-resized',
|
||
handleTimelineContainerResized as EventListener,
|
||
)
|
||
window.removeEventListener('milestone-click-locate', handleMilestoneClickLocate as EventListener)
|
||
window.removeEventListener('drag-boundary-check', handleDragBoundaryCheck as EventListener)
|
||
window.removeEventListener('resource-taskbar-drop', handleResourceTaskBarDrop as EventListener)
|
||
|
||
// 清理ResizeObserver
|
||
if (resizeObserver) {
|
||
resizeObserver.disconnect()
|
||
resizeObserver = null
|
||
}
|
||
|
||
// 清理window事件监听器
|
||
window.removeEventListener('resize', updateSvgSize)
|
||
|
||
// 清理可能残留的鼠标事件监听器
|
||
document.removeEventListener('mousemove', handleMouseMove)
|
||
document.removeEventListener('mouseup', handleMouseUp)
|
||
})
|
||
|
||
const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
|
||
emit('delete', task, deleteChildren)
|
||
}
|
||
|
||
// 处理删除链接事件
|
||
const handleDeleteLink = (event: { sourceTaskId: number; targetTaskId: number }) => {
|
||
const targetTask = tasks.value.find(t => t.id === event.targetTaskId)
|
||
if (!targetTask || !targetTask.predecessor) return
|
||
|
||
const predecessorIds = getPredecessorIds(targetTask.predecessor)
|
||
const newIds = predecessorIds.filter(id => id !== event.sourceTaskId)
|
||
|
||
// 创建一个新的任务对象以触发响应式更新
|
||
const updatedTask = {
|
||
...targetTask,
|
||
predecessor: newIds.length > 0 ? (newIds as any) : undefined,
|
||
}
|
||
|
||
// 触发链接删除事件,让 GanttChart 更新 tasks 数组
|
||
emit('link-deleted', {
|
||
sourceTaskId: event.sourceTaskId,
|
||
targetTaskId: event.targetTaskId,
|
||
updatedTask,
|
||
})
|
||
|
||
// 同时触发任务更新事件(兼容现有逻辑)
|
||
updateTask(updatedTask)
|
||
|
||
// 强制清除缓存,确保时间轴数据重新计算
|
||
clearTimelineCache()
|
||
}
|
||
|
||
// 月度视图中按年份分组的计算属性
|
||
const groupMonthsByYear = computed(() => {
|
||
if (currentTimeScale.value !== TimelineScale.MONTH) {
|
||
return {}
|
||
}
|
||
|
||
const groups: Record<number, unknown[]> = {}
|
||
const timelineDataArray = timelineData.value as unknown[]
|
||
|
||
// 使用 for...of 循环代替 forEach
|
||
for (const month of timelineDataArray) {
|
||
const monthObj = month as Record<string, unknown>
|
||
const monthYear = monthObj.year as number
|
||
if (!groups[monthYear]) {
|
||
groups[monthYear] = []
|
||
}
|
||
groups[monthYear].push(month)
|
||
}
|
||
|
||
return groups
|
||
})
|
||
|
||
// 优化:预计算周视图中月份1号旗帜的位置,避免3层嵌套循环
|
||
const monthFirstFlags = computed(() => {
|
||
if (currentTimeScale.value !== TimelineScale.WEEK) {
|
||
return []
|
||
}
|
||
|
||
const flags: Array<{ left: number; date: number }> = []
|
||
const timelineDataArray = timelineData.value as any[]
|
||
|
||
for (let monthIndex = 0; monthIndex < timelineDataArray.length; monthIndex++) {
|
||
const month = timelineDataArray[monthIndex]
|
||
if (!month.isWeekView || !month.weeks) continue
|
||
|
||
for (let weekIndex = 0; weekIndex < month.weeks.length; weekIndex++) {
|
||
const week = month.weeks[weekIndex]
|
||
const subDays = week.subDays || []
|
||
|
||
for (let dayIndex = 0; dayIndex < subDays.length; dayIndex++) {
|
||
const subDay = subDays[dayIndex]
|
||
if (subDay.date && subDay.date.getDate() === 1) {
|
||
flags.push({
|
||
left: getGlobalWeekPosition(monthIndex, weekIndex) + dayIndex * (60 / 7),
|
||
date: subDay.date.getDate(),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return flags
|
||
})
|
||
|
||
// 优化:预计算周视图中月份1号竖直线的位置
|
||
const monthFirstVerticalLines = computed(() => {
|
||
if (currentTimeScale.value !== TimelineScale.WEEK) {
|
||
return []
|
||
}
|
||
|
||
const lines: Array<{ left: number }> = []
|
||
const timelineDataArray = timelineData.value as any[]
|
||
|
||
for (let monthIndex = 0; monthIndex < timelineDataArray.length; monthIndex++) {
|
||
const month = timelineDataArray[monthIndex]
|
||
if (!month.isWeekView || !month.weeks) continue
|
||
|
||
for (let weekIndex = 0; weekIndex < month.weeks.length; weekIndex++) {
|
||
const week = month.weeks[weekIndex]
|
||
const subDays = week.subDays || []
|
||
|
||
for (let dayIndex = 0; dayIndex < subDays.length; dayIndex++) {
|
||
const subDay = subDays[dayIndex]
|
||
if (subDay.date && subDay.date.getDate() === 1) {
|
||
lines.push({
|
||
left: getGlobalWeekPosition(monthIndex, weekIndex) + dayIndex * (60 / 7),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return lines
|
||
})
|
||
|
||
// 年度视图时间轴数据的计算属性
|
||
const yearTimelineData = computed(() => {
|
||
if (currentTimeScale.value !== TimelineScale.YEAR) {
|
||
return []
|
||
}
|
||
|
||
try {
|
||
const data = generateYearTimelineData() as any
|
||
return Array.isArray(data) ? data : []
|
||
} catch (error) {
|
||
// 发生错误时返回空数组
|
||
return []
|
||
}
|
||
})
|
||
|
||
// 暴露公共API
|
||
defineExpose({
|
||
// 基础滚动功能
|
||
scrollToTasks,
|
||
scrollToToday,
|
||
scrollToTodayCenter,
|
||
scrollToDate,
|
||
// 时间线配置
|
||
timelineConfig,
|
||
// 时间刻度更新
|
||
updateTimeScale,
|
||
// 高亮相关
|
||
highlightedTaskId,
|
||
clearHighlight,
|
||
})
|
||
|
||
// 处理开始计时事件
|
||
const handleStartTimer = (task: Task) => {
|
||
emit('start-timer', task)
|
||
}
|
||
// 处理停止计时事件
|
||
const handleStopTimer = (task: Task) => {
|
||
emit('stop-timer', task)
|
||
}
|
||
|
||
// Task类型转换成Milestone类型, 需要返回一个Milestone对象
|
||
const convertTaskToMilestone = (task: Task): Milestone => {
|
||
// 保证 startDate 一定为 string,避免 undefined
|
||
const startDate = task.startDate || task.endDate || ''
|
||
return {
|
||
...task,
|
||
type: 'milestone',
|
||
startDate,
|
||
endDate: task.startDate || task.endDate,
|
||
}
|
||
}
|
||
|
||
// 监听tasks变化,重新计算里程碑位置(使用 shallow watch 避免深度监听)
|
||
watch(
|
||
() => tasks.value.length,
|
||
() => {
|
||
invalidateTaskDateRangeCache()
|
||
computeAllMilestonesPositions()
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// 优化:统一的防抖函数,避免重复的 setTimeout 造成性能浪费
|
||
let timelineUpdateTimer: number | null = null
|
||
|
||
const debouncedUpdateTimelineRange = (delay = 50) => {
|
||
if (timelineUpdateTimer) clearTimeout(timelineUpdateTimer)
|
||
timelineUpdateTimer = setTimeout(() => {
|
||
updateTimelineRange()
|
||
timelineUpdateTimer = null
|
||
}, delay)
|
||
}
|
||
|
||
// 监听tasks数据变化和容器宽度变化,合并处理以减少重复计算
|
||
const updateTimelineRange = () => {
|
||
let newRange: { startDate: Date; endDate: Date } | null = null
|
||
|
||
if (currentTimeScale.value === TimelineScale.HOUR) {
|
||
newRange = getHourTimelineRange()
|
||
} else if (currentTimeScale.value === TimelineScale.DAY) {
|
||
newRange = getDayTimelineRange()
|
||
} else if (currentTimeScale.value === TimelineScale.WEEK) {
|
||
newRange = getWeekTimelineRange()
|
||
} else if (currentTimeScale.value === TimelineScale.MONTH) {
|
||
newRange = getMonthTimelineRange()
|
||
} else if (
|
||
currentTimeScale.value === TimelineScale.QUARTER ||
|
||
currentTimeScale.value === TimelineScale.YEAR
|
||
) {
|
||
newRange = getYearTimelineRange()
|
||
}
|
||
|
||
if (newRange) {
|
||
clearTimelineCache()
|
||
isUpdatingTimelineConfig = true
|
||
timelineConfig.value.startDate = newRange.startDate
|
||
timelineConfig.value.endDate = newRange.endDate
|
||
isUpdatingTimelineConfig = false
|
||
timelineData.value = generateTimelineData()
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => tasks.value?.length,
|
||
(newLength, oldLength) => {
|
||
if (newLength !== oldLength) {
|
||
invalidateTaskDateRangeCache()
|
||
}
|
||
// 当任务从无到有时,重新计算时间范围
|
||
if (oldLength === 0 && newLength > 0) {
|
||
debouncedUpdateTimelineRange(50)
|
||
}
|
||
},
|
||
)
|
||
|
||
watch(
|
||
timelineContainerWidth,
|
||
(newWidth, oldWidth) => {
|
||
// 拖拽 splitter 时跳过重新计算
|
||
if (isSplitterDragging.value) return
|
||
|
||
// 只在容器宽度从 0 变为有效值,或容器宽度发生显著变化时重新计算
|
||
if (!oldWidth || oldWidth === 0 || Math.abs(newWidth - oldWidth) > 50) {
|
||
if (newWidth > 0) {
|
||
debouncedUpdateTimelineRange(100)
|
||
}
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// 监听timelineData或容器宽度变化,强制TaskBar重新渲染(优化:使用防抖)
|
||
let taskBarRenderTimer: number | null = null
|
||
watch([timelineData, timelineContainerWidth], () => {
|
||
// 拖拽 splitter 时跳过 TaskBar 重新渲染
|
||
if (isSplitterDragging.value) return
|
||
|
||
// 使用防抖避免频繁重新渲染
|
||
if (taskBarRenderTimer) clearTimeout(taskBarRenderTimer)
|
||
taskBarRenderTimer = setTimeout(() => {
|
||
// 清空位置信息
|
||
taskBarPositions.value = {}
|
||
// 更新渲染key强制TaskBar重新渲染
|
||
taskBarRenderKey.value++
|
||
// 延迟更新SVG尺寸
|
||
nextTick(() => {
|
||
setTimeout(() => {
|
||
updateSvgSize()
|
||
}, 200)
|
||
})
|
||
taskBarRenderTimer = null
|
||
}, 100)
|
||
})
|
||
|
||
// 监听viewMode和dataSource变化,刷新缓存和时间线
|
||
watch(
|
||
[viewMode, dataSource],
|
||
() => {
|
||
invalidateTaskDateRangeCache()
|
||
debouncedUpdateTimelineRange()
|
||
},
|
||
{ deep: true },
|
||
)
|
||
|
||
// 监听tasks变化,清理不再存在的任务的位置信息
|
||
watch(
|
||
() => tasks.value,
|
||
newTasks => {
|
||
invalidateTaskDateRangeCache()
|
||
// 优化:使用 for 循环直接构建 Set,避免 map 创建临时数组
|
||
const currentTaskIds = new Set<number>()
|
||
for (const task of newTasks) {
|
||
currentTaskIds.add(task.id)
|
||
}
|
||
|
||
// 优化:使用 for...in 循环代替 Object.keys().forEach()
|
||
// 注意:shallowRef 需要创建新对象来触发响应式
|
||
const newPositions: Record<number, any> = {}
|
||
for (const taskIdStr in taskBarPositions.value) {
|
||
const taskId = parseInt(taskIdStr)
|
||
if (currentTaskIds.has(taskId)) {
|
||
newPositions[taskId] = taskBarPositions.value[taskId]
|
||
}
|
||
}
|
||
taskBarPositions.value = newPositions
|
||
},
|
||
{ deep: true },
|
||
)
|
||
|
||
// 处理里程碑点击定位事件
|
||
const handleMilestoneClickLocate = (event: CustomEvent) => {
|
||
const { scrollLeft, smooth } = event.detail
|
||
|
||
// 获取Timeline容器 - 尝试两个可能的滚动容器
|
||
const timelineMain = document.querySelector('.timeline') as HTMLElement
|
||
const timelineBody = document.querySelector('.timeline-body') as HTMLElement
|
||
|
||
// 选择有滚动能力的容器
|
||
let scrollContainer: HTMLElement | null = null
|
||
if (timelineMain && timelineMain.scrollWidth > timelineMain.clientWidth) {
|
||
scrollContainer = timelineMain
|
||
} else if (timelineBody && timelineBody.scrollWidth > timelineBody.clientWidth) {
|
||
scrollContainer = timelineBody
|
||
}
|
||
|
||
if (scrollContainer) {
|
||
// 确保滚动位置在有效范围内
|
||
const maxScrollLeft = scrollContainer.scrollWidth - scrollContainer.clientWidth
|
||
const targetScrollLeft = Math.min(Math.max(0, scrollLeft), maxScrollLeft)
|
||
|
||
if (smooth) {
|
||
// 平滑滚动
|
||
scrollContainer.scrollTo({
|
||
left: targetScrollLeft,
|
||
behavior: 'smooth',
|
||
})
|
||
} else {
|
||
// 立即滚动
|
||
scrollContainer.scrollLeft = targetScrollLeft
|
||
}
|
||
}
|
||
}
|
||
|
||
// 生成月度视图时间轴数据
|
||
const generateMonthTimelineData = () => {
|
||
// 根据时间刻度动态调整时间范围
|
||
let startDate: Date, endDate: Date
|
||
|
||
if (currentTimeScale.value === TimelineScale.MONTH) {
|
||
// 月度视图使用任务范围+2年
|
||
const monthRange = getMonthTimelineRange()
|
||
startDate = monthRange.startDate
|
||
endDate = monthRange.endDate
|
||
} else {
|
||
// 使用当前配置的范围
|
||
startDate = new Date(timelineConfig.value.startDate)
|
||
endDate = new Date(timelineConfig.value.endDate)
|
||
}
|
||
|
||
const years: Record<
|
||
number,
|
||
{
|
||
year: number
|
||
yearLabel: string
|
||
months: {
|
||
year: number
|
||
month: number
|
||
monthLabel: string
|
||
startDate: Date
|
||
endDate: Date
|
||
isToday: boolean
|
||
dayCount: number
|
||
}[]
|
||
}
|
||
> = {}
|
||
|
||
const currentDate = new Date(startDate)
|
||
currentDate.setDate(1) // 从月初开始
|
||
|
||
while (currentDate <= endDate) {
|
||
const year = currentDate.getFullYear()
|
||
const month = currentDate.getMonth() + 1
|
||
|
||
// 获取当月天数
|
||
const monthEndDate = new Date(year, month, 0)
|
||
const dayCount = monthEndDate.getDate()
|
||
|
||
// 检查是否包含今天
|
||
const today = new Date()
|
||
const isToday = year === today.getFullYear() && month === today.getMonth() + 1
|
||
|
||
if (!years[year]) {
|
||
years[year] = {
|
||
year,
|
||
yearLabel: `${year}年`,
|
||
months: [],
|
||
}
|
||
}
|
||
|
||
years[year].months.push({
|
||
year,
|
||
month,
|
||
monthLabel: formatMonth(month),
|
||
startDate: new Date(year, month - 1, 1),
|
||
endDate: new Date(year, month, 0),
|
||
isToday,
|
||
dayCount,
|
||
})
|
||
|
||
// 移动到下一个月
|
||
currentDate.setMonth(currentDate.getMonth() + 1)
|
||
}
|
||
|
||
// 转换为数组格式,保持与日/周视图兼容的结构
|
||
const result = []
|
||
|
||
let cumulativePosition = 0 // 用于跟踪累积位置
|
||
|
||
for (const yearData of Object.values(years)) {
|
||
for (const monthData of yearData.months) {
|
||
// 为月度视图生成每一天的subDays数组
|
||
const subDays = []
|
||
for (let day = 1; day <= monthData.dayCount; day++) {
|
||
const date = new Date(monthData.year, monthData.month - 1, day)
|
||
subDays.push({
|
||
day,
|
||
date: new Date(date),
|
||
dayOfWeek: date.getDay(),
|
||
isToday: isToday(date),
|
||
isWeekend: date.getDay() === 0 || date.getDay() === 6,
|
||
})
|
||
}
|
||
|
||
const monthWidth = 60 // 每个月的宽度
|
||
const monthStartPosition = cumulativePosition
|
||
const monthEndPosition = cumulativePosition + monthWidth
|
||
|
||
result.push({
|
||
year: monthData.year,
|
||
month: monthData.month,
|
||
yearMonthLabel: formatYearMonth(monthData.year, monthData.month),
|
||
startDate: monthData.startDate,
|
||
endDate: monthData.endDate,
|
||
isMonthView: true,
|
||
monthData: {
|
||
monthLabel: monthData.monthLabel,
|
||
isToday: monthData.isToday,
|
||
dayCount: monthData.dayCount,
|
||
},
|
||
// 添加每一天的子列数据
|
||
subDays,
|
||
// 为了兼容性,保留days数组,映射subDays的数据
|
||
days: subDays.map(subDay => ({
|
||
day: subDay.day,
|
||
date: subDay.date,
|
||
label: String(subDay.day).padStart(2, '0'),
|
||
isToday: subDay.isToday,
|
||
isWeekend: subDay.isWeekend,
|
||
})),
|
||
// 添加位置调试信息
|
||
_debugInfo: {
|
||
monthStartPosition,
|
||
monthEndPosition,
|
||
monthWidth,
|
||
},
|
||
})
|
||
|
||
cumulativePosition += monthWidth
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// 生成季度视图时间轴数据
|
||
const generateQuarterTimelineData = () => {
|
||
// 使用从 GanttChart 传入的日期范围(已包含正确的 buffer 和容器填充逻辑)
|
||
const startDate = timelineConfig.value.startDate
|
||
const endDate = timelineConfig.value.endDate
|
||
|
||
const years: unknown[] = []
|
||
|
||
// 确保从 startDate 所在年份的年初开始
|
||
const startYear = startDate.getFullYear()
|
||
const endYear = endDate.getFullYear()
|
||
|
||
for (let year = startYear; year <= endYear; year++) {
|
||
// 生成该年的4个季度
|
||
const quarters = []
|
||
for (let quarter = 1; quarter <= 4; quarter++) {
|
||
const quarterStartMonth = (quarter - 1) * 3 // Q1: 0, Q2: 3, Q3: 6, Q4: 9
|
||
const quarterStartDate = new Date(year, quarterStartMonth, 1)
|
||
// 修正:每季度结束日期应该是本季度最后一个月的最后一天
|
||
const quarterEndMonth = quarterStartMonth + 2 // Q1: 2(3月), Q2: 5(6月), Q3: 8(9月), Q4: 11(12月)
|
||
const quarterEndDate = new Date(year, quarterEndMonth + 1, 0) // 下一个月的第0天 = 本月最后一天
|
||
|
||
quarters.push({
|
||
quarter,
|
||
label: `Q${quarter}`,
|
||
fullLabel: `${year}年第${quarter}季度`,
|
||
startDate: quarterStartDate,
|
||
endDate: quarterEndDate,
|
||
isToday: isQuarterContainsToday(quarterStartDate, quarterEndDate),
|
||
year,
|
||
})
|
||
}
|
||
|
||
years.push({
|
||
year,
|
||
yearLabel: String(year),
|
||
startDate: new Date(year, 0, 1),
|
||
endDate: new Date(year, 11, 31),
|
||
quarters,
|
||
})
|
||
}
|
||
|
||
return years
|
||
}
|
||
|
||
// 判断季度是否包含今天
|
||
const isQuarterContainsToday = (startDate: Date, endDate: Date) => {
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
return today >= startDate && today <= endDate
|
||
}
|
||
|
||
// 生成年度视图时间轴数据
|
||
const generateYearTimelineData = () => {
|
||
// 使用从 GanttChart 传入的日期范围(已包含正确的 buffer 和容器填充逻辑)
|
||
const startDate = timelineConfig.value.startDate
|
||
const endDate = timelineConfig.value.endDate
|
||
|
||
const years: unknown[] = []
|
||
|
||
// 确保从 startDate 所在年份的年初开始到 endDate 所在年份的年末
|
||
const startYear = startDate.getFullYear()
|
||
const endYear = endDate.getFullYear()
|
||
|
||
for (let year = startYear; year <= endYear; year++) {
|
||
// 生成上半年和下半年
|
||
const halfYears = [
|
||
{
|
||
label: t('halfYearFirst'),
|
||
startDate: new Date(year, 0, 1),
|
||
endDate: new Date(year, 6, 0), // 6月的最后一天
|
||
width: 180,
|
||
},
|
||
{
|
||
label: t('halfYearSecond'),
|
||
startDate: new Date(year, 6, 1),
|
||
endDate: new Date(year, 11, 31),
|
||
width: 180,
|
||
},
|
||
]
|
||
|
||
years.push({
|
||
year,
|
||
startDate: new Date(year, 0, 1),
|
||
endDate: new Date(year, 11, 31),
|
||
halfYears,
|
||
width: 360,
|
||
})
|
||
}
|
||
|
||
return years
|
||
}
|
||
|
||
// 添加前置任务事件
|
||
const handleAddPredecessor = (task: Task) => {
|
||
emit('add-predecessor', task)
|
||
}
|
||
|
||
// 添加后置任务事件
|
||
const handleAddSuccessor = (task: Task) => {
|
||
emit('add-successor', task)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
ref="timelineContainer"
|
||
class="timeline"
|
||
@mousedown="handleMouseDown"
|
||
@scroll="handleTimelineScroll"
|
||
>
|
||
<!-- Timeline Header -->
|
||
<div class="timeline-header">
|
||
<!-- 年度视图的header:第一行=年份,第二行=上半年/下半年 -->
|
||
<template
|
||
v-if="
|
||
currentTimeScale === TimelineScale.YEAR && yearTimelineData && yearTimelineData.length > 0
|
||
"
|
||
>
|
||
<!-- 第一行:年份 -->
|
||
<div class="timeline-header-row year-row">
|
||
<div
|
||
v-for="yearData in yearTimelineData"
|
||
:key="`year-${(yearData as any).year}`"
|
||
class="timeline-year"
|
||
:style="{ width: '360px' }"
|
||
>
|
||
<div class="year-label">{{ (yearData as any).year }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 第二行:上半年/下半年 -->
|
||
<div class="timeline-header-row half-years-row">
|
||
<template
|
||
v-for="yearData in yearTimelineData"
|
||
:key="`halfyear-${(yearData as any).year}`"
|
||
>
|
||
<div
|
||
v-for="halfYear in (yearData as any).halfYears || []"
|
||
:key="`halfyear-${(yearData as any).year}-${halfYear.label}`"
|
||
class="timeline-half-year-item"
|
||
:style="{ width: '180px' }"
|
||
>
|
||
<div class="half-year-label">{{ halfYear.label }}</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 月度视图的header:第一行=年份,第二行=月份 -->
|
||
<template v-else-if="currentTimeScale === TimelineScale.MONTH">
|
||
<!-- 第一行:年份 -->
|
||
<div class="timeline-header-row year-row">
|
||
<div
|
||
v-for="(_, yearValue) in groupMonthsByYear"
|
||
:key="`year-${yearValue}`"
|
||
class="timeline-year"
|
||
:style="{ width: '720px' }"
|
||
>
|
||
<div class="year-label">{{ yearValue }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 第二行:月份 -->
|
||
<div class="timeline-header-row months-row">
|
||
<div
|
||
v-for="month in timelineData"
|
||
:key="`month-${month.year}-${month.month}`"
|
||
class="timeline-month-item"
|
||
:class="{ today: month.monthData?.isToday }"
|
||
:style="{ width: '59px' }"
|
||
>
|
||
<div class="month-label">{{ month.monthData?.monthLabel }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 季度视图的header:第一行=年份,第二行=季度 -->
|
||
<template v-else-if="currentTimeScale === TimelineScale.QUARTER">
|
||
<!-- 第一行:年份 -->
|
||
<div class="timeline-header-row year-row">
|
||
<div
|
||
v-for="year in timelineData"
|
||
:key="`year-${year.year}`"
|
||
class="timeline-year"
|
||
:style="{ width: '240px' }"
|
||
>
|
||
<div class="year-label">{{ year.yearLabel }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 第二行:季度 -->
|
||
<div class="timeline-header-row quarters-row">
|
||
<template v-for="year in timelineData" :key="`quarters-${year.year}`">
|
||
<div
|
||
v-for="quarter in year.quarters"
|
||
:key="`quarter-${year.year}-${quarter.quarter}`"
|
||
class="timeline-quarter-item"
|
||
:class="{ today: quarter.isToday }"
|
||
:style="{ width: '60px' }"
|
||
>
|
||
<div class="quarter-label">{{ quarter.label }}</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 小时视图的header:第一行=yyyy/MM/dd,第二行=00:00-23:00 -->
|
||
<template v-else-if="currentTimeScale === TimelineScale.HOUR">
|
||
<!-- 设置header容器总宽度以确保完整的滚动范围 -->
|
||
<div class="hour-header-container" :style="{ width: `${totalTimelineWidth}px` }">
|
||
<!-- 第一行:日期 (yyyy/MM/dd) -->
|
||
<div class="timeline-header-row date-row">
|
||
<div
|
||
v-for="day in optimizedTimelineData"
|
||
:key="`date-${day.year}-${day.month}-${day.day}`"
|
||
class="timeline-day-item"
|
||
:style="{
|
||
position: 'absolute',
|
||
width: `${day.hours.length * 40}px`,
|
||
left: `${(day.hourOffset || 0) * 40}px`,
|
||
}"
|
||
>
|
||
<div class="date-label">{{ day.dateLabel }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 第二行:小时 (00:00-23:00) -->
|
||
<div class="timeline-header-row hours-row">
|
||
<template
|
||
v-for="day in optimizedTimelineData"
|
||
:key="`hours-${day.year}-${day.month}-${day.day}`"
|
||
>
|
||
<div
|
||
v-for="(hour, index) in day.hours"
|
||
:key="`hour-${day.year}-${day.month}-${day.day}-${hour.hour}`"
|
||
class="timeline-hour-item"
|
||
:class="{
|
||
today: hour.isToday,
|
||
'non-working-hour': !hour.isWorkingHour,
|
||
}"
|
||
:style="{
|
||
position: 'absolute',
|
||
width: '40px',
|
||
left: `${(day.hourOffset + index) * 40}px`,
|
||
}"
|
||
>
|
||
<div class="hour-label">{{ hour.shortLabel }}</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 日视图和周视图的header(保持原有逻辑) -->
|
||
<template v-else>
|
||
<!-- 第一行:年月 -->
|
||
<div class="timeline-header-row year-month-row">
|
||
<div
|
||
v-for="month in timelineData"
|
||
:key="`year-month-${month.year}-${month.month}`"
|
||
class="timeline-month"
|
||
:style="{
|
||
width: month.isWeekView
|
||
? `${(month.weeks || []).length * 60}px`
|
||
: `${(month.days || []).length * 30}px`,
|
||
}"
|
||
>
|
||
<div class="year-month-label">{{ month.yearMonthLabel }}</div>
|
||
</div>
|
||
|
||
<!-- 月份1号标记旗帜 - 优化:使用预计算的位置数组,避免3层嵌套循环 -->
|
||
<div
|
||
v-for="(flag, index) in monthFirstFlags"
|
||
:key="`flag-${index}`"
|
||
class="month-first-flag"
|
||
:style="{
|
||
left: `${flag.left}px`,
|
||
transform: 'translateX(-50%)',
|
||
}"
|
||
>
|
||
<div class="flag-pole"></div>
|
||
<div class="flag-content">{{ flag.date }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 第二行:周/日期 -->
|
||
<div class="timeline-header-row days-row">
|
||
<!-- 周视图和日视图渲染 -->
|
||
<template v-for="month in timelineData" :key="`timeline-${month.year}-${month.month}`">
|
||
<!-- 周视图 -->
|
||
<div
|
||
v-if="month.isWeekView && month.weeks"
|
||
class="timeline-month-weeks"
|
||
:style="{ width: `${(month.weeks || []).length * 60}px` }"
|
||
>
|
||
<div
|
||
v-for="week in month.weeks || []"
|
||
:key="`week-${month.year}-${month.month}-${week.label}`"
|
||
class="timeline-week"
|
||
:class="{
|
||
today: week.isToday,
|
||
}"
|
||
>
|
||
<div class="week-label">{{ week.label }}</div>
|
||
<!-- 优化:移除7个子div,使用CSS grid替代,大幅减少DOM节点 -->
|
||
<div class="week-sub-days"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 日视图 -->
|
||
<div
|
||
v-else
|
||
class="timeline-month-days"
|
||
:style="{ width: `${month.days.length * 30}px` }"
|
||
>
|
||
<div
|
||
v-for="day in month.days"
|
||
:key="`day-${month.year}-${month.month}-${day.day}`"
|
||
class="timeline-day"
|
||
:class="{
|
||
today: day.isToday,
|
||
weekend: day.isWeekend && !day.isToday,
|
||
}"
|
||
>
|
||
<div class="day-label">{{ day.label }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Timeline Body (Task Bar Area) -->
|
||
<div class="timeline-body" @scroll="handleTimelineBodyScroll">
|
||
<div ref="bodyContentRef" class="timeline-body-content">
|
||
<!-- 关系线组件(Canvas 渲染,支持虚拟渲染)- 仅在任务视图渲染 -->
|
||
<GanttLinks
|
||
v-if="viewMode === 'task'"
|
||
:tasks="tasks"
|
||
:task-bar-positions="taskBarPositions"
|
||
:width="canvasWidth"
|
||
:height="canvasHeight"
|
||
:offset-left="canvasOffsetLeft"
|
||
:offset-top="canvasOffsetTop"
|
||
:highlighted-task-id="highlightedTaskId"
|
||
:highlighted-task-ids="highlightedTaskIds"
|
||
:hovered-task-id="hoveredTaskId"
|
||
:vertical-lines="monthFirstVerticalLines"
|
||
:show-vertical-lines="currentTimeScale === TimelineScale.WEEK"
|
||
/>
|
||
|
||
<!-- 连接线拖拽引导线 - 🚀 优化:使用命令式 API,由 RAF 直接调用 draw() -->
|
||
<LinkDragGuide
|
||
ref="linkDragGuideRef"
|
||
:active="!!dragLinkMode && !!linkDragSourceTask"
|
||
:width="canvasWidth"
|
||
:height="canvasHeight"
|
||
:offset-left="canvasOffsetLeft"
|
||
:offset-top="canvasOffsetTop"
|
||
/>
|
||
|
||
<!-- 年度视图今日标记线 -->
|
||
<div
|
||
v-if="isTodayVisibleInYearView && getTodayLinePositionInYearView >= 0"
|
||
class="today-line-year-view"
|
||
:style="{
|
||
left: `${getTodayLinePositionInYearView}px`,
|
||
height: `${contentHeight}px`,
|
||
}"
|
||
></div>
|
||
|
||
<!-- 背景列 -->
|
||
<div class="day-columns" :style="{ height: `${contentHeight}px` }">
|
||
<!-- 小时视图背景列 -->
|
||
<template v-if="currentTimeScale === TimelineScale.HOUR">
|
||
<!-- 设置容器总宽度以确保完整的滚动范围 -->
|
||
<div
|
||
class="hour-columns-container"
|
||
:style="{
|
||
width: `${totalTimelineWidth}px`,
|
||
height: `${contentHeight}px`,
|
||
position: 'relative',
|
||
}"
|
||
>
|
||
<template
|
||
v-for="day in optimizedTimelineData"
|
||
:key="`day-col-${day.year}-${day.month}-${day.day}`"
|
||
>
|
||
<div
|
||
v-for="(hour, index) in day.hours"
|
||
:key="`hour-col-${day.year}-${day.month}-${day.day}-${hour.hour}`"
|
||
class="hour-column"
|
||
:class="{
|
||
today: hour.isToday,
|
||
weekend: hour.isWeekend,
|
||
'working-hour': hour.isWorkingHour,
|
||
'rest-hour': !hour.isWorkingHour && !hour.isWeekend,
|
||
}"
|
||
:style="{
|
||
position: 'absolute',
|
||
width: '40px',
|
||
height: `${contentHeight}px`,
|
||
left: `${(day.hourOffset + index) * 40}px`,
|
||
}"
|
||
>
|
||
<!-- 15分钟刻度分割线 -->
|
||
<div class="quarter-hour-lines">
|
||
<div class="quarter-line" style="left: 10px"></div>
|
||
<div class="quarter-line" style="left: 20px"></div>
|
||
<div class="quarter-line" style="left: 30px"></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 年度视图背景列 -->
|
||
<template v-if="currentTimeScale === TimelineScale.YEAR">
|
||
<template
|
||
v-for="yearData in yearTimelineData"
|
||
:key="`year-col-${(yearData as any).year}`"
|
||
>
|
||
<div
|
||
v-for="halfYear in (yearData as any).halfYears || []"
|
||
:key="`halfyear-col-${(yearData as any).year}-${(halfYear as any).label}`"
|
||
class="half-year-column"
|
||
:style="{ width: '180px', height: `${contentHeight}px` }"
|
||
></div>
|
||
</template>
|
||
</template>
|
||
|
||
<!-- 季度视图背景列 -->
|
||
<template v-if="currentTimeScale === TimelineScale.QUARTER">
|
||
<!-- 设置容器总宽度以确保完整的滚动范围 -->
|
||
<div
|
||
class="quarter-columns-container"
|
||
:style="{
|
||
width: `${totalTimelineWidth}px`,
|
||
height: `${contentHeight}px`,
|
||
position: 'relative',
|
||
}"
|
||
>
|
||
<template v-for="(year, yearIndex) in timelineData" :key="`quarter-col-${year.year}`">
|
||
<div
|
||
v-for="(quarter, quarterIndex) in year.quarters"
|
||
:key="`quarter-col-${year.year}-${quarter.quarter}`"
|
||
class="quarter-column"
|
||
:class="{ today: quarter.isToday }"
|
||
:style="{
|
||
position: 'absolute',
|
||
width: '60px',
|
||
height: `${contentHeight}px`,
|
||
left: `${yearIndex * 240 + quarterIndex * 60}px`,
|
||
}"
|
||
></div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 其他视图背景列 -->
|
||
<template
|
||
v-else-if="
|
||
currentTimeScale !== TimelineScale.HOUR &&
|
||
currentTimeScale !== TimelineScale.YEAR &&
|
||
currentTimeScale !== TimelineScale.QUARTER
|
||
"
|
||
>
|
||
<template v-for="month in timelineData" :key="`day-col-${month.year}-${month.month}`">
|
||
<!-- 月度视图背景列 -->
|
||
<div
|
||
v-if="month.isMonthView"
|
||
class="month-column"
|
||
:class="{ today: month.monthData?.isToday }"
|
||
:style="{ width: '59px', height: `${contentHeight}px` }"
|
||
></div>
|
||
|
||
<!-- 周视图背景列 - 优化:移除 subDay 子列,减少 364 个 DOM 节点 -->
|
||
<div
|
||
v-else-if="month.isWeekView && month.weeks"
|
||
class="month-week-columns"
|
||
:style="{
|
||
width: `${(month.weeks || []).length * 60}px`,
|
||
height: `${contentHeight}px`,
|
||
}"
|
||
>
|
||
<div
|
||
v-for="week in month.weeks || []"
|
||
:key="`week-col-${month.year}-${month.month}-${week.label}`"
|
||
class="week-column"
|
||
:class="{
|
||
today: week.isToday,
|
||
}"
|
||
:style="{ height: `${contentHeight}px`, width: '60px' }"
|
||
>
|
||
<!-- 周内的7个子列 -->
|
||
<div
|
||
v-for="(subDay, dayIndex) in week.subDays || []"
|
||
:key="`subday-col-${dayIndex}`"
|
||
class="sub-day-column"
|
||
:class="{
|
||
weekend: subDay.dayOfWeek === 0 || subDay.dayOfWeek === 6,
|
||
today: isToday(subDay.date),
|
||
}"
|
||
:style="{ height: `${contentHeight}px`, width: `${dayWidth}px` }"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 日视图背景列 -->
|
||
<div
|
||
v-else
|
||
class="month-day-columns"
|
||
:style="{
|
||
width: `${(month.days || []).length * 30}px`,
|
||
height: `${contentHeight}px`,
|
||
}"
|
||
>
|
||
<div
|
||
v-for="day in month.days || []"
|
||
:key="`day-col-${month.year}-${month.month}-${day.day}`"
|
||
class="day-column"
|
||
:class="{
|
||
weekend: day.isWeekend,
|
||
today: day.isToday,
|
||
}"
|
||
:style="{ height: `${contentHeight}px` }"
|
||
></div>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Task Bar 组件 -->
|
||
<!-- top按照50px增加是为了保证和左侧TaskList中row的高度保持一致 -->
|
||
<!-- 同时需要考虑左侧TaskList包含1px的bottom border -->
|
||
<div class="task-bar-container" :style="{ height: `${contentHeight}px` }">
|
||
<div class="task-rows" :style="{ height: `${contentHeight}px` }">
|
||
<!-- 任务视图:使用虚拟滚动渲染可见任务 -->
|
||
<div
|
||
v-for="{ task, originalIndex } in visibleTasks"
|
||
v-if="viewMode === 'task'"
|
||
:key="task.id"
|
||
class="task-row"
|
||
:class="{ 'task-row-hovered': hoveredTaskId === task.id }"
|
||
:style="{ top: `${originalIndex * 51}px` }"
|
||
@mouseenter="handleTaskRowHover(task.id)"
|
||
@mouseleave="handleTaskRowHover(null)"
|
||
>
|
||
<!-- 里程碑分组行:显示所有里程碑在同一行的不同时间列中,不渲染父级TaskBar -->
|
||
<template v-if="task.type === 'milestone-group' && task.children">
|
||
<MilestonePoint
|
||
v-for="milestone in task.children"
|
||
:key="milestone.id"
|
||
:date="milestone.startDate || ''"
|
||
:row-height="50"
|
||
:day-width="dayWidth"
|
||
:start-date="
|
||
currentTimeScale === TimelineScale.YEAR
|
||
? getYearTimelineRange().startDate
|
||
: currentTimeScale === TimelineScale.QUARTER
|
||
? getYearTimelineRange().startDate
|
||
: currentTimeScale === TimelineScale.MONTH
|
||
? getMonthTimelineRange().startDate
|
||
: timelineConfig.startDate
|
||
"
|
||
:timeline-start="timelineConfig.startDate"
|
||
:timeline-end="timelineConfig.endDate"
|
||
:period-width="dayWidth"
|
||
:name="milestone.name"
|
||
:milestone="convertTaskToMilestone(milestone)"
|
||
:scroll-left="timelineScrollLeft"
|
||
:container-width="timelineContainerWidth"
|
||
:milestone-id="milestone.id"
|
||
:other-milestones="getOtherMilestonesInfo(milestone.id)"
|
||
:timeline-data="timelineData"
|
||
:current-time-scale="currentTimeScale"
|
||
:allow-drag-and-resize="props.allowDragAndResize && !isInHighlightMode"
|
||
:is-in-highlight-mode="isInHighlightMode"
|
||
@milestone-double-click="handleMilestoneDoubleClick"
|
||
@update:milestone="handleMilestoneUpdate"
|
||
@drag-end="handleMilestoneDragEnd"
|
||
/>
|
||
</template>
|
||
<!-- 独立里程碑 -->
|
||
<template v-else-if="task.type === 'milestone'">
|
||
<MilestonePoint
|
||
:key="task.id"
|
||
:date="task.startDate || ''"
|
||
:row-height="50"
|
||
:day-width="dayWidth"
|
||
:start-date="
|
||
currentTimeScale === TimelineScale.YEAR
|
||
? getYearTimelineRange().startDate
|
||
: currentTimeScale === TimelineScale.QUARTER
|
||
? getYearTimelineRange().startDate
|
||
: currentTimeScale === TimelineScale.MONTH
|
||
? getMonthTimelineRange().startDate
|
||
: timelineConfig.startDate
|
||
"
|
||
:timeline-start="timelineConfig.startDate"
|
||
:timeline-end="timelineConfig.endDate"
|
||
:period-width="dayWidth"
|
||
:name="task.name"
|
||
:milestone="convertTaskToMilestone(task)"
|
||
:scroll-left="timelineScrollLeft"
|
||
:container-width="timelineContainerWidth"
|
||
:milestone-id="task.id"
|
||
:other-milestones="getOtherMilestonesInfo(task.id)"
|
||
:timeline-data="timelineData"
|
||
:current-time-scale="currentTimeScale"
|
||
:allow-drag-and-resize="props.allowDragAndResize && !isInHighlightMode"
|
||
:is-in-highlight-mode="isInHighlightMode"
|
||
@milestone-double-click="handleMilestoneDoubleClick"
|
||
@update:milestone="handleMilestoneUpdate"
|
||
@drag-end="handleMilestoneDragEnd"
|
||
/>
|
||
</template>
|
||
<!-- 普通任务条 - 排除里程碑分组和普通里程碑 -->
|
||
<TaskBar
|
||
v-else-if="task.type !== 'milestone-group' && task.type !== 'milestone'"
|
||
:key="`taskbar-${task.id}-${taskBarRenderKey}`"
|
||
:task="task"
|
||
:row-index="originalIndex"
|
||
:row-height="50"
|
||
:day-width="dayWidth"
|
||
:start-date="
|
||
currentTimeScale === TimelineScale.YEAR
|
||
? getYearTimelineRange().startDate
|
||
: currentTimeScale === TimelineScale.MONTH
|
||
? getMonthTimelineRange().startDate
|
||
: timelineConfig.startDate
|
||
"
|
||
:is-parent="task.isParent"
|
||
:scroll-left="timelineScrollLeft"
|
||
:container-width="timelineContainerWidth"
|
||
:hide-bubbles="hideBubbles"
|
||
:timeline-data="
|
||
currentTimeScale === TimelineScale.HOUR ? optimizedTimelineData : timelineData
|
||
"
|
||
:current-time-scale="currentTimeScale"
|
||
:task-bar-config="props.taskBarConfig"
|
||
:allow-drag-and-resize="props.allowDragAndResize && !isInHighlightMode"
|
||
:show-actual-taskbar="props.showActualTaskbar"
|
||
:enable-task-bar-tooltip="props.enableTaskBarTooltip"
|
||
:pending-task-background-color="props.pendingTaskBackgroundColor"
|
||
:delay-task-background-color="props.delayTaskBackgroundColor"
|
||
:complete-task-background-color="props.completeTaskBackgroundColor"
|
||
:ongoing-task-background-color="props.ongoingTaskBackgroundColor"
|
||
:is-highlighted="highlightedTaskIds.has(task.id)"
|
||
:is-primary-highlight="highlightedTaskId === task.id"
|
||
:is-in-highlight-mode="isInHighlightMode"
|
||
:drag-link-mode="dragLinkMode"
|
||
:is-link-drag-source="linkDragSourceTask?.id === task.id"
|
||
:is-valid-link-target="
|
||
linkDragTargetTask?.id === task.id && isValidLinkTarget === true
|
||
"
|
||
:is-invalid-link-target="
|
||
linkDragTargetTask?.id === task.id && isValidLinkTarget === false
|
||
"
|
||
:all-tasks="tasks"
|
||
@update:task="updateTask"
|
||
@bar-mounted="handleBarMounted"
|
||
@click="handleTaskBarClick(task, $event)"
|
||
@dblclick="handleTaskBarDoubleClick(task)"
|
||
@drag-end="handleTaskBarDragEnd"
|
||
@resize-end="handleTaskBarResizeEnd"
|
||
@scroll-to-position="handleScrollToPosition"
|
||
@context-menu="handleTaskBarContextMenu"
|
||
@start-timer="handleStartTimer"
|
||
@stop-timer="handleStopTimer"
|
||
@add-predecessor="handleAddPredecessor"
|
||
@add-successor="handleAddSuccessor"
|
||
@delete="handleTaskDelete"
|
||
@delete-link="handleDeleteLink"
|
||
@long-press="setHighlightTask"
|
||
@link-drag-start="handleLinkDragStart"
|
||
@link-drag-move="handleLinkDragMove"
|
||
@link-drag-end="handleLinkDragEnd"
|
||
>
|
||
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
|
||
<slot name="custom-task-content" v-bind="barScope" />
|
||
</template>
|
||
<template
|
||
v-if="$slots['task-bar-context-menu']"
|
||
#task-bar-context-menu="contextMenuScope"
|
||
>
|
||
<slot name="task-bar-context-menu" v-bind="contextMenuScope" />
|
||
</template>
|
||
</TaskBar>
|
||
</div>
|
||
|
||
<!-- 资源视图:一行渲染多个 TaskBar -->
|
||
<div
|
||
v-for="{ resource, originalIndex } in visibleResourcesWithFilteredTasks"
|
||
v-else-if="viewMode === 'resource'"
|
||
:key="resource.id"
|
||
class="task-row resource-row"
|
||
:class="{ 'task-row-hovered': hoveredTaskId === resource.id }"
|
||
:style="{
|
||
top: `${resourceRowPositions?.get(resource.id) || 0}px`,
|
||
height: `${resourceTaskLayouts?.get(resource.id)?.totalHeight || 51}px`
|
||
}"
|
||
@mouseenter="handleTaskRowHover(resource.id)"
|
||
@mouseleave="handleTaskRowHover(null)"
|
||
>
|
||
<!-- 为资源下的每个任务渲染 TaskBar -->
|
||
<template v-if="(resource as any).tasks && (resource as any).tasks.length > 0">
|
||
<TaskBar
|
||
v-for="(task) in (resource as any).tasks"
|
||
:key="`taskbar-${task.id}-${taskBarRenderKey}`"
|
||
:task="task"
|
||
:row-index="originalIndex"
|
||
:row-height="51"
|
||
:task-sub-row="resourceTaskLayouts?.get(resource.id)?.taskRowMap.get(task.id) || 0"
|
||
:row-heights="resourceTaskLayouts?.get(resource.id)?.rowHeights || [51]"
|
||
:day-width="dayWidth"
|
||
:start-date="
|
||
currentTimeScale === TimelineScale.YEAR
|
||
? getYearTimelineRange().startDate
|
||
: currentTimeScale === TimelineScale.MONTH
|
||
? getMonthTimelineRange().startDate
|
||
: timelineConfig.startDate
|
||
"
|
||
:scroll-left="timelineScrollLeft"
|
||
:container-width="timelineContainerWidth"
|
||
:hide-bubbles="hideBubbles"
|
||
:timeline-data="
|
||
currentTimeScale === TimelineScale.HOUR ? optimizedTimelineData : timelineData
|
||
"
|
||
:current-time-scale="currentTimeScale"
|
||
:task-bar-config="props.taskBarConfig"
|
||
:allow-drag-and-resize="props.allowDragAndResize && !isInHighlightMode"
|
||
:show-actual-taskbar="props.showActualTaskbar"
|
||
:enable-task-bar-tooltip="props.enableTaskBarTooltip"
|
||
:pending-task-background-color="props.pendingTaskBackgroundColor"
|
||
:delay-task-background-color="props.delayTaskBackgroundColor"
|
||
:complete-task-background-color="props.completeTaskBackgroundColor"
|
||
:ongoing-task-background-color="props.ongoingTaskBackgroundColor"
|
||
:is-highlighted="highlightedTaskIds.has(task.id)"
|
||
:is-primary-highlight="highlightedTaskId === task.id"
|
||
:is-in-highlight-mode="isInHighlightMode"
|
||
:drag-link-mode="dragLinkMode"
|
||
:is-link-drag-source="linkDragSourceTask?.id === task.id"
|
||
:is-valid-link-target="
|
||
linkDragTargetTask?.id === task.id && isValidLinkTarget === true
|
||
"
|
||
:is-invalid-link-target="
|
||
linkDragTargetTask?.id === task.id && isValidLinkTarget === false
|
||
"
|
||
:all-tasks="tasks"
|
||
:has-resource-conflict="resourceConflicts.get(String(resource.id))?.has(task.id) || false"
|
||
:conflict-tasks="getConflictTasksForTask(resource.id, task.id)"
|
||
:current-resource-id="resource.id"
|
||
:resources="dataSource"
|
||
@update:task="updateTask"
|
||
@bar-mounted="handleBarMounted"
|
||
@click="handleTaskBarClick(task, $event)"
|
||
@dblclick="handleTaskBarDoubleClick(task)"
|
||
@drag-end="handleTaskBarDragEnd"
|
||
@resize-end="handleTaskBarResizeEnd"
|
||
@scroll-to-position="handleScrollToPosition"
|
||
@context-menu="handleTaskBarContextMenu"
|
||
@start-timer="handleStartTimer"
|
||
@stop-timer="handleStopTimer"
|
||
@add-predecessor="handleAddPredecessor"
|
||
@add-successor="handleAddSuccessor"
|
||
@delete="handleTaskDelete"
|
||
@delete-link="handleDeleteLink"
|
||
@long-press="setHighlightTask"
|
||
@link-drag-start="handleLinkDragStart"
|
||
@link-drag-move="handleLinkDragMove"
|
||
@link-drag-end="handleLinkDragEnd"
|
||
>
|
||
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
|
||
<slot name="custom-task-content" v-bind="barScope" />
|
||
</template>
|
||
<template
|
||
v-if="$slots['task-bar-context-menu']"
|
||
#task-bar-context-menu="contextMenuScope"
|
||
>
|
||
<slot name="task-bar-context-menu" v-bind="contextMenuScope" />
|
||
</template>
|
||
</TaskBar>
|
||
|
||
<!-- v1.9.2 资源冲突可视化层 -->
|
||
<!-- v1.9.5 P2-1修复:使用原始完整的任务列表进行冲突检测 -->
|
||
<!-- v1.9.5 修复:传递任务行号信息,正确计算冲突区域高度 -->
|
||
<!-- v1.9.5 可通过 show-conflicts prop 控制是否显示 -->
|
||
<!-- v1.9.6 修复:width使用totalTimelineWidth(用于坐标计算),containerWidth用于Canvas宽度 -->
|
||
<!-- v1.9.7 Bug修复:使用渲染的tasks而不是allTasks,避免滚动后显示已消失TaskBar的冲突 -->
|
||
<GanttConflicts
|
||
v-if="showConflicts"
|
||
:tasks="(resource as any).tasks"
|
||
:resource-id="resource.id"
|
||
:day-width="dayWidth"
|
||
:start-date="
|
||
currentTimeScale === TimelineScale.YEAR
|
||
? getYearTimelineRange().startDate
|
||
: currentTimeScale === TimelineScale.MONTH
|
||
? getMonthTimelineRange().startDate
|
||
: timelineConfig.startDate
|
||
"
|
||
:top-offset="7.5"
|
||
:height="(resourceTaskLayouts.get(resource.id)?.totalHeight || 51) - 10"
|
||
:width="totalTimelineWidth"
|
||
:timeline-data="timelineData as any"
|
||
:current-time-scale="currentTimeScale"
|
||
:task-row-map="resourceTaskLayouts.get(resource.id)?.taskRowMap"
|
||
:row-heights="resourceTaskLayouts.get(resource.id)?.rowHeights"
|
||
:scroll-left="timelineScrollLeft"
|
||
:container-width="timelineContainerWidth"
|
||
/>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
@import '../styles/theme-variables.css';
|
||
|
||
.timeline {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: var(--gantt-bg-primary, #ffffff);
|
||
overflow-x: auto; /* 横向滚动,显示滚动条 */
|
||
overflow-y: auto; /* 纵向滚动,显示滚动条 */
|
||
width: 100%;
|
||
cursor: grab;
|
||
transition: background-color 0.3s ease;
|
||
position: relative; /* 为覆盖层定位 */
|
||
|
||
/* Webkit浏览器滚动条样式 */
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
|
||
}
|
||
|
||
.timeline:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.timeline::-webkit-scrollbar {
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
|
||
.timeline::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.timeline::-webkit-scrollbar-thumb {
|
||
background-color: var(--gantt-scrollbar-thumb);
|
||
border-radius: 4px;
|
||
border: 2px solid transparent;
|
||
background-clip: content-box;
|
||
}
|
||
|
||
.timeline::-webkit-scrollbar-thumb:hover {
|
||
background-color: var(--gantt-scrollbar-thumb-hover);
|
||
}
|
||
|
||
.timeline::-webkit-scrollbar-corner {
|
||
background: transparent;
|
||
}
|
||
|
||
.timeline-header {
|
||
height: 80px;
|
||
background: var(--gantt-bg-secondary);
|
||
width: fit-content;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.timeline-header-row {
|
||
display: flex;
|
||
height: 50%;
|
||
border-bottom: 1px solid var(--gantt-border-medium);
|
||
min-width: fit-content;
|
||
}
|
||
|
||
.timeline-header-row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.year-month-row {
|
||
align-items: center;
|
||
position: relative; /* 为旗帜提供定位上下文 */
|
||
}
|
||
|
||
.days-row {
|
||
align-items: center;
|
||
}
|
||
|
||
.timeline-month {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-right: 1px solid var(--gantt-border-medium);
|
||
box-sizing: border-box;
|
||
height: 100%;
|
||
min-width: 60px;
|
||
}
|
||
|
||
.timeline-month:last-child {
|
||
border-right: none;
|
||
}
|
||
|
||
.year-month-label {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--gantt-text-header);
|
||
text-align: center;
|
||
}
|
||
|
||
.timeline-month-days {
|
||
display: flex;
|
||
height: 100%;
|
||
border-right: 1px solid var(--gantt-border-medium);
|
||
box-sizing: border-box;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.timeline-month-days:last-child {
|
||
border-right: none;
|
||
}
|
||
|
||
.timeline-day {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-right: 1px solid var(--gantt-border-light);
|
||
width: 30px;
|
||
box-sizing: border-box;
|
||
border-bottom: 1px solid var(--gantt-border-medium);
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.timeline-day:last-child {
|
||
border-right: none;
|
||
}
|
||
|
||
.timeline-day.today {
|
||
background-color: var(--gantt-primary);
|
||
color: var(--gantt-text-white);
|
||
}
|
||
|
||
.timeline-day.today .day-label {
|
||
color: var(--gantt-text-white);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.timeline-day.weekend {
|
||
background-color: var(--gantt-bg-secondary);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.timeline-day.weekend .day-label {
|
||
color: var(--gantt-border-dark);
|
||
}
|
||
|
||
.day-label {
|
||
font-size: 12px;
|
||
color: var(--gantt-text-header);
|
||
text-align: center;
|
||
}
|
||
|
||
/* 周视图样式 */
|
||
.timeline-month-weeks {
|
||
display: flex;
|
||
height: 100%;
|
||
border-right: 1px solid var(--gantt-border-medium);
|
||
box-sizing: border-box;
|
||
min-width: 60px;
|
||
}
|
||
|
||
.timeline-month-weeks:last-child {
|
||
border-right: none;
|
||
}
|
||
|
||
.timeline-week {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-right: 1px solid var(--gantt-border-light);
|
||
width: 60px;
|
||
box-sizing: border-box;
|
||
border-bottom: 1px solid var(--gantt-border-medium);
|
||
transition: background-color 0.2s;
|
||
position: relative;
|
||
}
|
||
|
||
.timeline-week:last-child {
|
||
border-right: none;
|
||
}
|
||
|
||
.timeline-week.today {
|
||
background-color: var(--gantt-primary);
|
||
color: var(--gantt-text-white);
|
||
}
|
||
|
||
.timeline-week.today .week-label {
|
||
color: var(--gantt-text-white);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.week-label {
|
||
font-size: 12px;
|
||
color: var(--gantt-text-header);
|
||
text-align: center;
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.week-sub-days {
|
||
/* 优化:删除子节点后,这个容器仅作为占位符,无需复杂样式 */
|
||
width: 100%;
|
||
height: 100%;
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
pointer-events: none; /* 不阻挡事件穿透 */
|
||
}
|
||
|
||
/* 优化:week-sub-day 样式已废弃,子节点已移除以减少 DOM 节点 */
|
||
/* .week-sub-day 不再使用 */
|
||
|
||
/* 月份1号标记旗帜样式 */
|
||
.month-first-flag {
|
||
position: absolute;
|
||
bottom: -40px;
|
||
z-index: 1;
|
||
pointer-events: none;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.flag-content {
|
||
background-color: var(--gantt-primary, #409eff);
|
||
color: white;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
padding: 1px 4px;
|
||
border-radius: 2px;
|
||
text-align: center;
|
||
min-width: 12px;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||
order: 1; /* 旗帜内容在上 */
|
||
}
|
||
|
||
.flag-pole {
|
||
width: 1px;
|
||
height: 50px;
|
||
background-color: var(--gantt-primary, #409eff);
|
||
order: 2; /* 旗杆在下 */
|
||
}
|
||
|
||
/* 暗色主题下的旗帜样式 */
|
||
:global(html[data-theme='dark']) .flag-pole {
|
||
background-color: var(--gantt-primary-light, #66b1ff);
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .flag-content {
|
||
background-color: var(--gantt-primary-light, #66b1ff);
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
/* 周视图背景列样式 */
|
||
.month-week-columns {
|
||
display: flex;
|
||
position: relative;
|
||
}
|
||
|
||
.week-column {
|
||
position: relative;
|
||
border-right: 1px solid var(--gantt-border-light, #e4e7ed);
|
||
box-sizing: border-box;
|
||
|
||
/* 优化:使用 CSS Grid 代替 7 个 DOM 节点 */
|
||
display: grid;
|
||
grid-template-columns: repeat(7, 1fr); /* 7 列等宽 */
|
||
gap: 0;
|
||
}
|
||
|
||
.week-column:last-child {
|
||
border-right: none;
|
||
}
|
||
|
||
.week-column.today {
|
||
background-color: rgba(64, 158, 255, 0.1);
|
||
}
|
||
|
||
/* 优化:sub-day-column 样式保留用于其他可能的用途,但背景列不再使用 */
|
||
.sub-day-column {
|
||
position: relative;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.sub-day-column.weekend {
|
||
background-color: var(--gantt-bg-secondary, #f5f7fa);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.sub-day-column.today {
|
||
background-color: var(--gantt-primary-light, rgba(64, 158, 255, 0.2));
|
||
}
|
||
|
||
.timeline-body {
|
||
flex: 1;
|
||
overflow-x: hidden; /* 禁用横向滚动,由父容器.timeline处理 */
|
||
overflow-y: auto; /* 只保留纵向滚动 */
|
||
position: relative;
|
||
width: fit-content;
|
||
background: var(--gantt-bg-primary, #ffffff);
|
||
cursor: grab;
|
||
transition: background-color 0.3s ease;
|
||
|
||
/* 隐藏滚动条但保留滚动功能 */
|
||
scrollbar-width: none; /* Firefox */
|
||
-ms-overflow-style: none; /* IE and Edge */
|
||
}
|
||
|
||
.timeline-body::-webkit-scrollbar {
|
||
display: none; /* Chrome, Safari, Opera */
|
||
}
|
||
|
||
.timeline-body-content {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
background: var(--gantt-bg-primary, #ffffff);
|
||
transition: background-color 0.3s ease;
|
||
}
|
||
|
||
.task-bar-container {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
z-index: 100;
|
||
pointer-events: none;
|
||
/* height由内联样式动态设置 */
|
||
}
|
||
|
||
.task-rows {
|
||
position: relative;
|
||
width: 100%;
|
||
/* height由内联样式动态设置 */
|
||
}
|
||
|
||
.task-row {
|
||
position: absolute;
|
||
left: 0;
|
||
width: 100%;
|
||
min-height: 51px; /** 为了对齐左侧的Task List Row高度,同时需要包含List Row的Bottom Border 1px */
|
||
pointer-events: auto;
|
||
z-index: 11;
|
||
transition: background-color 0.2s ease;
|
||
padding-bottom: 2px;
|
||
}
|
||
|
||
/* 资源视图行样式 */
|
||
.resource-row {
|
||
/* 不使用flex,保持TaskBar的绝对定位 */
|
||
position: absolute !important;
|
||
left: 0;
|
||
width: 100%;
|
||
/* height由内联样式动态设置,不使用固定值 */
|
||
pointer-events: auto;
|
||
z-index: 11;
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.timeline-body .task-row-hovered {
|
||
background-color: var(--gantt-bg-hover); /* 与TaskList保持一致的悬停背景色 */
|
||
/* 降低层级,避免覆盖任务条等元素 */
|
||
z-index: 11;
|
||
}
|
||
|
||
.timeline-body .task-row-hovered > * {
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.task-bar-placeholder {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
color: #909399;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.placeholder-text {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.placeholder-desc {
|
||
font-size: 14px;
|
||
color: #c0c4cc;
|
||
}
|
||
|
||
.day-columns {
|
||
display: flex;
|
||
border-right: 1px solid var(--gantt-border-light, #ebeef5);
|
||
box-sizing: border-box;
|
||
min-width: 120px;
|
||
/* height由内联样式动态设置 */
|
||
}
|
||
|
||
.day-columns:last-child {
|
||
border-right: none;
|
||
}
|
||
|
||
.month-day-columns {
|
||
display: flex;
|
||
border-right: 1px dashed var(--gantt-border-light, #ebeef5);
|
||
box-sizing: border-box;
|
||
min-width: 120px;
|
||
/* height由内联样式动态设置 */
|
||
}
|
||
|
||
.month-day-columns:last-child {
|
||
border-right: none;
|
||
}
|
||
|
||
.day-column {
|
||
width: 30px;
|
||
border-right: 1px dashed var(--gantt-border-light, #f0f0f0);
|
||
box-sizing: border-box;
|
||
transition: background-color 0.2s;
|
||
position: relative;
|
||
/* height由内联样式动态设置 */
|
||
}
|
||
|
||
.day-column:last-child {
|
||
border-right: none;
|
||
}
|
||
|
||
.day-column.weekend {
|
||
background-color: var(--gantt-bg-secondary, #f5f7fa);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.day-column.today {
|
||
border-left: 3px solid var(--gantt-primary-color, #409eff);
|
||
background-color: var(--gantt-primary-color, #409eff);
|
||
opacity: 0.15;
|
||
position: relative;
|
||
}
|
||
|
||
.day-column.today::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: linear-gradient(
|
||
to bottom,
|
||
rgba(64, 158, 255, 0.1) 0%,
|
||
rgba(64, 158, 255, 0.05) 50%,
|
||
rgba(64, 158, 255, 0.1) 100%
|
||
);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.day-column.today.weekend {
|
||
background-color: var(--gantt-primary-color, #409eff);
|
||
opacity: 0.2;
|
||
}
|
||
|
||
/* 今日定位高亮效果 - 点击"今日"按钮后的强化效果 */
|
||
.day-column.today-highlight {
|
||
background-color: var(--gantt-primary-color, #409eff) !important;
|
||
opacity: 0.5 !important;
|
||
animation: today-pulse 2s ease-in-out;
|
||
border-left: 3px solid var(--gantt-primary-color, #409eff) !important;
|
||
box-shadow: 0 0 8px rgba(64, 158, 255, 0.4);
|
||
}
|
||
|
||
.day-column.today-highlight::before {
|
||
background: linear-gradient(
|
||
to bottom,
|
||
rgba(64, 158, 255, 0.3) 0%,
|
||
rgba(64, 158, 255, 0.2) 50%,
|
||
rgba(64, 158, 255, 0.3) 100%
|
||
) !important;
|
||
}
|
||
|
||
@keyframes today-pulse {
|
||
0% {
|
||
opacity: 0.8;
|
||
transform: scale(1);
|
||
box-shadow: 0 0 8px rgba(64, 158, 255, 0.6);
|
||
}
|
||
50% {
|
||
opacity: 0.9;
|
||
transform: scale(1.02);
|
||
box-shadow: 0 0 12px rgba(64, 158, 255, 0.8);
|
||
}
|
||
100% {
|
||
opacity: 0.5;
|
||
transform: scale(1);
|
||
box-shadow: 0 0 8px rgba(64, 158, 255, 0.4);
|
||
}
|
||
}
|
||
|
||
/* 暗色主题支持 */
|
||
:global(html[data-theme='dark']) .timeline {
|
||
background: var(--gantt-bg-primary, #3a3a3a) !important;
|
||
color: var(--gantt-text-primary, #e5e5e5) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-header {
|
||
background: var(--gantt-bg-secondary, #1a1a1a) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-header-row {
|
||
border-bottom-color: var(--gantt-border-medium, #333333) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-month {
|
||
border-right-color: var(--gantt-border-medium, #333333) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .year-month-label {
|
||
color: var(--gantt-text-header, #ffffff) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-day {
|
||
border-right-color: var(--gantt-border-light, #555555) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-day.today {
|
||
background: #1a365d !important;
|
||
color: #e3f2fd !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-day.weekend {
|
||
background: var(--gantt-bg-secondary, #1a1a1a) !important;
|
||
color: var(--gantt-text-muted, #b0b0b0) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .day-label {
|
||
color: var(--gantt-text-header, #ffffff) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-body {
|
||
background: var(--gantt-bg-primary, #6b6b6b) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-body-content {
|
||
background: var(--gantt-bg-primary, #6b6b6b) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .day-columns {
|
||
border-right-color: var(--gantt-border-light, #555555) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .month-day-columns {
|
||
border-right-color: var(--gantt-border-light, #555555) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .day-column {
|
||
border-right-color: var(--gantt-border-light, #555555) !important;
|
||
/* 非周末列:透明背景,继承timeline-body的背景色 */
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .day-column.today {
|
||
border-left-color: var(--gantt-primary, #409eff) !important;
|
||
background-color: var(--gantt-primary, #409eff) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .day-column.today::before {
|
||
background: linear-gradient(
|
||
to bottom,
|
||
rgba(64, 158, 255, 0.15) 0%,
|
||
rgba(64, 158, 255, 0.08) 50%,
|
||
rgba(64, 158, 255, 0.15) 100%
|
||
) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .day-column.today.weekend {
|
||
background-color: var(--gantt-primary-color, #409eff) !important;
|
||
}
|
||
|
||
/* 暗色主题下的今日定位高亮效果 */
|
||
:global(html[data-theme='dark']) .day-column.today-highlight {
|
||
background-color: var(--gantt-primary-color, #409eff) !important;
|
||
box-shadow: 0 0 8px rgba(64, 158, 255, 0.6) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .day-column.today-highlight::before {
|
||
background: linear-gradient(
|
||
to bottom,
|
||
rgba(64, 158, 255, 0.4) 0%,
|
||
rgba(64, 158, 255, 0.25) 50%,
|
||
rgba(64, 158, 255, 0.4) 100%
|
||
) !important;
|
||
}
|
||
|
||
/* 暗黑模式下的任务行悬停效果 */
|
||
:global(html[data-theme='dark']) .timeline-body .task-row-hovered {
|
||
background-color: var(--gantt-bg-hover) !important; /* 与TaskList保持一致,使用透明背景 */
|
||
/* 降低层级,避免覆盖任务条等元素 */
|
||
z-index: 11 !important;
|
||
}
|
||
|
||
/* 确保暗黑模式下子元素能继续响应事件 */
|
||
:global(html[data-theme='dark']) .timeline-body .task-row-hovered > * {
|
||
pointer-events: auto !important;
|
||
}
|
||
|
||
/* 暗黑模式下的非工作时间样式 */
|
||
:global(html[data-theme='dark']) .timeline-hour-item.non-working-hour {
|
||
background-color: var(--gantt-bg-secondary, #1a1a1a) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-hour-item.non-working-hour .hour-label {
|
||
color: var(--gantt-text-muted, #b0b0b0) !important;
|
||
}
|
||
|
||
/* 月度视图专用样式 */
|
||
.year-row {
|
||
min-height: 36px;
|
||
border-bottom: 1px solid var(--gantt-border-medium, #e1e4e8);
|
||
position: relative; /* 为绝对定位的子元素提供基准 */
|
||
}
|
||
|
||
.timeline-year {
|
||
border-right: 1px solid var(--gantt-border-medium, #e1e4e8);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 36px;
|
||
box-sizing: border-box; /* 确保border包含在width内 */
|
||
}
|
||
|
||
.year-label {
|
||
color: var(--gantt-text-header, #24292e);
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
text-align: center;
|
||
white-space: nowrap; /* 防止年份文字换行 */
|
||
}
|
||
|
||
.months-row {
|
||
min-height: 36px;
|
||
border-bottom: 1px solid var(--gantt-border-light, #d1d5da);
|
||
}
|
||
|
||
.timeline-month-item {
|
||
border-right: 1px solid var(--gantt-border-light, #d1d5da);
|
||
border-bottom: 1px solid var(--gantt-border-light, #d1d5da);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 36px;
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.timeline-month-item.today {
|
||
background-color: var(--gantt-primary);
|
||
}
|
||
|
||
.month-label {
|
||
color: var(--gantt-text-primary, #24292e);
|
||
font-weight: 500;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
text-align: center;
|
||
}
|
||
|
||
.month-column {
|
||
border-right: 1px solid var(--gantt-border-light, #d1d5da);
|
||
position: relative;
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.month-column.today {
|
||
background-color: var(--gantt-primary-color, #409eff);
|
||
opacity: 0.15;
|
||
}
|
||
|
||
/* 季度视图样式 */
|
||
.quarter-header-container {
|
||
position: relative;
|
||
overflow: hidden; /* 防止内容溢出 */
|
||
}
|
||
|
||
.quarters-row {
|
||
min-height: 36px; /* 与其他视图的第二行保持一致 */
|
||
background: var(--gantt-bg-secondary, #f6f8fa);
|
||
border-bottom: 1px solid var(--gantt-border-light, #d1d5da); /* 与其他视图第二行保持一致 */
|
||
position: relative; /* 为绝对定位的子元素提供基准 */
|
||
}
|
||
|
||
.timeline-quarter-item {
|
||
border-right: 1px solid var(--gantt-border-light, #d1d5da);
|
||
border-bottom: 1px solid var(--gantt-border-light, #d1d5da); /* 添加底边框,与月度视图保持一致 */
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 36px; /* 改为min-height并与其他视图第二行保持一致 */
|
||
height: 100%; /* 占满父容器高度 */
|
||
transition: background-color 0.2s ease;
|
||
box-sizing: border-box; /* 确保border包含在width内 */
|
||
}
|
||
|
||
.timeline-quarter-item.today {
|
||
background-color: var(--gantt-primary-color, #409eff);
|
||
color: white;
|
||
}
|
||
|
||
.quarter-label {
|
||
color: var(--gantt-text-primary, #24292e); /* 改为text-primary,与月度视图的month-label保持一致 */
|
||
font-weight: 500; /* 与月度视图的month-label保持一致 */
|
||
font-size: 13px; /* 与月度视图的month-label保持一致 */
|
||
line-height: 1.5; /* 与月度视图的month-label保持一致 */
|
||
text-align: center;
|
||
white-space: nowrap; /* 防止文字换行 */
|
||
}
|
||
|
||
.timeline-quarter-item.today .quarter-label {
|
||
color: white;
|
||
}
|
||
|
||
.quarter-columns-container {
|
||
position: relative;
|
||
overflow: hidden; /* 防止内容溢出 */
|
||
}
|
||
|
||
.quarter-column {
|
||
border-right: 1px solid var(--gantt-border-light, #d1d5da);
|
||
position: absolute; /* 改为绝对定位 */
|
||
transition: background-color 0.2s ease;
|
||
box-sizing: border-box; /* 确保border包含在width内 */
|
||
}
|
||
|
||
.quarter-column.today {
|
||
background-color: var(--gantt-primary-color, #409eff);
|
||
opacity: 0.15;
|
||
}
|
||
|
||
/* 年度视图样式 */
|
||
.half-years-row {
|
||
min-height: 36px;
|
||
border-bottom: 1px solid var(--gantt-border-light, #d1d5da);
|
||
}
|
||
|
||
.timeline-half-year-item {
|
||
border-right: 1px solid var(--gantt-border-light, #d1d5da);
|
||
border-bottom: 1px solid var(--gantt-border-light, #d1d5da);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 36px;
|
||
transition: background-color 0.2s ease;
|
||
box-sizing: border-box; /* 确保border包含在width内 */
|
||
}
|
||
|
||
.half-year-label {
|
||
color: var(--gantt-text-primary, #24292e);
|
||
font-weight: 500;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
text-align: center;
|
||
}
|
||
|
||
/* 年度视图背景列样式 */
|
||
.half-year-column {
|
||
border-right: 1px solid var(--gantt-border-light, #d1d5da);
|
||
position: relative;
|
||
transition: background-color 0.2s ease;
|
||
background-color: var(--gantt-bg-primary, #ffffff);
|
||
box-sizing: border-box; /* 确保border包含在width内 */
|
||
}
|
||
|
||
.half-year-column:hover {
|
||
background-color: var(--gantt-bg-hover, rgba(64, 158, 255, 0.05));
|
||
}
|
||
|
||
/* 月度视图暗色主题样式 */
|
||
:global(html[data-theme='dark']) .year-row {
|
||
background: var(--gantt-bg-secondary, #1a1a1a) !important;
|
||
border-bottom-color: var(--gantt-border-medium, #333333) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-year {
|
||
border-right-color: var(--gantt-border-medium, #333333) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .year-label {
|
||
color: var(--gantt-text-header, #ffffff) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .months-row {
|
||
background: var(--gantt-bg-secondary, #1a1a1a) !important;
|
||
border-bottom-color: var(--gantt-border-medium, #333333) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-month-item {
|
||
border-right-color: var(--gantt-border-light, #555555) !important;
|
||
border-bottom-color: var(--gantt-border-light, #555555) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-month-item.today {
|
||
background-color: var(--gantt-primary);
|
||
border-left-color: var(--gantt-primary, #409eff) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .month-label {
|
||
color: var(--gantt-text-header, #ffffff) !important;
|
||
}
|
||
|
||
/* 季度视图暗色主题样式 */
|
||
:global(html[data-theme='dark']) .quarters-row {
|
||
background: var(--gantt-bg-secondary, #1a1a1a) !important;
|
||
border-bottom-color: var(--gantt-border-medium, #333333) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-quarter-item {
|
||
border-right-color: var(--gantt-border-light, #555555) !important;
|
||
border-bottom-color: var(--gantt-border-light, #555555) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-quarter-item.today {
|
||
background-color: var(--gantt-primary);
|
||
border-left-color: var(--gantt-primary, #409eff) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .quarter-label {
|
||
color: var(--gantt-text-header, #ffffff) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .quarter-column {
|
||
border-right-color: var(--gantt-border-light, #555555) !important;
|
||
background-color: var(--gantt-bg-primary, #6b6b6b) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .quarter-column.today {
|
||
background-color: var(--gantt-primary, #409eff) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .month-column {
|
||
border-right-color: var(--gantt-border-light, #555555) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .month-column.today {
|
||
background-color: var(--gantt-primary-color, #409eff);
|
||
border-left-color: var(--gantt-primary-color, #409eff) !important;
|
||
}
|
||
|
||
/* 年度视图暗色主题样式 */
|
||
:global(html[data-theme='dark']) .half-years-row {
|
||
background: var(--gantt-bg-secondary, #1a1a1a) !important;
|
||
border-bottom-color: var(--gantt-border-medium, #333333) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-half-year-item {
|
||
border-right-color: var(--gantt-border-light, #555555) !important;
|
||
border-bottom-color: var(--gantt-border-light, #555555) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .half-year-label {
|
||
color: var(--gantt-text-header, #ffffff) !important;
|
||
}
|
||
|
||
/* 年度视图背景列暗色主题样式 */
|
||
:global(html[data-theme='dark']) .half-year-column {
|
||
border-right-color: var(--gantt-border-light, #555555) !important;
|
||
background-color: var(--gantt-bg-primary, #6b6b6b) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .half-year-column:hover {
|
||
background-color: var(--gantt-bg-hover, rgba(64, 158, 255, 0.1)) !important;
|
||
}
|
||
|
||
/* 年度视图今日标记线样式 */
|
||
.today-line-year-view {
|
||
position: absolute;
|
||
top: 0;
|
||
width: 2px;
|
||
background-color: var(--gantt-primary, #409eff);
|
||
z-index: 30;
|
||
pointer-events: none;
|
||
box-shadow: 0 0 4px rgba(64, 158, 255, 0.3);
|
||
}
|
||
|
||
/* 暗黑模式下的年度视图今日标记线 */
|
||
:global(html[data-theme='dark']) .today-line-year-view {
|
||
background-color: var(--gantt-primary, #66b1ff);
|
||
box-shadow: 0 0 4px rgba(102, 177, 255, 0.4);
|
||
}
|
||
|
||
/* 小时视图header容器 */
|
||
.hour-header-container {
|
||
position: relative;
|
||
min-width: 100%;
|
||
}
|
||
|
||
.date-row {
|
||
min-height: 40px;
|
||
border-bottom: 1px solid var(--gantt-border-light, #d1d5da);
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/*---------------*/
|
||
.timeline-day-item {
|
||
top: 0;
|
||
height: 100%;
|
||
border-right: 1px solid var(--gantt-border-medium, #e1e4e8);
|
||
background-color: var(--gantt-bg-secondary, #f6f8fa);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 40px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* 小时视图日期项专用样式 */
|
||
.timeline-day-item.hour-view-day {
|
||
position: absolute;
|
||
top: 0;
|
||
height: 100%;
|
||
border-right: 1px solid var(--gantt-border-medium, #e1e4e8);
|
||
background-color: var(--gantt-bg-secondary, #f6f8fa);
|
||
}
|
||
|
||
.date-label {
|
||
color: var(--gantt-text-header, #24292e);
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
text-align: center;
|
||
}
|
||
|
||
.hours-row {
|
||
min-height: 40px;
|
||
border-bottom: 1px solid var(--gantt-border-light, #d1d5da);
|
||
position: relative;
|
||
overflow: hidden;
|
||
display: flex;
|
||
}
|
||
|
||
.hour-label {
|
||
color: var(--gantt-text-primary, #24292e);
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
line-height: 1.3;
|
||
text-align: center;
|
||
letter-spacing: 0px;
|
||
}
|
||
|
||
/* 15分钟刻度线样式 */
|
||
.quarter-hour-lines {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.quarter-line {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 1px;
|
||
background-color: var(--gantt-border-light, #d1d5da);
|
||
opacity: 0.5;
|
||
}
|
||
/**------------------------ */
|
||
|
||
.timeline-hour-item {
|
||
top: 0;
|
||
height: 100%;
|
||
border-right: 1px solid var(--gantt-border-light, #d1d5da);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 40px;
|
||
transition: background-color 0.2s ease;
|
||
box-sizing: border-box;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.timeline-hour-item.today {
|
||
background-color: var(--gantt-primary);
|
||
color: var(--gantt-text-white);
|
||
}
|
||
|
||
.timeline-hour-item.today .hour-label {
|
||
color: var(--gantt-text-white);
|
||
}
|
||
|
||
/* 小时视图非工作时间样式 - 参考日视图周末样式 */
|
||
.timeline-hour-item.non-working-hour {
|
||
background-color: var(--gantt-bg-secondary);
|
||
}
|
||
|
||
.timeline-hour-item.non-working-hour .hour-label {
|
||
color: var(--gantt-border-dark);
|
||
}
|
||
|
||
/* 小时视图容器样式 */
|
||
.hour-columns-container {
|
||
position: relative;
|
||
min-width: 100%;
|
||
min-height: 100px;
|
||
display: flex;
|
||
}
|
||
|
||
.hour-column {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
border-right: 1px solid var(--gantt-border-light, #e0e6ed);
|
||
background-color: var(--gantt-bg-primary, #ffffff);
|
||
transition: background-color 0.2s ease;
|
||
box-sizing: border-box;
|
||
flex-shrink: 0;
|
||
pointer-events: none;
|
||
z-index: 1;
|
||
}
|
||
|
||
.hour-column.weekend {
|
||
background-color: var(--gantt-bg-secondary, #f5f7fa);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.hour-column.rest-hour {
|
||
background-color: var(--gantt-bg-secondary, #f5f7fa);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.hour-column.working-hour {
|
||
background-color: var(--gantt-bg-primary, #ffffff);
|
||
}
|
||
|
||
.hour-column.today {
|
||
background-color: var(--gantt-primary-color, #409eff);
|
||
opacity: 0.2;
|
||
border-left: 2px solid var(--gantt-primary-color, #409eff);
|
||
}
|
||
|
||
.hour-column:hover {
|
||
background-color: var(--gantt-bg-hover, rgba(64, 158, 255, 0.05));
|
||
}
|
||
|
||
/* 小时视图暗色主题样式 */
|
||
:global(html[data-theme='dark']) .date-row {
|
||
background: var(--gantt-bg-secondary, #1a1a1a) !important;
|
||
border-bottom-color: var(--gantt-border-medium, #333333) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-day-item {
|
||
border-right-color: var(--gantt-border-medium, #333333) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .date-label {
|
||
color: var(--gantt-text-header, #ffffff) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .hours-row {
|
||
background: var(--gantt-bg-secondary, #1a1a1a) !important;
|
||
border-bottom-color: var(--gantt-border-medium, #333333) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-hour-item {
|
||
border-right-color: var(--gantt-border-light, #555555) !important;
|
||
border-bottom-color: var(--gantt-border-light, #555555) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-hour-item.today {
|
||
background-color: var(--gantt-primary, #1a365d) !important;
|
||
color: var(--gantt-text-white, #e3f2fd) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .hour-label {
|
||
color: var(--gantt-text-primary, #e5e5e5) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .timeline-hour-item.today .hour-label {
|
||
color: var(--gantt-text-white, #e3f2fd) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .hour-column {
|
||
border-right-color: var(--gantt-border-light, #555555) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .hour-column.weekend {
|
||
background-color: var(--gantt-bg-secondary, #1a1a1a) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .hour-column.rest-hour {
|
||
background-color: var(--gantt-bg-secondary, #1a1a1a) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .hour-column.working-hour {
|
||
background-color: var(--gantt-bg-primary, #6b6b6b) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .hour-column.today {
|
||
background-color: var(--gantt-primary-color, #409eff) !important;
|
||
border-left-color: var(--gantt-primary-color, #409eff) !important;
|
||
}
|
||
|
||
/* 暗色主题:15分钟刻度线样式 */
|
||
:global(html[data-theme='dark']) .quarter-line {
|
||
background-color: var(--gantt-border-light, #555555) !important;
|
||
}
|
||
</style>
|