1002 lines
30 KiB
Vue
1002 lines
30 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, inject, type Ref } from 'vue'
|
||
import { detectConflicts, type ConflictZone } from '../../utils/conflictUtils'
|
||
import type { Task } from '../../models/classes/Task'
|
||
import { TimelineScale } from '../../models/types/TimelineScale'
|
||
|
||
interface Props {
|
||
/** 当前资源的所有任务 */
|
||
tasks: Task[]
|
||
/** 资源ID */
|
||
resourceId: string | number
|
||
/** 每天的宽度(px) */
|
||
dayWidth: number
|
||
/** 时间轴起始日期 */
|
||
startDate: Date
|
||
/** Canvas高度(对应资源行高度) */
|
||
height: number
|
||
/** Canvas宽度(时间轴总宽度) */
|
||
width: number
|
||
/** 顶部偏移量(px),用于对齐第一行TaskBar上沿 */
|
||
topOffset?: number
|
||
/** 时间轴数据(用于精确定位) */
|
||
timelineData?: Array<any>
|
||
/** 当前时间刻度 */
|
||
currentTimeScale?: TimelineScale
|
||
/** v1.9.5 任务ID到行号的映射 */
|
||
taskRowMap?: Map<string | number, number>
|
||
/** v1.9.5 每行的高度数组 */
|
||
rowHeights?: number[]
|
||
/** 可视区域滚动位置 */
|
||
scrollLeft?: number
|
||
/** 可视区域宽度 */
|
||
containerWidth?: number
|
||
/** v1.9.9 当前渲染的任务数量限制(用于渐进式渲染) */
|
||
renderLimit?: number
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
|
||
// 注入拖拽状态
|
||
const isDraggingTaskBar = inject<Ref<boolean>>('isDraggingTaskBar', ref(false))
|
||
// v1.9.5 P2-4优化 - 注入Split Bar拖拽状态
|
||
const isSplitBarDragging = inject<Ref<boolean>>('isSplitBarDragging', ref(false))
|
||
// 注入Timeline拖拽状态
|
||
const isDraggingTimeline = inject<Ref<boolean>>('isDraggingTimeline', ref(false))
|
||
// 注入最近变化的TaskBar ID(用于增量更新)
|
||
const lastChangedTaskId = inject<Ref<string | number | null>>('lastChangedTaskId', ref(null))
|
||
|
||
// Canvas引用
|
||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||
// v1.9.6 使用可视区域宽度而非总宽度,避免Canvas尺寸过大
|
||
// containerWidth优先(约1900px),如果为0则使用1920作为默认值,绝不使用props.width(可能30万px)
|
||
const canvasWidth = computed(() => {
|
||
const width = props.containerWidth || 1920
|
||
return width
|
||
})
|
||
const canvasHeight = computed(() => props.height)
|
||
|
||
// 冲突区域列表
|
||
const conflictZones = ref<ConflictZone[]>([])
|
||
const needsRecalculation = ref(false)
|
||
|
||
// v1.9.4 P1优化 - 坐标缓存,避免重复计算
|
||
const coordsCache = new Map<string, { left: number; width: number }>()
|
||
|
||
// v1.9.4 P1优化 - 上一次的冲突区域列表,用于增量重绘
|
||
const previousConflictZones = ref<ConflictZone[]>([])
|
||
|
||
// 纹理预生成(性能优化)
|
||
const texturePatterns = ref<{
|
||
light: CanvasPattern | null
|
||
medium: CanvasPattern | null
|
||
severe: CanvasPattern | null
|
||
}>({
|
||
light: null,
|
||
medium: null,
|
||
severe: null,
|
||
})
|
||
|
||
// Canvas样式(使用transform平移到可视区域)
|
||
const canvasStyle = computed(() => ({
|
||
position: 'absolute' as const,
|
||
top: `${props.topOffset || 0}px`,
|
||
left: `${props.scrollLeft || 0}px`, // 平移到滚动位置
|
||
pointerEvents: 'none' as const, // 始终穿透,让TaskBar可以响应鼠标事件
|
||
transition: 'opacity 0.2s',
|
||
zIndex: 250, // v1.9.2 高于TaskBar的最高z-index(200),确保冲突层在最上层
|
||
backgroundColor: 'transparent', // 确保Canvas背景透明
|
||
}))
|
||
|
||
// 监听任务列表变化
|
||
watch(() => props.tasks, () => {
|
||
if (isDraggingTaskBar.value) {
|
||
// 拖拽进行中:标记需要重算,等拖拽结束后再执行
|
||
needsRecalculation.value = true
|
||
} else {
|
||
// 拖拽结束后的已提交数据变更:isDraggingTaskBar 已先于数据更新变为 false,
|
||
// 此处直接触发增量/全量重算,避免冲突层不刷新。
|
||
nextTick(() => {
|
||
if (lastChangedTaskId.value !== null) {
|
||
recalculateConflictsIncremental(lastChangedTaskId.value)
|
||
lastChangedTaskId.value = null
|
||
} else {
|
||
texturePatterns.value = { light: null, medium: null, severe: null }
|
||
coordsCache.clear()
|
||
recalculateConflicts()
|
||
}
|
||
})
|
||
}
|
||
}, { deep: true })
|
||
|
||
// v1.9.9 监听renderLimit变化,TaskBar渐进式渲染时重新计算冲突
|
||
watch(() => props.renderLimit, () => {
|
||
// 延迟一帧,确保DOM更新完成
|
||
nextTick(() => {
|
||
recalculateConflicts()
|
||
})
|
||
})
|
||
|
||
// 监听拖拽状态变化
|
||
watch(isDraggingTaskBar, (dragging) => {
|
||
if (!dragging && needsRecalculation.value) {
|
||
nextTick(() => {
|
||
// 检查是否可以使用增量更新(单个TaskBar变化且有ID记录)
|
||
if (lastChangedTaskId.value !== null) {
|
||
recalculateConflictsIncremental(lastChangedTaskId.value)
|
||
lastChangedTaskId.value = null // 清除记录
|
||
} else {
|
||
// 全量重新计算
|
||
texturePatterns.value = { light: null, medium: null, severe: null }
|
||
coordsCache.clear()
|
||
recalculateConflicts()
|
||
}
|
||
needsRecalculation.value = false
|
||
})
|
||
}
|
||
})
|
||
|
||
// v1.9.5 P2-4优化 - 监听Split Bar拖拽状态变化
|
||
watch(isSplitBarDragging, (dragging) => {
|
||
if (!dragging && needsRecalculation.value) {
|
||
nextTick(() => {
|
||
// 拖拽结束后清空缓存并重新计算,清空纹理缓存避免颜色问题
|
||
texturePatterns.value = { light: null, medium: null, severe: null }
|
||
coordsCache.clear()
|
||
recalculateConflicts()
|
||
needsRecalculation.value = false
|
||
})
|
||
}
|
||
})
|
||
|
||
// 监听Timeline拖拽状态变化 - 拖拽开始时立即清除Canvas
|
||
watch(isDraggingTimeline, (dragging) => {
|
||
if (dragging) {
|
||
// 拖拽开始时立即清除Canvas
|
||
clearCanvas()
|
||
} else {
|
||
// 拖拽结束后重新计算并绘制
|
||
nextTick(() => {
|
||
recalculateConflicts()
|
||
})
|
||
}
|
||
})
|
||
|
||
// 监听Canvas尺寸变化
|
||
watch([canvasWidth, canvasHeight], () => {
|
||
// v1.9.5 P2-4优化 - Split Bar拖拽时延迟重绘
|
||
if (isSplitBarDragging.value) {
|
||
needsRecalculation.value = true
|
||
return
|
||
}
|
||
|
||
nextTick(() => {
|
||
renderConflicts()
|
||
})
|
||
})
|
||
|
||
// 监听timelineData和currentTimeScale变化
|
||
watch([() => props.timelineData, () => props.currentTimeScale], () => {
|
||
// v1.9.4 BUG修复 - 视图切换时清空坐标缓存
|
||
coordsCache.clear()
|
||
// v1.9.7 Bug修复 - 视图切换时清空纹理缓存并使用nextTick确保立即刷新
|
||
texturePatterns.value = { light: null, medium: null, severe: null }
|
||
nextTick(() => {
|
||
recalculateConflicts()
|
||
})
|
||
}, { deep: true })
|
||
|
||
// v1.9.6 监听scrollLeft变化,滚动停止后重新计算可视区域的冲突
|
||
// v1.9.9 恢复防抖机制:避免滚动时频繁重绘Canvas导致性能问题
|
||
let scrollTimer: ReturnType<typeof setTimeout> | null = null
|
||
watch(() => props.scrollLeft, () => {
|
||
// 清除之前的定时器
|
||
if (scrollTimer) {
|
||
clearTimeout(scrollTimer)
|
||
}
|
||
|
||
// 滚动时仅清空Canvas,避免闪烁
|
||
clearCanvas()
|
||
|
||
// 50ms防抖:滚动停止后重新计算和绘制冲突区域
|
||
scrollTimer = setTimeout(() => {
|
||
recalculateConflicts()
|
||
scrollTimer = null
|
||
}, 50)
|
||
})
|
||
|
||
// 增量重新计算冲突(仅计算与变化TaskBar相关的冲突)
|
||
function recalculateConflictsIncremental(changedTaskId: string | number) {
|
||
// 找到变化的任务
|
||
const changedTask = props.tasks.find(t => t.id === changedTaskId)
|
||
if (!changedTask) {
|
||
recalculateConflicts()
|
||
return
|
||
}
|
||
|
||
// 找出与变化任务时间重叠的所有任务(包括自己)
|
||
const affectedTasks = props.tasks.filter(task => {
|
||
// 检查时间是否重叠
|
||
const taskStart = task.startDate ? new Date(task.startDate) : null
|
||
if (!taskStart) return false
|
||
const taskEnd = task.endDate ? new Date(task.endDate) : null
|
||
if (!taskEnd) return false
|
||
const changedStart = changedTask.startDate ? new Date(changedTask.startDate) : null
|
||
if (!changedStart) return false
|
||
const changedEnd = changedTask.endDate ? new Date(changedTask.endDate) : null
|
||
if (!changedEnd) return false
|
||
|
||
return !(taskEnd < changedStart || taskStart > changedEnd)
|
||
})
|
||
|
||
// 只对受影响的任务进行冲突检测
|
||
const newConflicts = detectConflicts(affectedTasks, props.resourceId)
|
||
|
||
// 找出哪些旧的冲突区域需要被移除(不再涉及受影响的任务)
|
||
const affectedTaskIds = new Set(affectedTasks.map(t => t.id))
|
||
const unchangedConflicts = previousConflictZones.value.filter(zone => {
|
||
// 如果冲突区域的所有任务都不在受影响列表中,则保留
|
||
return !zone.tasks.some(task => affectedTaskIds.has(Number(task.id)))
|
||
})
|
||
|
||
// 合并未变化的冲突和新计算的冲突
|
||
const allConflicts = [...unchangedConflicts.map(z => ({
|
||
startDate: z.startDate,
|
||
endDate: z.endDate,
|
||
tasks: z.tasks,
|
||
totalPercent: z.totalPercent,
|
||
level: z.level,
|
||
})), ...newConflicts]
|
||
|
||
// 转换为Canvas坐标(与全量计算相同的逻辑)
|
||
conflictZones.value = allConflicts.map((zone) => {
|
||
const cacheKey = `${zone.startDate.getTime()}-${zone.endDate.getTime()}`
|
||
let left: number, width: number
|
||
|
||
if (coordsCache.has(cacheKey)) {
|
||
const cached = coordsCache.get(cacheKey)!
|
||
left = cached.left
|
||
width = cached.width
|
||
} else {
|
||
const coords = calculatePosition(zone.startDate, zone.endDate)
|
||
left = coords.left
|
||
width = coords.width
|
||
coordsCache.set(cacheKey, { left, width })
|
||
}
|
||
|
||
const scrollLeft = props.scrollLeft || 0
|
||
const viewportWidth = props.containerWidth || 1920
|
||
const viewportLeft = left - scrollLeft
|
||
const viewportRight = viewportLeft + width
|
||
|
||
if (viewportRight <= 0 || viewportLeft >= viewportWidth) {
|
||
return null
|
||
}
|
||
|
||
const canvasLeft = Math.max(0, viewportLeft)
|
||
const canvasRight = Math.min(viewportWidth, viewportRight)
|
||
const canvasWidth = canvasRight - canvasLeft
|
||
|
||
let top = 0
|
||
let height = props.height
|
||
|
||
if (props.taskRowMap && props.rowHeights && zone.tasks.length > 0) {
|
||
const rowNumbers: number[] = []
|
||
for (const task of zone.tasks) {
|
||
const rowNum = props.taskRowMap.get(task.id)
|
||
if (rowNum !== undefined) {
|
||
rowNumbers.push(rowNum)
|
||
}
|
||
}
|
||
|
||
if (rowNumbers.length > 0) {
|
||
const minRow = Math.min(...rowNumbers)
|
||
const maxRow = Math.max(...rowNumbers)
|
||
top = 0
|
||
for (let i = 0; i < minRow; i++) {
|
||
top += props.rowHeights[i] || 51
|
||
}
|
||
height = 0
|
||
for (let i = minRow; i <= maxRow; i++) {
|
||
height += props.rowHeights[i] || 51
|
||
}
|
||
height = Math.max(1, height - 10)
|
||
}
|
||
}
|
||
|
||
return {
|
||
...zone,
|
||
left: canvasLeft,
|
||
width: canvasWidth,
|
||
top,
|
||
height,
|
||
}
|
||
}).filter(z => z !== null) as ConflictZone[]
|
||
|
||
// 使用增量重绘
|
||
renderConflictsIncremental()
|
||
}
|
||
|
||
// 重新计算冲突
|
||
function recalculateConflicts() {
|
||
// v1.9.9 优化:只计算已渲染的TaskBar的冲突
|
||
// 如果设置了renderLimit,则只取前N个任务进行冲突检测
|
||
const tasksToCheck = props.renderLimit !== undefined && props.renderLimit > 0
|
||
? props.tasks.slice(0, props.renderLimit)
|
||
: props.tasks
|
||
|
||
// 调用冲突检测算法
|
||
const conflicts = detectConflicts(tasksToCheck, props.resourceId)
|
||
|
||
// v1.9.4 P1优化 - 使用坐标缓存
|
||
conflictZones.value = conflicts.map((zone) => {
|
||
// 生成缓存key(基于时间戳避免日期对象比较)
|
||
const cacheKey = `${zone.startDate.getTime()}-${zone.endDate.getTime()}`
|
||
|
||
let left: number, width: number
|
||
|
||
// 尝试从缓存获取坐标
|
||
if (coordsCache.has(cacheKey)) {
|
||
const cached = coordsCache.get(cacheKey)!
|
||
left = cached.left
|
||
width = cached.width
|
||
} else {
|
||
// 缓存未命中,重新计算并缓存
|
||
const coords = calculatePosition(zone.startDate, zone.endDate)
|
||
left = coords.left
|
||
width = coords.width
|
||
coordsCache.set(cacheKey, { left, width })
|
||
}
|
||
|
||
// 转换为相对于可视区域的坐标
|
||
const scrollLeft = props.scrollLeft || 0
|
||
const viewportWidth = props.containerWidth || 1920
|
||
|
||
// 计算冲突区域在可视区域中的位置
|
||
const viewportLeft = left - scrollLeft
|
||
const viewportRight = viewportLeft + width
|
||
|
||
// 如果冲突区域完全在可视区域外,跳过
|
||
if (viewportRight <= 0 || viewportLeft >= viewportWidth) {
|
||
return null
|
||
}
|
||
|
||
// 计算在Canvas上的绘制坐标(相对于Canvas左边界)
|
||
// Canvas通过CSS定位在scrollLeft位置,所以绘制时使用viewport相对坐标
|
||
const canvasLeft = Math.max(0, viewportLeft)
|
||
const canvasRight = Math.min(viewportWidth, viewportRight)
|
||
const canvasWidth = canvasRight - canvasLeft
|
||
|
||
// v1.9.5 修复:根据冲突任务所在的行号计算正确的top和height
|
||
let top = 0
|
||
let height = props.height
|
||
|
||
if (props.taskRowMap && props.rowHeights && zone.tasks.length > 0) {
|
||
// 找到所有冲突任务的行号
|
||
const rowNumbers: number[] = []
|
||
for (const task of zone.tasks) {
|
||
const rowNum = props.taskRowMap.get(task.id)
|
||
if (rowNum !== undefined) {
|
||
rowNumbers.push(rowNum)
|
||
}
|
||
}
|
||
|
||
if (rowNumbers.length > 0) {
|
||
// 找到最小和最大行号
|
||
const minRow = Math.min(...rowNumbers)
|
||
const maxRow = Math.max(...rowNumbers)
|
||
|
||
// 计算top:从第一行的顶部开始(相对于Canvas内部坐标)
|
||
// Canvas本身已有topOffset,所以这里直接累加行高即可
|
||
top = 0
|
||
for (let i = 0; i < minRow; i++) {
|
||
top += props.rowHeights[i] || 51
|
||
}
|
||
|
||
// 计算height:从minRow到maxRow所有行的高度之和
|
||
height = 0
|
||
for (let i = minRow; i <= maxRow; i++) {
|
||
height += props.rowHeights[i] || 51
|
||
}
|
||
// 减去底部边距(约2.5px),让冲突区域不超出TaskBar底部边界
|
||
height = Math.max(1, height - 10)
|
||
}
|
||
}
|
||
|
||
return {
|
||
...zone,
|
||
left: canvasLeft,
|
||
width: canvasWidth,
|
||
top,
|
||
height,
|
||
}
|
||
}).filter(z => z !== null) as ConflictZone[]
|
||
|
||
// v1.9.4 P1优化 - 增量重绘
|
||
// v1.9.5 修复:如果纹理缓存被清空,使用全量重绘避免颜色变淡
|
||
if (!texturePatterns.value.light && !texturePatterns.value.medium && !texturePatterns.value.severe) {
|
||
renderConflicts()
|
||
} else {
|
||
renderConflictsIncremental()
|
||
}
|
||
}
|
||
|
||
// 计算冲突区域在Canvas上的位置(与TaskBar使用相同逻辑)
|
||
function calculatePosition(startDate: Date, endDate: Date): { left: number; width: number } {
|
||
let left = 0
|
||
let width = 0
|
||
|
||
// 小时视图:使用分钟精度计算
|
||
if (props.currentTimeScale === TimelineScale.HOUR) {
|
||
// 计算时间线开始日期的00:00:00作为全局基准
|
||
const timelineStartOfDay = new Date(props.startDate)
|
||
timelineStartOfDay.setHours(0, 0, 0, 0)
|
||
|
||
// 计算开始和结束时间相对于时间线开始的总分钟数
|
||
const startMinutesTotal = Math.floor((startDate.getTime() - timelineStartOfDay.getTime()) / (1000 * 60))
|
||
const endMinutesTotal = Math.floor((endDate.getTime() - timelineStartOfDay.getTime()) / (1000 * 60))
|
||
|
||
// 每小时40px,每分钟40/60 = 2/3 px
|
||
const pixelPerMinute = 40 / 60
|
||
|
||
left = Math.max(0, startMinutesTotal * pixelPerMinute)
|
||
width = Math.max(4, (endMinutesTotal - startMinutesTotal) * pixelPerMinute)
|
||
|
||
return { left, width }
|
||
}
|
||
|
||
// 创建只包含日期的Date对象(忽略时分秒)
|
||
const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
|
||
const endDateOnly = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
|
||
|
||
if (
|
||
props.timelineData &&
|
||
props.currentTimeScale &&
|
||
(props.currentTimeScale === TimelineScale.WEEK ||
|
||
props.currentTimeScale === TimelineScale.MONTH ||
|
||
props.currentTimeScale === TimelineScale.QUARTER ||
|
||
props.currentTimeScale === TimelineScale.YEAR)
|
||
) {
|
||
// 使用timelineData精确定位
|
||
const startPosition = calculatePositionFromTimelineData(
|
||
startDateOnly,
|
||
props.timelineData,
|
||
props.currentTimeScale,
|
||
)
|
||
|
||
// 计算结束位置:为结束日期添加一天来获取正确的结束位置
|
||
const nextDay = new Date(endDateOnly)
|
||
nextDay.setDate(nextDay.getDate() + 1)
|
||
let endPosition = calculatePositionFromTimelineData(
|
||
nextDay,
|
||
props.timelineData,
|
||
props.currentTimeScale,
|
||
)
|
||
|
||
// 如果结束日期+1天超出范围,使用结束日期的位置+一天的宽度
|
||
if (endPosition === startPosition) {
|
||
let dayWidth = 60 / 30 // 默认月视图
|
||
if (props.currentTimeScale === TimelineScale.WEEK) {
|
||
dayWidth = 60 / 7
|
||
} else if (props.currentTimeScale === TimelineScale.QUARTER) {
|
||
dayWidth = 60 / 90
|
||
} else if (props.currentTimeScale === TimelineScale.YEAR) {
|
||
dayWidth = 180 / 182
|
||
}
|
||
endPosition =
|
||
calculatePositionFromTimelineData(
|
||
endDateOnly,
|
||
props.timelineData,
|
||
props.currentTimeScale,
|
||
) + dayWidth
|
||
}
|
||
|
||
left = startPosition
|
||
width = Math.max(endPosition - startPosition, 4)
|
||
} else if (
|
||
props.timelineData &&
|
||
props.currentTimeScale === TimelineScale.DAY
|
||
) {
|
||
// 日视图:使用timelineData精确定位
|
||
const startPosition = calculatePositionFromTimelineData(
|
||
startDateOnly,
|
||
props.timelineData,
|
||
props.currentTimeScale,
|
||
)
|
||
|
||
const nextDay = new Date(endDateOnly)
|
||
nextDay.setDate(nextDay.getDate() + 1)
|
||
let endPosition = calculatePositionFromTimelineData(
|
||
nextDay,
|
||
props.timelineData,
|
||
props.currentTimeScale,
|
||
)
|
||
|
||
if (endPosition === startPosition) {
|
||
endPosition = calculatePositionFromTimelineData(
|
||
endDateOnly,
|
||
props.timelineData,
|
||
props.currentTimeScale,
|
||
) + 30 // 日视图每天30px
|
||
}
|
||
|
||
left = startPosition
|
||
width = Math.max(endPosition - startPosition, 4)
|
||
} else {
|
||
// 其他情况:基于日期的简单计算
|
||
const startDiff = Math.floor(
|
||
(startDateOnly.getTime() - props.startDate.getTime()) / (1000 * 60 * 60 * 24),
|
||
)
|
||
|
||
const timeDiffMs = endDateOnly.getTime() - startDateOnly.getTime()
|
||
const daysDiff = Math.round(timeDiffMs / (1000 * 60 * 60 * 24))
|
||
const duration = daysDiff === 0 ? 1 : daysDiff + 1
|
||
|
||
left = startDiff * props.dayWidth
|
||
width = duration * props.dayWidth
|
||
}
|
||
|
||
return { left: Math.max(0, left), width: Math.max(4, width) }
|
||
}
|
||
|
||
// 辅助函数:将日期转换为只包含年月日的本地Date对象
|
||
function toLocalDateOnly(date: Date | string): Date {
|
||
if (typeof date === 'string') {
|
||
// 解析字符串日期
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||
const [year, month, day] = date.split('-').map(Number)
|
||
return new Date(year, month - 1, day)
|
||
}
|
||
if (/^\d{4}-\d{2}-\d{2}T/.test(date)) {
|
||
// ISO格式
|
||
const d = new Date(date)
|
||
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||
}
|
||
}
|
||
|
||
if (date instanceof Date) {
|
||
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||
}
|
||
|
||
// 兜底
|
||
const d = new Date(date)
|
||
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||
}
|
||
|
||
// 从timelineData计算日期位置(与TaskBar使用相同的函数)
|
||
function calculatePositionFromTimelineData(
|
||
targetDate: Date,
|
||
timelineData: Array<any>,
|
||
timeScale: TimelineScale,
|
||
): number {
|
||
let cumulativePosition = 0
|
||
|
||
for (const periodData of timelineData) {
|
||
if (timeScale === TimelineScale.DAY) {
|
||
const days = periodData.days || []
|
||
|
||
for (let i = 0; i < days.length; i++) {
|
||
const dayData = days[i]
|
||
const dayDate = toLocalDateOnly(dayData.date)
|
||
|
||
if (
|
||
dayDate.getFullYear() === targetDate.getFullYear() &&
|
||
dayDate.getMonth() === targetDate.getMonth() &&
|
||
dayDate.getDate() === targetDate.getDate()
|
||
) {
|
||
return cumulativePosition + i * 30
|
||
}
|
||
}
|
||
|
||
cumulativePosition += days.length * 30
|
||
} else if (timeScale === TimelineScale.QUARTER) {
|
||
const quarters = periodData.quarters || []
|
||
|
||
for (const quarter of quarters) {
|
||
const quarterStart = toLocalDateOnly(quarter.startDate)
|
||
const quarterEnd = toLocalDateOnly(quarter.endDate)
|
||
|
||
if (targetDate >= quarterStart && targetDate <= quarterEnd) {
|
||
const quarterWidth = 60
|
||
const daysInQuarter = Math.ceil(
|
||
(quarterEnd.getTime() - quarterStart.getTime()) / (1000 * 60 * 60 * 24),
|
||
)
|
||
const dayWidth = quarterWidth / daysInQuarter
|
||
const dayInQuarter = Math.ceil(
|
||
(targetDate.getTime() - quarterStart.getTime()) / (1000 * 60 * 60 * 24),
|
||
)
|
||
return cumulativePosition + dayInQuarter * dayWidth
|
||
}
|
||
|
||
cumulativePosition += 60
|
||
}
|
||
} else if (timeScale === TimelineScale.WEEK) {
|
||
const weeks = periodData.weeks || []
|
||
|
||
for (const week of weeks) {
|
||
const weekStart = toLocalDateOnly(week.weekStart)
|
||
const weekEnd = toLocalDateOnly(week.weekEnd)
|
||
|
||
if (targetDate >= weekStart && targetDate <= weekEnd) {
|
||
const weekWidth = 60
|
||
const daysInWeek = Math.ceil(
|
||
(weekEnd.getTime() - weekStart.getTime()) / (1000 * 60 * 60 * 24),
|
||
) + 1
|
||
const dayWidth = weekWidth / daysInWeek
|
||
const dayInWeek = Math.ceil(
|
||
(targetDate.getTime() - weekStart.getTime()) / (1000 * 60 * 60 * 24),
|
||
)
|
||
return cumulativePosition + dayInWeek * dayWidth
|
||
}
|
||
|
||
cumulativePosition += 60
|
||
}
|
||
} else if (timeScale === TimelineScale.MONTH) {
|
||
const monthStart = new Date(periodData.year, periodData.month - 1, 1)
|
||
const monthEnd = new Date(periodData.year, periodData.month, 0)
|
||
|
||
if (targetDate >= monthStart && targetDate <= monthEnd) {
|
||
const monthWidth = 60
|
||
const daysInMonth = periodData.monthData?.dayCount || 30
|
||
const dayWidth = monthWidth / daysInMonth
|
||
const dayInMonth = targetDate.getDate()
|
||
return cumulativePosition + (dayInMonth - 1) * dayWidth
|
||
}
|
||
|
||
cumulativePosition += 60
|
||
} else if (timeScale === TimelineScale.YEAR) {
|
||
const halfYears = periodData.halfYears || []
|
||
|
||
for (const halfYear of halfYears) {
|
||
const halfYearStart = toLocalDateOnly(halfYear.startDate)
|
||
const halfYearEnd = toLocalDateOnly(halfYear.endDate)
|
||
|
||
if (targetDate >= halfYearStart && targetDate <= halfYearEnd) {
|
||
const halfYearWidth = 180
|
||
const daysInHalfYear = Math.ceil(
|
||
(halfYearEnd.getTime() - halfYearStart.getTime()) / (1000 * 60 * 60 * 24),
|
||
) + 1
|
||
const dayWidth = halfYearWidth / daysInHalfYear
|
||
const dayInHalfYear = Math.ceil(
|
||
(targetDate.getTime() - halfYearStart.getTime()) / (1000 * 60 * 60 * 24),
|
||
)
|
||
return cumulativePosition + dayInHalfYear * dayWidth
|
||
}
|
||
|
||
cumulativePosition += 180
|
||
}
|
||
}
|
||
}
|
||
|
||
return cumulativePosition
|
||
}
|
||
|
||
// v1.9.4 P1优化 - 增量重绘:只重绘发生变化的冲突区域
|
||
function renderConflictsIncremental() {
|
||
const canvas = canvasRef.value
|
||
if (!canvas) return
|
||
|
||
const ctx = canvas.getContext('2d')
|
||
if (!ctx) return
|
||
|
||
// 检测变化的区域
|
||
const { added, removed, changed } = detectChangedZones(
|
||
previousConflictZones.value,
|
||
conflictZones.value,
|
||
)
|
||
|
||
// 如果变化区域很多(> 50%),直接全量重绘更高效
|
||
const totalZones = conflictZones.value.length
|
||
const changedCount = added.length + removed.length + changed.length
|
||
|
||
if (totalZones === 0 || changedCount > totalZones * 0.5) {
|
||
renderConflicts()
|
||
previousConflictZones.value = [...conflictZones.value]
|
||
return
|
||
}
|
||
|
||
// 增量重绘:只处理变化的区域
|
||
|
||
// 1. 清除被移除的区域
|
||
for (const zone of removed) {
|
||
if (zone.left !== undefined && zone.width !== undefined) {
|
||
ctx.clearRect(zone.left - 2, 0, zone.width + 4, canvas.height)
|
||
}
|
||
}
|
||
|
||
// 2. 重绘新增的区域
|
||
for (const zone of added) {
|
||
drawConflictZone(ctx, zone)
|
||
}
|
||
|
||
// 3. 重绘变化的区域(先清除再绘制)
|
||
for (const zone of changed) {
|
||
if (zone.left !== undefined && zone.width !== undefined) {
|
||
ctx.clearRect(zone.left - 2, 0, zone.width + 4, canvas.height)
|
||
drawConflictZone(ctx, zone)
|
||
}
|
||
}
|
||
|
||
previousConflictZones.value = [...conflictZones.value]
|
||
}
|
||
|
||
// v1.9.4 检测发生变化的冲突区域
|
||
function detectChangedZones(
|
||
oldZones: ConflictZone[],
|
||
newZones: ConflictZone[],
|
||
): {
|
||
added: ConflictZone[]
|
||
removed: ConflictZone[]
|
||
changed: ConflictZone[]
|
||
} {
|
||
const added: ConflictZone[] = []
|
||
const removed: ConflictZone[] = []
|
||
const changed: ConflictZone[] = []
|
||
|
||
// 为快速查找创建 Map
|
||
const oldZonesMap = new Map(
|
||
oldZones.map(z => [`${z.startDate.getTime()}-${z.endDate.getTime()}`, z]),
|
||
)
|
||
const newZonesMap = new Map(
|
||
newZones.map(z => [`${z.startDate.getTime()}-${z.endDate.getTime()}`, z]),
|
||
)
|
||
|
||
// 查找新增和变化的区域
|
||
for (const [key, newZone] of newZonesMap) {
|
||
const oldZone = oldZonesMap.get(key)
|
||
if (!oldZone) {
|
||
added.push(newZone)
|
||
} else if (
|
||
oldZone.totalPercent !== newZone.totalPercent ||
|
||
oldZone.level !== newZone.level ||
|
||
oldZone.tasks.length !== newZone.tasks.length
|
||
) {
|
||
changed.push(newZone)
|
||
}
|
||
}
|
||
|
||
// 查找被移除的区域
|
||
for (const [key, oldZone] of oldZonesMap) {
|
||
if (!newZonesMap.has(key)) {
|
||
removed.push(oldZone)
|
||
}
|
||
}
|
||
|
||
return { added, removed, changed }
|
||
}
|
||
|
||
// v1.9.4 绘制单个冲突区域(从 renderConflicts 提取)
|
||
function drawConflictZone(ctx: CanvasRenderingContext2D, zone: ConflictZone) {
|
||
// v1.9.6 修复:使用 === undefined 检查,避免 left=0 时被错误跳过
|
||
if (zone.left === undefined || zone.width === undefined || zone.width <= 0) {
|
||
return
|
||
}
|
||
|
||
// 视口裁剪优化
|
||
const canvas = canvasRef.value
|
||
if (!canvas || zone.left + zone.width < 0 || zone.left > canvas.width) {
|
||
return
|
||
}
|
||
|
||
// 绘制纹理背景
|
||
drawTextureBackground(ctx, zone)
|
||
|
||
// 绘制左右边界线
|
||
drawBorders(ctx, zone)
|
||
|
||
// 绘制顶部警告标识
|
||
drawTopWarning(ctx, zone)
|
||
}
|
||
|
||
// 渲染冲突纹理(全量重绘)
|
||
function renderConflicts() {
|
||
const canvas = canvasRef.value
|
||
if (!canvas) {
|
||
return
|
||
}
|
||
|
||
const ctx = canvas.getContext('2d')
|
||
if (!ctx) {
|
||
return
|
||
}
|
||
|
||
// 清空Canvas
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||
|
||
// 绘制所有冲突区域
|
||
for (const zone of conflictZones.value) {
|
||
drawConflictZone(ctx, zone)
|
||
}
|
||
|
||
previousConflictZones.value = [...conflictZones.value]
|
||
}
|
||
|
||
// 清除Canvas内容(用于滚动时清除旧的冲突区域)
|
||
function clearCanvas() {
|
||
const canvas = canvasRef.value
|
||
if (!canvas) {
|
||
return
|
||
}
|
||
|
||
const ctx = canvas.getContext('2d')
|
||
if (!ctx) {
|
||
return
|
||
}
|
||
|
||
// 清空整个Canvas
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||
|
||
// 重置冲突区域列表,强制全量重绘(不清空纹理缓存,可以复用)
|
||
previousConflictZones.value = []
|
||
}
|
||
|
||
// 绘制纹理背景
|
||
function drawTextureBackground(ctx: CanvasRenderingContext2D, zone: ConflictZone) {
|
||
const { left = 0, width = 0, top = 0, height = props.height } = zone
|
||
|
||
// 根据冲突等级选择颜色(降低透明度使其更淡)
|
||
let color: string
|
||
let alpha: number
|
||
|
||
switch (zone.level) {
|
||
case 'light':
|
||
color = 'rgba(255,220,0,' // 浅黄
|
||
alpha = 0.12 // 降低透明度
|
||
break
|
||
case 'medium':
|
||
color = 'rgba(255,165,0,' // 橙色
|
||
alpha = 0.15 // 降低透明度
|
||
break
|
||
case 'severe':
|
||
color = 'rgba(255,69,0,' // 红色
|
||
alpha = 0.18 // 降低透明度
|
||
break
|
||
}
|
||
|
||
// 绘制斜线纹理
|
||
ctx.save()
|
||
ctx.fillStyle = getTexturePattern(ctx, zone.level) || `${color}${alpha})`
|
||
ctx.fillRect(left, top, width, height)
|
||
ctx.restore()
|
||
}
|
||
|
||
// 获取或创建纹理pattern(性能优化)
|
||
function getTexturePattern(ctx: CanvasRenderingContext2D, level: 'light' | 'medium' | 'severe'): CanvasPattern | null {
|
||
// 如果已有缓存,直接返回
|
||
if (texturePatterns.value[level]) {
|
||
return texturePatterns.value[level]
|
||
}
|
||
|
||
// 创建临时Canvas绘制纹理
|
||
const patternCanvas = document.createElement('canvas')
|
||
patternCanvas.width = 10
|
||
patternCanvas.height = 10
|
||
const patternCtx = patternCanvas.getContext('2d')
|
||
if (!patternCtx) return null
|
||
|
||
// 根据等级选择颜色(降低透明度使斜线更淡)
|
||
let color: string
|
||
switch (level) {
|
||
case 'light':
|
||
color = 'rgba(255,220,0,0.25)' // 降低透明度
|
||
break
|
||
case 'medium':
|
||
color = 'rgba(255,165,0,0.3)' // 降低透明度
|
||
break
|
||
case 'severe':
|
||
color = 'rgba(255,69,0,0.35)' // 降低透明度
|
||
break
|
||
}
|
||
|
||
// 绘制45度斜线
|
||
patternCtx.strokeStyle = color
|
||
patternCtx.lineWidth = 1 // 使用细线条
|
||
patternCtx.beginPath()
|
||
patternCtx.moveTo(0, 10)
|
||
patternCtx.lineTo(10, 0)
|
||
patternCtx.stroke()
|
||
|
||
// 创建pattern并缓存
|
||
const pattern = ctx.createPattern(patternCanvas, 'repeat')
|
||
texturePatterns.value[level] = pattern
|
||
|
||
return pattern
|
||
}
|
||
|
||
// 绘制边界线
|
||
function drawBorders(ctx: CanvasRenderingContext2D, zone: ConflictZone) {
|
||
const { left = 0, width = 0, top = 0, height = props.height } = zone
|
||
|
||
ctx.save()
|
||
ctx.strokeStyle = '#f56c6c' // 红色边界
|
||
ctx.lineWidth = 2
|
||
|
||
// 左边界
|
||
ctx.beginPath()
|
||
ctx.moveTo(left, top)
|
||
ctx.lineTo(left, top + height)
|
||
ctx.stroke()
|
||
|
||
// 右边界
|
||
ctx.beginPath()
|
||
ctx.moveTo(left + width, top)
|
||
ctx.lineTo(left + width, top + height)
|
||
ctx.stroke()
|
||
|
||
ctx.restore()
|
||
}
|
||
|
||
// 绘制顶部警告标识(只显示三角形icon)
|
||
function drawTopWarning(ctx: CanvasRenderingContext2D, zone: ConflictZone) {
|
||
const { left = 0, top = 0 } = zone
|
||
|
||
const triangleSize = 16
|
||
const padding = 4
|
||
const x = left + padding
|
||
const y = top + padding
|
||
|
||
ctx.save()
|
||
|
||
// 绘制三角形警告图标
|
||
ctx.fillStyle = '#faad14' // 黄色
|
||
ctx.beginPath()
|
||
ctx.moveTo(x + triangleSize / 2, y) // 顶点
|
||
ctx.lineTo(x, y + triangleSize) // 左下角
|
||
ctx.lineTo(x + triangleSize, y + triangleSize) // 右下角
|
||
ctx.closePath()
|
||
ctx.fill()
|
||
|
||
// 绘制白色感叹号
|
||
ctx.fillStyle = '#fff'
|
||
ctx.font = 'bold 12px Arial'
|
||
ctx.textAlign = 'center'
|
||
ctx.textBaseline = 'middle'
|
||
ctx.fillText('!', x + triangleSize / 2, y + triangleSize * 0.6)
|
||
|
||
ctx.restore()
|
||
}
|
||
|
||
// 组件挂载
|
||
onMounted(() => {
|
||
// v1.9.6 修复:资源视图使用虚拟滚动,可见的资源行必然在视口内
|
||
// 直接计算冲突,不需要IntersectionObserver延迟渲染
|
||
nextTick(() => {
|
||
recalculateConflicts()
|
||
})
|
||
})
|
||
|
||
// 组件卸载
|
||
onUnmounted(() => {
|
||
if (scrollTimer) {
|
||
clearTimeout(scrollTimer)
|
||
}
|
||
|
||
// v1.9.4 清理坐标缓存
|
||
coordsCache.clear()
|
||
|
||
// 清理资源
|
||
texturePatterns.value = {
|
||
light: null,
|
||
medium: null,
|
||
severe: null,
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<canvas
|
||
ref="canvasRef"
|
||
class="gantt-conflicts-canvas"
|
||
:width="canvasWidth"
|
||
:height="canvasHeight"
|
||
:style="canvasStyle"
|
||
></canvas>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.gantt-conflicts-canvas {
|
||
display: block;
|
||
background-color: transparent;
|
||
}
|
||
</style>
|