Files
jordium-gantt-vue3/src/components/GanttChart.vue
T

3239 lines
98 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onUnmounted, onMounted, computed, watch, nextTick, useSlots, provide } from 'vue'
import type { StyleValue } from 'vue'
import TaskList from './TaskList/TaskList.vue'
import Timeline from './Timeline.vue'
import GanttToolbar from './GanttToolbar.vue'
import TaskDrawer from './TaskDrawer.vue'
import MilestoneDialog from './MilestoneDialog.vue'
import { useI18n, setCustomMessages } from '../composables/useI18n'
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
import { moveTask } from '../utils/taskTreeUtils'
import jsPDF from 'jspdf'
import html2canvas from 'html2canvas'
import type { Task } from '../models/classes/Task'
import type { Milestone } from '../models/classes/Milestone'
import { useTaskListContextMenu } from './TaskList/composables/taskList/useTaskListContextMenu'
import { useTaskBarContextMenu } from './Timeline/composables/useTaskBarContextMenu'
import type { ToolbarConfig } from '../models/configs/ToolbarConfig'
import type { TaskListConfig } from '../models/configs/TaskListConfig'
import {
DEFAULT_TASK_LIST_WIDTH,
DEFAULT_TASK_LIST_MIN_WIDTH,
DEFAULT_TASK_LIST_MAX_WIDTH,
parseWidthValue,
} from '../models/configs/TaskListConfig'
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
import { TimelineScale, SCALE_CONFIGS } from '../models/types/TimelineScale'
import { useMessage } from '../composables/useMessage'
const props = withDefaults(defineProps<Props>(), {
tasks: () => [],
milestones: () => [],
useDefaultDrawer: true,
useDefaultMilestoneDialog: true,
toolbarConfig: () => ({}),
showToolbar: true,
onTodayLocate: undefined,
onExportCsv: undefined,
onExportPdf: undefined,
onLanguageChange: undefined,
onThemeChange: undefined,
onFullscreenChange: undefined,
onExpandAll: undefined,
onCollapseAll: undefined,
localeMessages: undefined,
workingHours: () => ({
morning: { start: 8, end: 11 },
afternoon: { start: 13, end: 17 },
}),
taskListConfig: undefined,
taskListColumnRenderMode: 'default',
taskBarConfig: undefined,
autoSortByStartDate: false,
allowDragAndResize: true,
enableTaskRowMove: false,
assigneeOptions: () => [],
taskListRowClassName: undefined,
taskListRowStyle: undefined,
enableTaskListContextMenu: true,
enableTaskBarContextMenu: true,
fullscreen: false,
expandAll: true,
locale: 'zh-CN',
timeScale: 'week',
theme: 'light',
})
const emit = defineEmits([
'task-click',
'task-double-click',
'taskbar-drag-end',
'taskbar-resize-end',
'milestone-drag-end',
'milestone-double-click',
'timer-started',
'timer-stopped',
'predecessor-added',
'successor-added',
'task-deleted',
'task-added',
'task-updated',
'task-collapse-change', // 任务折叠状态变化事件
'link-deleted', // 链接删除事件
// 工具栏事件
'add-task',
'add-milestone',
// 里程碑事件
'milestone-saved',
'milestone-deleted',
'milestone-icon-changed',
// 任务关系事件
'add-predecessor',
'add-successor',
// TaskRow拖拽事件
'task-row-moved',
])
const { showMessage } = useMessage()
const slots = useSlots()
// 提供 slots 给子组件(TaskList 和 TaskRow
provide('gantt-column-slots', slots)
// 提供右键菜单配置给子组件
provide('enable-task-list-context-menu', computed(() => props.enableTaskListContextMenu))
provide('enable-task-bar-context-menu', computed(() => props.enableTaskBarContextMenu))
// 使用声明式右键菜单 composables
const { hasDeclarativeContextMenu: hasDeclarativeTaskListContextMenu, declarativeContextMenu: declarativeTaskListContextMenu } =
useTaskListContextMenu(slots)
const { hasDeclarativeContextMenu: hasDeclarativeTaskBarContextMenu, declarativeContextMenu: declarativeTaskBarContextMenu } =
useTaskBarContextMenu(slots)
// 计算是否使用自定义 TaskList 右键菜单(声明式方式)
const hasTaskListContextMenuSlot = computed(() => hasDeclarativeTaskListContextMenu.value)
provide('task-list-context-menu-slot', hasTaskListContextMenuSlot)
provide('declarative-task-list-context-menu', declarativeTaskListContextMenu)
// 计算是否使用自定义 TaskBar 右键菜单(声明式方式)
const hasTaskBarContextMenuSlot = computed(() => hasDeclarativeTaskBarContextMenu.value)
provide('task-bar-context-menu-slot', hasTaskBarContextMenuSlot)
provide('declarative-task-bar-context-menu', declarativeTaskBarContextMenu)
interface Props {
// 任务数据
tasks?: Task[]
// 里程碑数据
milestones?: Task[]
// 是否使用默认的TaskDrawer
useDefaultDrawer?: boolean
// 是否使用默认的MilestoneDialog
useDefaultMilestoneDialog?: boolean
// 工具栏配置
toolbarConfig?: ToolbarConfig
// 是否显示工具栏
showToolbar?: boolean
// 工具栏事件处理器
onTodayLocate?: () => void
onExportCsv?: () => boolean | void
onExportPdf?: () => void
onLanguageChange?: (lang: 'zh-CN' | 'en-US') => void
onThemeChange?: (isDark: boolean) => void
onFullscreenChange?: (isFullscreen: boolean) => void
onExpandAll?: () => void
onCollapseAll?: () => void
/**
* 自定义多语言(国际化)配置,支持多语言扩展。
* 例如:
* localeMessages={{
* 'zh-CN': { department: '部门', departmentCode: '部门编号' },
* 'en-US': { department: 'Department', departmentCode: 'Department Code' }
* }}
* 支持嵌套对象(如 csvHeaders、taskTypeMap 等)。
* 仅在组件初始化时合并,运行时变更会自动响应。
*/
localeMessages?: Partial<{
'zh-CN'?: Partial<import('../composables/useI18n').Messages['zh-CN']>
'en-US'?: Partial<import('../composables/useI18n').Messages['en-US']>
}>
// 工作时间配置
workingHours?: {
morning?: { start: number; end: number } // 上午工作时间,如 { start: 8, end: 11 }
afternoon?: { start: number; end: number } // 下午工作时间,如 { start: 13, end: 17 }
}
// 任务列表配置
taskListConfig?: TaskListConfig
// 任务列表列渲染模式:'default' 使用 taskListConfig.columns 配置,'declarative' 使用声明式 <task-list-column> 标签
taskListColumnRenderMode?: 'default' | 'declarative'
// TaskBar 配置
taskBarConfig?: TaskBarConfig
// 是否启用自动排序(根据开始时间排序任务)
autoSortByStartDate?: boolean
// 是否允许拖拽和拉伸(默认为 true)
allowDragAndResize?: boolean
// 是否启用TaskRow拖拽移动功能(默认为 false)
enableTaskRowMove?: boolean
// 指派人员选项列表(用于TaskDrawer中的assignee下拉菜单)
// 格式:{ key?: string | number, value: string | number, label: string }
// key 为可选项,若不存在则使用 value 作为选项的唯一标识
assigneeOptions?: Array<{ key?: string | number; value: string | number; label: string }>
// 任务行自定义样式类名,支持字符串或函数
// 函数形式:(row: Task, rowIndex: number) => string
taskListRowClassName?: string | ((row: Task, rowIndex: number) => string)
// 任务行自定义样式,支持对象或函数,优先级高于 taskListRowClassName
// 函数形式:(row: Task, rowIndex: number) => StyleValue
taskListRowStyle?: StyleValue | ((row: Task, rowIndex: number) => StyleValue)
// 是否启用 TaskListTaskRow)右键菜单(默认为 true
// 当设置为 true 且未声明 TaskListContextMenu 组件时,使用系统默认菜单
// 当设置为 true 且声明了 TaskListContextMenu 组件时,使用自定义菜单
// 当设置为 false 时,无论是否声明组件,TaskRow 右键菜单都失效
enableTaskListContextMenu?: boolean
// 是否启用 TaskBar 右键菜单(默认为 true
// 当设置为 true 且未声明 TaskBarContextMenu 组件时,使用系统默认菜单
// 当设置为 true 且声明了 TaskBarContextMenu 组件时,使用自定义菜单
// 当设置为 false 时,无论是否声明组件,TaskBar 右键菜单都失效
enableTaskBarContextMenu?: boolean
// 全屏状态控制(响应式)
fullscreen?: boolean
// 展开/收起所有任务(响应式)
expandAll?: boolean
// 语言设置(响应式)
locale?: 'zh-CN' | 'en-US'
// 时间刻度(响应式)
timeScale?: TimelineScale
// 主题模式(响应式)
theme?: 'light' | 'dark'
}
// TaskList的固定总长度(所有列的最小宽度之和 + 边框等额外空间)
// 列宽: 300+120+120+140+140+100+100+100 = 1120px
// 边框: 7个列间边框 * 1px = 7px
// 滚动条预留: 20px
// 额外边距: 13px (task-list-header的左边距3px + 其他10px预留)
// 总计: 1160px (已在 TaskListConfig 中定义)
// 甘特图容器宽度
const ganttRootRef = ref<HTMLElement | null>(null)
const ganttContainerWidth = ref(1920) // 默认使用常见的屏幕宽度作为初始值
// 监听容器宽度变化
// 节流函数工具
const throttle = <T extends (...args: unknown[]) => unknown>(func: T, delay: number): T => {
let lastCall = 0
let timeoutId: number | null = null
return ((...args: Parameters<T>) => {
const now = Date.now()
const remaining = delay - (now - lastCall)
if (timeoutId) {
clearTimeout(timeoutId)
}
if (remaining <= 0) {
lastCall = now
func(...args)
} else {
timeoutId = window.setTimeout(() => {
lastCall = Date.now()
func(...args)
timeoutId = null
}, remaining)
}
}) as T
}
const updateContainerWidth = () => {
if (ganttRootRef.value) {
const newWidth = ganttRootRef.value.clientWidth
if (newWidth !== ganttContainerWidth.value) {
ganttContainerWidth.value = newWidth
// 容器宽度变化时,重新计算 TaskList 的宽度限制
ganttPanelLeftMinWidth.value = getTaskListMinWidth()
taskListBodyWidth.value = getTaskListMaxWidth()
taskListBodyProposedWidth.value = getTaskListMaxWidth()
taskListBodyWidthLimit.value = getTaskListMaxWidth()
// 确保当前宽度在新的限制范围内
const adjustedWidth = checkWidthLimits(leftPanelWidth.value)
if (adjustedWidth !== leftPanelWidth.value) {
leftPanelWidth.value = adjustedWidth
}
}
}
}
// 创建节流版本的 updateContainerWidth,避免频繁调用
const throttledUpdateContainerWidth = throttle(updateContainerWidth, 100)
onMounted(() => {
updateContainerWidth()
// 使用节流版本监听窗口大小变化
window.addEventListener('resize', throttledUpdateContainerWidth)
})
onUnmounted(() => {
window.removeEventListener('resize', throttledUpdateContainerWidth)
})
// TaskList最小宽度,支持通过taskListConfig配置(支持像素和百分比)
const getTaskListMinWidth = () => {
const configMinWidth = parseWidthValue(
props.taskListConfig?.minWidth,
ganttContainerWidth.value,
DEFAULT_TASK_LIST_MIN_WIDTH,
)
return Math.max(configMinWidth, DEFAULT_TASK_LIST_MIN_WIDTH) // 确保不小于280px
}
// TaskList最大宽度,支持通过taskListConfig配置(支持像素和百分比)
const getTaskListMaxWidth = () => {
return parseWidthValue(
props.taskListConfig?.maxWidth,
ganttContainerWidth.value,
DEFAULT_TASK_LIST_MAX_WIDTH,
)
}
// TaskList默认宽度,支持通过taskListConfig配置(支持像素和百分比)
const getTaskListDefaultWidth = () => {
return parseWidthValue(
props.taskListConfig?.defaultWidth,
ganttContainerWidth.value,
DEFAULT_TASK_LIST_WIDTH,
)
}
const taskListBodyWidth = ref(getTaskListMaxWidth()) // TaskList默认宽度
const ganttPanelLeftMinWidth = ref(getTaskListMinWidth()) // 左侧面板最小宽度
const ganttPanelLeftCurrentWidth = ref(getTaskListMinWidth()) // 当前左侧面板宽度
const taskListBodyProposedWidth = ref(getTaskListMaxWidth())
const taskListBodyWidthLimit = ref(getTaskListMaxWidth())
// 使用taskListConfig中的默认宽度,如果未配置则使用320px
const leftPanelWidth = ref(getTaskListDefaultWidth())
// 简化的限制检查函数:直接基于面板实际宽度判断
const checkWidthLimits = (proposedLeftWidth: number): number => {
if (proposedLeftWidth < ganttPanelLeftMinWidth.value) {
return ganttPanelLeftMinWidth.value
} else if (proposedLeftWidth > taskListBodyWidthLimit.value) {
return taskListBodyWidthLimit.value
} else {
return Math.max(ganttPanelLeftMinWidth.value, proposedLeftWidth)
}
}
// 监听taskListConfig变化,更新相关配置
watch(
() => props.taskListConfig,
newConfig => {
if (newConfig) {
// 更新默认宽度(支持像素和百分比)
if (newConfig.defaultWidth !== undefined) {
leftPanelWidth.value = getTaskListDefaultWidth()
}
// 更新最小最大宽度限制
ganttPanelLeftMinWidth.value = getTaskListMinWidth()
taskListBodyWidth.value = getTaskListMaxWidth()
taskListBodyProposedWidth.value = getTaskListMaxWidth()
taskListBodyWidthLimit.value = getTaskListMaxWidth()
ganttPanelLeftCurrentWidth.value = getTaskListMinWidth()
// 确保当前宽度在新的限制范围内
const adjustedWidth = checkWidthLimits(leftPanelWidth.value)
if (adjustedWidth !== leftPanelWidth.value) {
leftPanelWidth.value = adjustedWidth
}
}
},
{ immediate: false, deep: true },
)
// Timeline组件的引用
const timelineRef = ref<InstanceType<typeof Timeline> | null>(null)
// Timeline容器宽度(用于计算需要多少列才能铺满)
const timelineContainerWidth = ref<number>(0)
// 任务拖拽/拉伸触发器(用于触发timeline范围重新计算)
const updateTaskTrigger = ref<number>(0)
// 监听props.tasks变化,自动触发Timeline更新
// 这对于TaskRow移动等操作很重要,因为外部更新tasks后需要通知Timeline重新渲染
watch(
() => props.tasks,
() => {
updateTaskTrigger.value++
},
{ deep: true },
)
// 时间刻度状态
const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
// 计算是否显示关闭按钮
const showCloseButton = computed(() => {
const taskId = timelineRef.value?.highlightedTaskId
return taskId !== null && taskId !== undefined
})
watch(
() => timelineRef.value,
newTimeline => {
if (newTimeline) {
newTimeline.updateTimeScale(currentTimeScale.value)
}
},
)
const dragging = ref(false)
function onMouseDown(e: MouseEvent) {
// 检查事件目标是否是task-list-toggle按钮或其子元素
const target = e.target as HTMLElement
if (target.closest('.task-list-toggle')) {
// 如果点击的是toggle按钮,不执行拖拽逻辑
return
}
// 阻止默认行为和事件冒泡,防止选择文本和触发其他事件
e.preventDefault()
e.stopPropagation()
dragging.value = true
// ⚠️ 关键修复:初始化拖拽起始点和宽度
const startX = e.clientX
const startWidth = leftPanelWidth.value
// 获取task-list-body的宽度
const taskListBody = document.querySelector('.task-list-body')
if (!taskListBody) return
const taskListBodyRect = taskListBody.getBoundingClientRect()
taskListBodyWidth.value = taskListBodyRect.width
// 获取当前inner windows宽度, 右侧预留20%的空间
taskListBodyProposedWidth.value = window.innerWidth * 0.8 - 6 // 减去splitter宽度
// 获取左侧面板的最小宽度
taskListBodyWidthLimit.value = Math.min(taskListBodyProposedWidth.value,
taskListBodyWidthLimit.value)
// 广播拖拽开始事件,通知其他组件暂停悬停效果
window.dispatchEvent(new CustomEvent('splitter-drag-start'))
// 在拖拽期间禁用页面选择和所有指针事件
document.body.style.userSelect = 'none'
document.body.style.webkitUserSelect = 'none'
document.body.style.cursor = 'col-resize'
document.body.style.pointerEvents = 'none' // 禁止所有指针事件
// 全局事件拦截器:在捕获阶段拦截所有事件(除了 mousemove 和 mouseup
const blockAllEvents = (ev: Event) => {
if (ev.type !== 'mousemove' && ev.type !== 'mouseup') {
ev.preventDefault()
ev.stopPropagation()
ev.stopImmediatePropagation()
}
}
// 在捕获阶段添加事件监听,确保最先拦截
document.addEventListener('mousedown', blockAllEvents, { capture: true })
document.addEventListener('click', blockAllEvents, { capture: true })
document.addEventListener('dblclick', blockAllEvents, { capture: true })
document.addEventListener('mouseover', blockAllEvents, { capture: true })
document.addEventListener('mouseout', blockAllEvents, { capture: true })
document.addEventListener('mouseenter', blockAllEvents, { capture: true })
document.addEventListener('mouseleave', blockAllEvents, { capture: true })
document.addEventListener('wheel', blockAllEvents, { capture: true, passive: false })
document.addEventListener('contextmenu', blockAllEvents, { capture: true })
function onMouseMove(ev: MouseEvent) {
if (!dragging.value) return
// 强制阻止所有默认行为和事件传播
ev.preventDefault()
ev.stopPropagation()
ev.stopImmediatePropagation()
const delta = ev.clientX - startX
const proposedWidth = startWidth + delta
// 直接使用面板宽度限制检查,无需复杂的坐标计算
const finalWidth = checkWidthLimits(proposedWidth)
leftPanelWidth.value = finalWidth
}
function onMouseUp() {
dragging.value = false
// 移除全局事件拦截器
document.removeEventListener('mousedown', blockAllEvents, { capture: true })
document.removeEventListener('click', blockAllEvents, { capture: true })
document.removeEventListener('dblclick', blockAllEvents, { capture: true })
document.removeEventListener('mouseover', blockAllEvents, { capture: true })
document.removeEventListener('mouseout', blockAllEvents, { capture: true })
document.removeEventListener('mouseenter', blockAllEvents, { capture: true })
document.removeEventListener('mouseleave', blockAllEvents, { capture: true })
document.removeEventListener('wheel', blockAllEvents, { capture: true })
document.removeEventListener('contextmenu', blockAllEvents, { capture: true })
// 广播拖拽结束事件,通知其他组件恢复悬停效果
window.dispatchEvent(new CustomEvent('splitter-drag-end'))
// 恢复页面选择、光标和指针事件
document.body.style.userSelect = ''
document.body.style.webkitUserSelect = ''
document.body.style.cursor = ''
document.body.style.pointerEvents = ''
taskListBodyWidth.value = getTaskListMaxWidth() // TaskList默认宽度
ganttPanelLeftMinWidth.value = getTaskListMinWidth() // 左侧面板最小宽度
taskListBodyProposedWidth.value = getTaskListMaxWidth()
taskListBodyWidthLimit.value = getTaskListMaxWidth()
ganttPanelLeftCurrentWidth.value = getTaskListMinWidth() // 当前左侧面板宽度
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
}
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
}
// TaskList显示/隐藏状态管理
const isTaskListVisible = ref(true)
// 动画状态管理
const isAnimating = ref(false)
const animationClass = ref('')
// 切换TaskList显示状态
const toggleTaskList = () => {
// 如果正在动画中,忽略点击
if (isAnimating.value) return
// 触发撞击动画
isAnimating.value = true
if (isTaskListVisible.value) {
// 收起时:左侧撞击动画
animationClass.value = 'impact-left'
} else {
// 展开时:右侧撞击动画
animationClass.value = 'impact-right'
}
// 在动画进行到一半时切换状态
setTimeout(() => {
isTaskListVisible.value = !isTaskListVisible.value
}, 200)
// 动画结束后清理状态,并通知Timeline容器变化
setTimeout(() => {
isAnimating.value = false
animationClass.value = ''
// 手动切换TaskList后,通知Timeline重新计算半圆
nextTick(() => {
window.dispatchEvent(
new CustomEvent('timeline-container-resized', {
detail: { source: 'manual-task-list-toggle' },
}),
)
})
}, 400)
}
// 监听Timeline的TaskList切换事件
const handleToggleTaskList = (event: CustomEvent) => {
isTaskListVisible.value = event.detail
// TaskList切换会改变Timeline容器宽度,需要通知Timeline重新计算半圆
// 派发事件通知Timeline容器宽度发生了变化
nextTick(() => {
window.dispatchEvent(
new CustomEvent('timeline-container-resized', {
detail: { source: 'task-list-toggle' },
}),
)
})
}
// --- 事件链路:监听 Timeline 传递上来的拖拽/拉伸事件,更新数据并通过 emit 暴露 ---
function handleTaskBarDragEnd(event: CustomEvent) {
const updatedTask = event.detail
// 更新 props.tasks 中的任务数据
if (props.tasks) {
updateTaskInTree(props.tasks, updatedTask)
}
updateTaskTrigger.value++
emit('taskbar-drag-end', updatedTask)
}
function handleTaskBarResizeEnd(event: CustomEvent) {
const updatedTask = event.detail
// 更新 props.tasks 中的任务数据
if (props.tasks) {
updateTaskInTree(props.tasks, updatedTask)
}
updateTaskTrigger.value++
emit('taskbar-resize-end', updatedTask)
}
function handleMilestoneDragEnd(event: CustomEvent) {
const updatedMilestone = event.detail
updateTaskTrigger.value++
emit('milestone-drag-end', updatedMilestone)
}
// ResizeObserver 引用,用于清理
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
window.addEventListener('taskbar-drag-end', handleTaskBarDragEnd as EventListener)
window.addEventListener('taskbar-resize-end', handleTaskBarResizeEnd as EventListener)
window.addEventListener('milestone-drag-end', handleMilestoneDragEnd as EventListener)
// 监听 timeline 容器宽度变化
nextTick(() => {
// 监听右侧面板(timeline 的可视容器)的宽度
const rightPanel = document.querySelector('.gantt-panel-right')
if (rightPanel) {
// 初始化宽度
timelineContainerWidth.value = rightPanel.clientWidth
// 使用 ResizeObserver 监听宽度变化
resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
timelineContainerWidth.value = entry.contentRect.width
}
})
resizeObserver.observe(rightPanel)
}
})
})
onUnmounted(() => {
window.removeEventListener('taskbar-drag-end', handleTaskBarDragEnd as EventListener)
window.removeEventListener('taskbar-resize-end', handleTaskBarResizeEnd as EventListener)
window.removeEventListener('milestone-drag-end', handleMilestoneDragEnd as EventListener)
// 清理 ResizeObserver
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
})
// 处理TaskList的任务折叠状态变化
const handleTaskCollapseChange = (task: Task) => {
// 递归查找并更新原始数据中的任务折叠状态
const updateTaskCollapsedState = (
tasks: Task[],
targetId: number,
collapsed: boolean,
): boolean => {
for (const t of tasks) {
if (t.id === targetId) {
t.collapsed = collapsed
return true
}
if (t.children && t.children.length > 0) {
if (updateTaskCollapsedState(t.children, targetId, collapsed)) {
return true
}
}
}
return false
}
// 更新原始任务数据的折叠状态
if (props.tasks) {
updateTaskCollapsedState(props.tasks, task.id, task.collapsed ?? false)
}
// 触发Timeline重新计算
updateTaskTrigger.value++
// 向外发出折叠状态变化事件
emit('task-collapse-change', task)
}
// 全部展开任务
const handleExpandAll = () => {
if (props.onExpandAll && typeof props.onExpandAll === 'function') {
props.onExpandAll()
} else {
// 默认行为:递归展开所有任务
const expandAllTasks = (tasks: Task[]): void => {
tasks.forEach(task => {
if (task.children && task.children.length > 0) {
task.collapsed = false
expandAllTasks(task.children)
}
})
}
if (props.tasks) {
expandAllTasks(props.tasks)
// 触发Timeline重新计算
updateTaskTrigger.value++
}
}
}
// 处理TaskRow移动事件
const handleTaskRowMoved = (payload: {
draggedTask: Task
targetTask: Task
position: 'after' | 'child'
oldParent: Task | null
newParent: Task | null
}) => {
const { draggedTask, targetTask, position } = payload
if (!props.tasks) return
// 在 GanttChart 层面执行移动操作,直接修改原始 props.tasks
// 这样确保修改的是正确的数据源,而不是计算属性的临时结果
const result = moveTask(props.tasks, draggedTask.id, targetTask.id, position)
if (!result) {
return
}
// 强制触发计算属性重新计算
updateTaskTrigger.value++
// 通知 TaskList 更新父级任务数据
nextTick(() => {
window.dispatchEvent(new CustomEvent('task-updated', {
detail: result.movedTask,
}))
})
// 向外传递事件,让外部组件进行额外处理(完全可选)
emit('task-row-moved', {
draggedTask: result.movedTask,
targetTask,
position,
oldParent: result.oldParent,
newParent: result.newParent,
})
}
// 全部折叠任务
const handleCollapseAll = () => {
if (props.onCollapseAll && typeof props.onCollapseAll === 'function') {
props.onCollapseAll()
} else {
// 默认行为:递归折叠所有任务
const collapseAllTasks = (tasks: Task[]): void => {
tasks.forEach(task => {
if (task.children && task.children.length > 0) {
task.collapsed = true
collapseAllTasks(task.children)
}
})
}
if (props.tasks) {
collapseAllTasks(props.tasks)
// 触发Timeline重新计算
updateTaskTrigger.value++
}
}
}
// === 展开/收起相关方法 ===
/**
* 展开所有任务
*/
const expandAllTasks = () => {
handleExpandAll()
}
/**
* 收起所有任务
*/
const collapseAllTasks = () => {
handleCollapseAll()
}
/**
* 切换展开/收起所有任务
*/
const toggleExpandAllTasks = () => {
// 检查是否所有有子任务的任务都已展开
const checkAllExpanded = (tasks: Task[]): boolean => {
for (const task of tasks) {
if (task.children && task.children.length > 0) {
if (task.collapsed) {
return false
}
if (!checkAllExpanded(task.children)) {
return false
}
}
}
return true
}
if (props.tasks) {
const allExpanded = checkAllExpanded(props.tasks)
if (allExpanded) {
collapseAllTasks()
} else {
expandAllTasks()
}
}
}
/**
* 获取当前是否所有任务都已展开
*/
const getIsExpandAll = (): boolean => {
if (!props.tasks || props.tasks.length === 0) {
return true
}
const checkAllExpanded = (tasks: Task[]): boolean => {
for (const task of tasks) {
if (task.children && task.children.length > 0) {
if (task.collapsed) {
return false
}
if (!checkAllExpanded(task.children)) {
return false
}
}
}
return true
}
return checkAllExpanded(props.tasks)
}
// 监听 Props expandAll 变化
watch(
() => props.expandAll,
(newValue) => {
if (newValue !== undefined) {
if (newValue) {
expandAllTasks()
} else {
collapseAllTasks()
}
}
},
{ immediate: true },
)
// 处理TaskDrawer请求任务列表
const handleRequestTaskList = () => {
// 创建扁平化的任务列表,包含所有任务和里程碑
const flatTasks: Task[] = []
// 递归扁平化任务
const flattenTasks = (taskList: Task[]) => {
taskList.forEach(task => {
flatTasks.push(task)
if (task.children && task.children.length > 0) {
flattenTasks(task.children)
}
})
}
// 添加任务数据
if (props.tasks) {
flattenTasks(props.tasks)
}
// 添加里程碑数据
if (props.milestones) {
flatTasks.push(...props.milestones)
}
// 发送任务列表给TaskDrawer
window.dispatchEvent(new CustomEvent('task-list-updated', { detail: flatTasks }))
}
// 当任务数据变化时,通知TaskDrawer更新
const notifyTaskListUpdated = () => {
handleRequestTaskList()
}
// 监听任务数据变化,自动通知TaskDrawer
watch(
[() => props.tasks, () => props.milestones],
() => {
// 延迟一点时间确保数据更新完成
nextTick(() => {
notifyTaskListUpdated()
})
},
{ deep: true, immediate: true },
)
onMounted(() => {
// 监听Timeline的TaskList切换事件
window.addEventListener('toggle-task-list', handleToggleTaskList as EventListener)
// 监听GanttToolbar的全屏切换事件
window.addEventListener('fullscreen-toggle', handleFullscreenToggle as EventListener)
// 监听Timeline的里程碑相关事件
window.addEventListener('milestone-icon-changed', handleMilestoneIconChangeEvent as EventListener)
// 监听里程碑删除和数据变化事件
window.addEventListener('milestone-deleted', handleMilestoneDeleted as EventListener)
window.addEventListener('milestone-data-changed', handleMilestoneDataChanged as EventListener)
// 监听TaskDrawer的任务列表请求
window.addEventListener('request-task-list', handleRequestTaskList as EventListener)
// 监听窗口大小变化
window.addEventListener('resize', handleWindowResize)
// 监听TaskBar的右键菜单事件
window.addEventListener('context-menu', handleTaskContextMenu as EventListener)
nextTick(() => {
if (timelineRef.value && typeof timelineRef.value.scrollToTodayCenter === 'function') {
timelineRef.value.scrollToTodayCenter()
}
})
})
onUnmounted(() => {
dragging.value = false
// 移除事件监听器
window.removeEventListener('toggle-task-list', handleToggleTaskList as EventListener)
window.removeEventListener('fullscreen-toggle', handleFullscreenToggle as EventListener)
window.removeEventListener(
'milestone-icon-changed',
handleMilestoneIconChangeEvent as EventListener,
)
window.removeEventListener('milestone-deleted', handleMilestoneDeleted as EventListener)
window.removeEventListener('milestone-data-changed', handleMilestoneDataChanged as EventListener)
window.removeEventListener('request-task-list', handleRequestTaskList as EventListener)
window.removeEventListener('resize', handleWindowResize)
window.removeEventListener('context-menu', handleTaskContextMenu as EventListener)
})
// 主题状态管理
const currentThemeMode = ref<'light' | 'dark'>('light')
/**
* 设置主题模式
* @param mode 主题模式,默认为 'dark'
*/
const setTheme = (mode?: 'light' | 'dark') => {
const targetMode = mode || 'dark'
currentThemeMode.value = targetMode
document.documentElement.setAttribute('data-theme', targetMode)
}
/**
* 获取当前主题模式
*/
const currentTheme = (): string => {
return currentThemeMode.value
}
// 监听 Props theme 变化
watch(
() => props.theme,
(newTheme) => {
if (newTheme && newTheme !== currentThemeMode.value) {
setTheme(newTheme)
}
},
{ immediate: true },
)
// 全屏状态管理
const isFullscreen = ref(false)
// 多语言支持
const { t, locale: i18nLocale } = useI18n()
const collapseTaskListText = computed(() => t.value.collapseTaskList)
const expandTaskListText = computed(() => t.value.expandTaskList)
// 为TaskList提供层级数据(保持原始层级结构,只扁平化里程碑)
const tasksForTaskList = computed(() => {
// 通过条件判断访问触发器,确保折叠状态变化时重新计算
if (updateTaskTrigger.value >= 0) {
// 触发器起作用,继续执行计算逻辑
}
const result: Task[] = []
// 如果有里程碑,创建里程碑分组行(扁平化显示)
if (props.milestones && props.milestones.length > 0) {
const milestoneGroupRow: Task = {
id: -1,
name: t.value.milestoneGroup,
startDate: '',
endDate: '',
type: 'milestone-group',
children: props.milestones,
isParent: true,
collapsed: false,
}
result.push(milestoneGroupRow)
}
// 添加原始任务数据(完全保持层级结构,不扁平化)
if (props.tasks && props.tasks.length > 0) {
// 根据配置决定是否排序
if (props.autoSortByStartDate) {
// 递归排序函数:根据实际开始时间排序
const sortTasksByStartDate = (tasks: Task[]): Task[] => {
return [...tasks]
.map(task => {
// 递归处理子任务
const sortedTask = { ...task }
if (task.children && task.children.length > 0) {
sortedTask.children = sortTasksByStartDate(task.children)
}
return sortedTask
})
.sort((a, b) => {
// 获取实际开始时间(考虑子任务的最早时间)
const getEarliestStartDate = (task: Task): Date => {
// 如果有子任务,找子任务中的最早时间
if (task.children && task.children.length > 0) {
const childDates = task.children
.map(child => getEarliestStartDate(child))
.filter(date => date.getTime() > 0) // 过滤无效日期
if (childDates.length > 0) {
return new Date(Math.min(...childDates.map(d => d.getTime())))
}
}
// 没有子任务或子任务都没有时间,使用自身时间
return task.startDate ? new Date(task.startDate) : new Date('9999-12-31')
}
const dateA = getEarliestStartDate(a)
const dateB = getEarliestStartDate(b)
// 按时间排序,时间相同时按ID排序
const timeDiff = dateA.getTime() - dateB.getTime()
return timeDiff !== 0 ? timeDiff : a.id - b.id
})
}
// 启用排序:对任务进行递归排序
const sortedTasks = sortTasksByStartDate(props.tasks)
result.push(...sortedTasks)
} else {
// 不排序:直接使用原始任务数据
result.push(...props.tasks)
}
}
return result
})
// 为Timeline提供正确的扁平化数据
const tasksForTimeline = computed(() => {
// 通过条件判断访问触发器,确保折叠状态变化时重新计算
if (updateTaskTrigger.value >= 0) {
// 触发器起作用,继续执行计算逻辑
}
// 创建最终结果数组
const result: Task[] = []
// 1. 处理里程碑数据 - 只显示里程碑分组行,不重复显示里程碑本身
if (props.milestones && props.milestones.length > 0) {
// 只添加里程碑分组行,里程碑本身的显示由TaskBar组件内部处理
const milestoneGroupRow: Task = {
id: -1,
name: t.value.milestoneGroup,
startDate: '',
endDate: '',
type: 'milestone-group',
children: props.milestones,
isParent: true,
collapsed: false,
}
result.push(milestoneGroupRow)
}
// 2. 处理任务数据 - 根据TaskList的折叠状态进行智能扁平化
if (props.tasks && props.tasks.length > 0) {
// 递归获取所有子任务日期范围的工具函数
const getAllChildDates = (children: Task[]): { starts: string[]; ends: string[] } => {
const starts: string[] = []
const ends: string[] = []
children.forEach(child => {
if (child.startDate) starts.push(child.startDate)
if (child.endDate) ends.push(child.endDate)
if (child.children && child.children.length > 0) {
const childDates = getAllChildDates(child.children)
starts.push(...childDates.starts)
ends.push(...childDates.ends)
}
})
return { starts, ends }
}
// 计算父任务的实际时间范围
const calculateParentDateRange = (parentTask: Task): { startDate: string; endDate: string } => {
if (!parentTask.children || parentTask.children.length === 0) {
return {
startDate: parentTask.startDate || '',
endDate: parentTask.endDate || '',
}
}
const { starts, ends } = getAllChildDates(parentTask.children)
if (starts.length === 0 || ends.length === 0) {
return {
startDate: parentTask.startDate || '',
endDate: parentTask.endDate || '',
}
}
// 找到最早的开始日期和最晚的结束日期
const earliestStart = starts.reduce((earliest, current) => {
return new Date(current) < new Date(earliest) ? current : earliest
})
const latestEnd = ends.reduce((latest, current) => {
return new Date(current) > new Date(latest) ? current : latest
})
return { startDate: earliestStart, endDate: latestEnd }
}
// 递归更新任务树中所有父任务的时间范围(从叶子节点开始向上)
const updateParentDateRanges = (tasks: Task[]): Task[] => {
return tasks.map(task => {
let updatedTask = { ...task }
// 先递归更新子任务
if (task.children && task.children.length > 0) {
updatedTask.children = updateParentDateRanges(task.children)
}
// 基于任务类型判断是否为父任务
const isParent =
task.type === 'story' || (updatedTask.children && updatedTask.children.length > 0)
updatedTask.isParent = isParent
// 如果是父任务且有子任务,重新计算时间范围
if (isParent && updatedTask.children && updatedTask.children.length > 0) {
const { startDate, endDate } = calculateParentDateRange(updatedTask)
updatedTask = {
...updatedTask,
startDate,
endDate,
}
}
return updatedTask
})
}
// 智能扁平化:只扁平化未折叠的任务
const smartFlattenTasks = (tasks: Task[], level = 0): Task[] => {
const flattened: Task[] = []
tasks.forEach(task => {
// 添加当前任务(设置层级)
const processedTask: Task = {
...task,
level,
}
flattened.push(processedTask)
// 关键:只有当父任务未折叠时,才添加子任务到Timeline
if (task.children && task.children.length > 0 && !task.collapsed) {
const childTasks = smartFlattenTasks(task.children, level + 1)
flattened.push(...childTasks)
}
})
return flattened
}
// 使用TaskList当前的数据状态进行扁平化
// 重要:确保使用TaskList组件内部的最新状态,包括折叠状态
const currentTasks = tasksForTaskList.value.filter(task => task.type !== 'milestone-group')
// 先更新所有父任务的时间范围(从叶子节点开始向上递归)
const tasksWithUpdatedDates = updateParentDateRanges(currentTasks)
// 因为tasksForTaskList返回的是props.tasks的副本,我们需要确保Timeline能看到最新的折叠状态
// 通过触发器强制重新计算来实现状态同步
const flattenedTasks = smartFlattenTasks(tasksWithUpdatedDates)
result.push(...flattenedTasks)
}
return result
})
// 将Task[]转换为Milestone[]的计算属性,确保类型兼容
const milestonesForTimeline = computed((): Milestone[] => {
// 通过条件判断访问触发器,确保里程碑更新时重新计算
if (updateTaskTrigger.value >= 0) {
// 触发器起作用,继续执行计算逻辑
}
if (!props.milestones) return []
// 过滤出有startDate的里程碑,并转换为Milestone类型
const result = props.milestones
.filter((task): task is Task & { startDate: string } => !!task.startDate)
.map(task => ({
id: task.id,
name: task.name,
startDate: task.startDate, // 此时已确保非空
endDate: task.endDate,
assignee: task.assignee,
type: task.type || 'milestone',
icon: task.icon,
description: task.description,
}))
return result
})
// 计算所有任务和里程碑的最小开始时间和最大结束时间
const timelineDateRange = computed(() => {
// 触发器依赖:确保拖拽/拉伸后会重新计算
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
updateTaskTrigger.value
// 扁平化所有任务和子任务
const flattenTasks = (tasks: Task[]): Task[] => {
let result: Task[] = []
tasks.forEach(task => {
result.push(task)
if (task.children && task.children.length > 0) {
result = result.concat(flattenTasks(task.children))
}
})
return result
}
let allTasks: Task[] = []
if (props.tasks) allTasks = allTasks.concat(flattenTasks(props.tasks))
if (props.milestones) allTasks = allTasks.concat(props.milestones)
// 过滤出有日期的任务
const startDates = allTasks
.map(t => t.startDate)
.filter(Boolean)
.map(d => new Date(d!))
const endDates = allTasks
.map(t => t.endDate)
.filter(Boolean)
.map(d => new Date(d!))
// 如果没有任务,使用默认范围(今天为中心,±6个月)
if (startDates.length === 0 || endDates.length === 0) {
const today = new Date()
const min = new Date(today.getFullYear(), today.getMonth() - 6, 1)
const max = new Date(today.getFullYear(), today.getMonth() + 6 + 1, 0)
return { min, max }
}
// 1. 获取任务的实际范围
const taskMinDate = new Date(Math.min(...startDates.map(d => d.getTime())))
const taskMaxDate = new Date(Math.max(...endDates.map(d => d.getTime())))
// 2. 获取容器宽度和当前刻度的列宽
const containerWidth = timelineContainerWidth.value || 1200 // 默认值
const columnWidth = SCALE_CONFIGS[currentTimeScale.value].cellWidth
// 3. 计算需要多少列才能铺满容器
const minColumns = Math.ceil(containerWidth / columnWidth)
// 4. 应用固定 buffer + 确保铺满容器
const { min, max } = applyBufferAndFillContainer(
taskMinDate,
taskMaxDate,
currentTimeScale.value,
minColumns,
columnWidth,
)
return { min, max }
})
/**
* 应用固定 buffer 规则并确保铺满容器
* Buffer 规则:
* - 小时视图:±1天
* - 日视图:±15天
* - 周视图:±1个月(对齐到整月)
* - 月视图:±1年
* - 季度视图:±1年
* - 年度视图:±1年
*/
function applyBufferAndFillContainer(
taskMin: Date,
taskMax: Date,
scale: TimelineScale,
minColumns: number,
columnWidth: number,
): { min: Date; max: Date } {
let min: Date
let max: Date
switch (scale) {
case TimelineScale.HOUR: {
// 小时视图:±1天
min = new Date(taskMin.getTime() - 24 * 60 * 60 * 1000)
max = new Date(taskMax.getTime() + 24 * 60 * 60 * 1000)
// 确保至少有 minColumns 小时
const currentHours = Math.ceil((max.getTime() - min.getTime()) / (1000 * 60 * 60))
if (currentHours < minColumns) {
const needHours = minColumns - currentHours
const expandEach = Math.ceil(needHours / 2)
min = new Date(min.getTime() - expandEach * 60 * 60 * 1000)
max = new Date(max.getTime() + expandEach * 60 * 60 * 1000)
}
break
}
case TimelineScale.DAY: {
// 日视图:±15天,显示 buffer 日期所在月份的完整月份
// 1. 计算 buffer 日期
const minBufferDate = new Date(taskMin)
minBufferDate.setDate(minBufferDate.getDate() - 15)
const maxBufferDate = new Date(taskMax)
maxBufferDate.setDate(maxBufferDate.getDate() + 15)
// 2. 获取 buffer 日期所在月份的第一天和最后一天
min = new Date(minBufferDate.getFullYear(), minBufferDate.getMonth(), 1)
max = new Date(maxBufferDate.getFullYear(), maxBufferDate.getMonth() + 1, 0)
// 3. 确保至少有 minColumns 天
const currentDays = Math.ceil((max.getTime() - min.getTime()) / (1000 * 60 * 60 * 24))
if (currentDays < minColumns) {
const needDays = minColumns - currentDays
const expandMonths = Math.ceil(needDays / 30) // 按月扩展
// 向前扩展整月
const newMinMonth = min.getMonth() - expandMonths
const newMinYear = min.getFullYear() + Math.floor(newMinMonth / 12)
const normalizedMinMonth = ((newMinMonth % 12) + 12) % 12
min = new Date(newMinYear, normalizedMinMonth, 1)
// 向后扩展整月
const newMaxMonth = max.getMonth() + expandMonths + 1
const newMaxYear = max.getFullYear() + Math.floor(newMaxMonth / 12)
const normalizedMaxMonth = ((newMaxMonth % 12) + 12) % 12
max = new Date(newMaxYear, normalizedMaxMonth, 0)
}
break
}
case TimelineScale.WEEK: {
// 周视图:确保铺满容器,按整月扩展
// 工具函数:获取某日期所在周的周一
const getMonday = (date: Date): Date => {
const d = new Date(date)
const day = d.getDay() || 7
d.setDate(d.getDate() - (day - 1))
return d
}
// 工具函数:获取某月第一个周一在该月的周(该月最小日期作为周一的周)
const 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) // 兜底
}
// 工具函数:获取某月最后一个周一在该月的周(该月最大日期作为周一的周)
const 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) // 兜底
}
// 工具函数:获取某月的所有周(周一在该月的周)
const 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(taskMin)
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(taskMax)
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. 判断是否填满容器,不够则继续扩展前后的完整月份
const weekWidth = 60 // 周视图:每周60px
let totalWidth = weeks.length * weekWidth
while (totalWidth < minColumns * columnWidth) {
// 前面扩展一个完整月
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 >= minColumns * columnWidth) 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. 计算最终 min/max
min = new Date(weeks[0])
max = new Date(weeks[weeks.length - 1])
max.setDate(max.getDate() + 6) // 该周的周日
break
}
case TimelineScale.MONTH: {
// 月视图:±1年
min = new Date(taskMin.getFullYear() - 1, taskMin.getMonth(), 1)
max = new Date(taskMax.getFullYear() + 1, taskMax.getMonth() + 1, 0)
// 确保至少有 minColumns 月
const currentMonths =
(max.getFullYear() - min.getFullYear()) * 12 + (max.getMonth() - min.getMonth())
if (currentMonths < minColumns) {
const needMonths = minColumns - currentMonths
const expandEach = Math.ceil(needMonths / 2)
min = new Date(min.getFullYear(), min.getMonth() - expandEach, 1)
max = new Date(max.getFullYear(), max.getMonth() + expandEach + 1, 0)
}
break
}
case TimelineScale.QUARTER: {
// 季度视图:以今日为中心,±2年 buffer,不够则继续扩展
const today = new Date()
const todayYear = today.getFullYear()
// 1. 先确保包含任务范围 + ±2年 buffer
const taskMinYear = taskMin.getFullYear()
const taskMaxYear = taskMax.getFullYear()
min = new Date(Math.min(todayYear - 2, taskMinYear - 2), 0, 1)
max = new Date(Math.max(todayYear + 2, taskMaxYear + 2), 11, 31)
// 2. 检查是否能填充容器
const currentQuarters = (max.getFullYear() - min.getFullYear() + 1) * 4
if (currentQuarters < minColumns) {
// 需要扩展,以1年(4个季度)为单位
const needQuarters = minColumns - currentQuarters
const expandYears = Math.ceil(needQuarters / 4)
// 以今日为中心扩展
const expandEach = Math.ceil(expandYears / 2)
min = new Date(min.getFullYear() - expandEach, 0, 1)
max = new Date(max.getFullYear() + expandEach, 11, 31)
}
break
}
case TimelineScale.YEAR: {
// 年度视图:以今日为中心,±2年 buffer,不够则继续扩展(与季度视图逻辑一致)
const today = new Date()
const todayYear = today.getFullYear()
// 1. 先确保包含任务范围 + ±2年 buffer
const taskMinYear = taskMin.getFullYear()
const taskMaxYear = taskMax.getFullYear()
min = new Date(Math.min(todayYear - 2, taskMinYear - 2), 0, 1)
max = new Date(Math.max(todayYear + 2, taskMaxYear + 2), 11, 31)
// 2. 检查是否能填充容器(年度视图每年2个半年,每个半年是一列)
const currentHalfYears = (max.getFullYear() - min.getFullYear() + 1) * 2
if (currentHalfYears < minColumns) {
// 需要扩展,以1年(2个半年)为单位
const needHalfYears = minColumns - currentHalfYears
const expandYears = Math.ceil(needHalfYears / 2)
// 以今日为中心扩展
const expandEach = Math.ceil(expandYears / 2)
min = new Date(min.getFullYear() - expandEach, 0, 1)
max = new Date(max.getFullYear() + expandEach, 11, 31)
}
break
}
default:
min = taskMin
max = taskMax
}
return { min, max }
}
// CSV导出处理器包装函数
const csvExportHandler = () => {
// 如果有外部处理器,先调用它
if (props.onExportCsv && typeof props.onExportCsv === 'function') {
const result = props.onExportCsv()
// 如果外部处理器返回 false,则继续执行默认导出
// 如果返回其他值或者 undefined,则认为外部已经处理了导出
if (result === false) {
defaultExportCsv()
}
return
}
// 使用默认导出实现
defaultExportCsv()
}
// 时间刻度变化处理函数
const handleTimeScaleChange = (scale: TimelineScale) => {
currentTimeScale.value = scale
// 通知 Timeline 组件更新时间刻度
if (timelineRef.value) {
timelineRef.value.updateTimeScale(scale)
}
}
// Timeline组件时间刻度变化完成后的处理函数
const handleTimelineScaleChanged = (scale: TimelineScale) => {
// 强制重新渲染所有TaskBar,触发位置重新计算
nextTick(() => {
// 触发强制更新,让所有TaskBar重新计算位置
const event = new CustomEvent('timeline-scale-updated', { detail: scale })
window.dispatchEvent(event)
})
}
// === 时间维度相关方法 ===
// 时间刻度顺序定义
const TIME_SCALE_ORDER: TimelineScale[] = ['hour', 'day', 'week', 'month', 'quarter', 'year']
/**
* 设置时间刻度
* @param scale 时间刻度,默认为 'week'
*/
const setTimeScale = (scale?: TimelineScale) => {
const targetScale = scale || 'week'
if (TIME_SCALE_ORDER.includes(targetScale)) {
handleTimeScaleChange(targetScale)
}
}
/**
* 放大时间刻度(显示更细粒度)
* year -> quarter -> month -> week -> day -> hour
*/
const zoomIn = () => {
const currentIndex = TIME_SCALE_ORDER.indexOf(currentTimeScale.value)
if (currentIndex > 0) {
const newScale = TIME_SCALE_ORDER[currentIndex - 1]
handleTimeScaleChange(newScale)
}
}
/**
* 缩小时间刻度(显示更粗粒度)
* hour -> day -> week -> month -> quarter -> year
*/
const zoomOut = () => {
const currentIndex = TIME_SCALE_ORDER.indexOf(currentTimeScale.value)
if (currentIndex < TIME_SCALE_ORDER.length - 1) {
const newScale = TIME_SCALE_ORDER[currentIndex + 1]
handleTimeScaleChange(newScale)
}
}
/**
* 获取当前时间刻度
*/
const currentScale = (): string => {
return currentTimeScale.value
}
// 监听 Props timeScale 变化
watch(
() => props.timeScale,
(newScale) => {
if (newScale && newScale !== currentTimeScale.value) {
handleTimeScaleChange(newScale)
}
},
{ immediate: true },
)
// 处理关闭高亮
const handleClearHighlight = () => {
if (timelineRef.value?.clearHighlight) {
timelineRef.value.clearHighlight()
}
}
// 默认CSV导出功能
const defaultExportCsv = () => {
try {
// 合并任务和里程碑数据进行导出
const allData: Task[] = []
// 添加任务数据
if (props.tasks && props.tasks.length > 0) {
allData.push(...props.tasks)
}
// 添加里程碑数据
if (props.milestones && props.milestones.length > 0) {
allData.push(...props.milestones)
}
// 生成CSV内容
const csvContent = generateCsvContent(allData)
// 添加UTF-8 BOM以确保在Excel等程序中正确显示中文
const BOM = '\uFEFF'
const csvWithBOM = BOM + csvContent
// 创建下载链接,明确指定UTF-8编码
const blob = new Blob([csvWithBOM], {
type: 'text/csv;charset=utf-8;',
})
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
// 根据当前语言生成文件名
const currentDate = new Date().toISOString().split('T')[0]
const filename = `gantt-tasks-${currentDate}.csv`
link.setAttribute('href', url)
link.setAttribute('download', filename)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// 清理URL对象
URL.revokeObjectURL(url)
} catch (error) {
console.error('CSV导出失败:', error)
showMessage('CSV导出失败', 'error', { closable: false })
}
}
// 生成CSV内容
const generateCsvContent = (tasks: Task[]): string => {
// 使用多语言的CSV头部 - 动态获取当前语言状态
const csvHeaders = t.value.csvHeaders
const headers = [
csvHeaders.id,
csvHeaders.taskName,
csvHeaders.predecessor,
csvHeaders.assignee,
csvHeaders.startDate,
csvHeaders.endDate,
csvHeaders.estimatedHours,
csvHeaders.actualHours,
csvHeaders.progress,
csvHeaders.type,
csvHeaders.description,
]
// 安全的字符串转义函数
const escapeCSVField = (value: any): string => {
if (value === null || value === undefined) {
return ''
}
const stringValue = String(value)
// 如果字段包含逗号、引号、换行符等特殊字符,需要用引号包围并转义内部引号
if (
stringValue.includes(',') ||
stringValue.includes('"') ||
stringValue.includes('\n') ||
stringValue.includes('\r')
) {
return `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
}
// 扁平化任务数据(包含子任务)
const flattenTasks = (taskList: Task[]): any[] => {
const result: any[] = []
taskList.forEach(task => {
// 添加当前任务,使用安全的数据处理
result.push({
id: task.id,
name: task.name || '',
predecessor: formatPredecessorDisplay(task.predecessor),
assignee: task.assignee || '',
startDate: task.startDate || '',
endDate: task.endDate || '',
estimatedHours: task.estimatedHours || '',
actualHours: task.actualHours || '',
progress: task.progress !== undefined ? `${task.progress}%` : '',
type: task.type || '',
description: task.description || '',
})
// 递归添加子任务
if (task.children && task.children.length > 0) {
result.push(...flattenTasks(task.children))
}
})
return result
}
const flatTasks = flattenTasks(tasks)
// 生成CSV行,使用安全的字符串转义
const csvRows = [
// 头部行 - 也需要进行转义处理
headers.map(header => escapeCSVField(header)).join(','),
// 数据行
...flatTasks.map(task => {
return [
escapeCSVField(task.id),
escapeCSVField(task.name),
escapeCSVField(formatPredecessorDisplay(task.predecessor)),
escapeCSVField(task.assignee),
escapeCSVField(task.startDate),
escapeCSVField(task.endDate),
escapeCSVField(task.estimatedHours),
escapeCSVField(task.actualHours),
escapeCSVField(task.progress),
escapeCSVField(task.type),
escapeCSVField(task.description),
].join(',')
}),
]
return csvRows.join('\n')
}
// PDF导出处理器
const pdfExportHandler = async () => {
try {
// 获取当前语言的文本
const loadingText = t.value.pdfExportLoading
const titleText = t.value.pdfExportTitle
const dateLabel = t.value.pdfExportDate
// 创建加载提示
const loadingEl = document.createElement('div')
loadingEl.innerHTML = `
<div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); display: flex; align-items: center;
justify-content: center; z-index: 10000; color: white; font-size: 16px;">
${loadingText}
</div>
`
document.body.appendChild(loadingEl)
// 获取甘特图容器元素
const ganttElement = document.querySelector('.gantt-body') as HTMLElement
if (!ganttElement) {
throw new Error('找不到甘特图元素')
}
// 设置临时样式以确保完整截图
const originalStyle = {
overflow: ganttElement.style.overflow,
height: ganttElement.style.height,
}
ganttElement.style.overflow = 'visible'
ganttElement.style.height = 'auto'
// 使用html2canvas捕获甘特图
const canvas = await html2canvas(ganttElement, {
allowTaint: true,
useCORS: true,
scale: 2, // 提高清晰度
width: ganttElement.scrollWidth,
height: ganttElement.scrollHeight,
backgroundColor: '#ffffff',
})
// 恢复原始样式
ganttElement.style.overflow = originalStyle.overflow
ganttElement.style.height = originalStyle.height
// 创建PDF
const imgData = canvas.toDataURL('image/png')
const pdf = new jsPDF({
orientation: 'landscape', // 横向布局更适合甘特图
unit: 'mm',
format: 'a4',
})
// 计算图片在PDF中的尺寸
const pdfWidth = pdf.internal.pageSize.getWidth()
const pdfHeight = pdf.internal.pageSize.getHeight()
const imgWidth = canvas.width
const imgHeight = canvas.height
// 计算缩放比例,保持宽高比
const ratio = Math.min(pdfWidth / imgWidth, pdfHeight / imgHeight)
const scaledWidth = imgWidth * ratio
const scaledHeight = imgHeight * ratio
// 居中放置
const x = (pdfWidth - scaledWidth) / 2
const y = (pdfHeight - scaledHeight) / 2
// 添加标题
pdf.setFontSize(16)
pdf.text(titleText, pdfWidth / 2, 15, { align: 'center' })
// 添加日期
pdf.setFontSize(10)
const currentDate = new Date().toLocaleDateString(i18nLocale.value)
pdf.text(`${dateLabel}: ${currentDate}`, pdfWidth - 10, 10, { align: 'right' })
// 添加甘特图图片
pdf.addImage(imgData, 'PNG', x, y + 10, scaledWidth, scaledHeight - 15)
// 如果内容超出一页,分页处理
if (scaledHeight > pdfHeight - 30) {
// 计算需要的页数
const pages = Math.ceil(scaledHeight / (pdfHeight - 30))
for (let i = 1; i < pages; i++) {
pdf.addPage()
const offsetY = -i * (pdfHeight - 30)
pdf.addImage(imgData, 'PNG', x, y + 10 + offsetY, scaledWidth, scaledHeight - 15)
}
}
// 保存PDF
const filename = `gantt-chart-${new Date().toISOString().split('T')[0]}.pdf`
pdf.save(filename)
// 移除加载提示
if (loadingEl && loadingEl.parentNode) {
loadingEl.parentNode.removeChild(loadingEl)
}
} catch (error) {
// 移除加载提示
const loadingEl = document.querySelector('[style*="position: fixed"]')
if (loadingEl && loadingEl.parentNode) {
loadingEl.parentNode.removeChild(loadingEl)
}
console.error('PDF导出失败:', error)
alert('PDF导出失败')
}
}
// 监听GanttToolbar的全屏切换事件
const handleFullscreenToggle = (event: CustomEvent) => {
isFullscreen.value = event.detail
// 调用外部传入的全屏处理器
if (props.onFullscreenChange && typeof props.onFullscreenChange === 'function') {
props.onFullscreenChange(isFullscreen.value)
}
// 全屏切换会改变Timeline容器宽度,需要通知Timeline重新计算TaskBar位置和关系线
// 延迟到动画完成后(全屏动画需要 300ms),确保容器尺寸已经稳定
setTimeout(() => {
window.dispatchEvent(
new CustomEvent('timeline-container-resized', {
detail: { source: 'fullscreen-toggle' },
}),
)
}, 500) // 比动画时间稍长一点,确保完全完成
}
// === 全屏相关方法 ===
/**
* 进入全屏模式
*/
const enterFullscreen = () => {
if (!isFullscreen.value) {
isFullscreen.value = true
if (props.onFullscreenChange && typeof props.onFullscreenChange === 'function') {
props.onFullscreenChange(true)
}
setTimeout(() => {
window.dispatchEvent(
new CustomEvent('timeline-container-resized', {
detail: { source: 'fullscreen-toggle' },
}),
)
}, 500)
}
}
/**
* 退出全屏模式
*/
const exitFullscreen = () => {
if (isFullscreen.value) {
isFullscreen.value = false
if (props.onFullscreenChange && typeof props.onFullscreenChange === 'function') {
props.onFullscreenChange(false)
}
setTimeout(() => {
window.dispatchEvent(
new CustomEvent('timeline-container-resized', {
detail: { source: 'fullscreen-toggle' },
}),
)
}, 500)
}
}
/**
* 切换全屏模式
*/
const toggleFullscreen = () => {
if (isFullscreen.value) {
exitFullscreen()
} else {
enterFullscreen()
}
}
/**
* 获取当前是否全屏
*/
const getIsFullscreen = (): boolean => {
return isFullscreen.value
}
// 监听 Props fullscreen 变化
watch(
() => props.fullscreen,
(newValue) => {
if (newValue !== undefined && newValue !== isFullscreen.value) {
if (newValue) {
enterFullscreen()
} else {
exitFullscreen()
}
}
},
{ immediate: true },
)
// 更新或添加里程碑到列表中
const updateOrAddMilestone = (milestones: Task[], milestone: Task): boolean => {
const existingIndex = milestones.findIndex(m => m.id === milestone.id)
if (existingIndex !== -1) {
// 更新现有里程碑 - 使用 splice 确保响应式
milestones.splice(existingIndex, 1, { ...milestones[existingIndex], ...milestone })
return true
} else {
// 添加新里程碑
milestones.push(milestone)
return true
}
}
// 处理里程碑保存事件
const handleMilestoneSave = (milestone: Task) => {
// 如果是新建里程碑(没有id),生成一个临时ID
if (!milestone.id) {
milestone.id = Date.now()
}
// 确保里程碑有必要的属性
milestone.type = 'milestone'
// 1. 先更新 props.milestones 数据(自动处理数据)
if (props.milestones) {
updateOrAddMilestone(props.milestones, milestone)
}
// 2. 触发里程碑保存事件(新的事件驱动 API)
emit('milestone-saved', milestone)
// 3. 强制更新任务触发器,确保Timeline重新计算
updateTaskTrigger.value++
// 如果是从对话框保存的,关闭对话框
if (milestoneDialogVisible.value) {
handleMilestoneDialogClose()
}
}
// 更新里程碑图标
const updateMilestoneIcon = (milestones: Task[], milestoneId: number, icon: string): boolean => {
const index = milestones.findIndex(m => m.id === milestoneId)
if (index !== -1) {
// 使用 splice 确保响应式更新
milestones.splice(index, 1, { ...milestones[index], icon })
return true
}
return false
}
// 处理里程碑图标变更事件
const handleMilestoneIconChangeEvent = (event: CustomEvent) => {
const { milestoneId, icon } = event.detail
// 1. 先更新 props.milestones 中的图标(自动处理数据)
if (props.milestones) {
updateMilestoneIcon(props.milestones, milestoneId, icon)
}
// 2. 触发里程碑图标变更事件(新的事件驱动 API)
emit('milestone-icon-changed', { milestoneId, icon })
// 3. 强制更新任务触发器,确保Timeline重新计算
updateTaskTrigger.value++
}
// 从里程碑列表中删除指定的里程碑
const removeMilestone = (milestones: Task[], milestoneId: number): boolean => {
const index = milestones.findIndex(m => m.id === milestoneId)
if (index !== -1) {
milestones.splice(index, 1)
return true
}
return false
}
// 处理里程碑删除事件(从全局事件触发,主要用于向后兼容)
const handleMilestoneDeleted = () => {
// 注意:由于新的架构中,handleMilestoneDialogDelete 已经处理了数据删除
// 这里只处理强制更新,避免重复删除导致的问题
// 强制更新任务触发器,确保Timeline重新计算
updateTaskTrigger.value++
}
// 处理里程碑数据变化事件
const handleMilestoneDataChanged = () => {
// 里程碑数据发生变化时,强制更新任务触发器
// 这会触发 tasksForTimeline 的重新计算
updateTaskTrigger.value++
// 确保DOM更新
nextTick(() => {
// 可以在这里添加额外的更新逻辑
})
}
// 保证页面加载后自动居中今日
onMounted(() => {
nextTick(() => {
if (timelineRef.value && typeof timelineRef.value.scrollToTodayCenter === 'function') {
timelineRef.value.scrollToTodayCenter()
}
})
})
// 今日定位处理器
const todayLocateHandler = () => {
// 如果有外部处理器,先调用它
if (props.onTodayLocate && typeof props.onTodayLocate === 'function') {
props.onTodayLocate()
return
}
// 使用Timeline组件的scrollToTodayCenter方法,确保今日居中
if (timelineRef.value && typeof timelineRef.value.scrollToTodayCenter === 'function') {
timelineRef.value.scrollToTodayCenter()
return
}
// 兜底方案
defaultTodayLocate()
}
// 里程碑添加处理器
const milestoneAddHandler = () => {
// 先派发事件,让外部有机会处理
emit('add-milestone')
// 如果启用默认里程碑对话框,则使用内置实现
if (props.useDefaultMilestoneDialog) {
defaultAddMilestone()
}
}
// 默认里程碑添加功能
const defaultAddMilestone = () => {
// 打开里程碑对话框(editingMilestone 为 null 表示新建)
editingMilestone.value = null
milestoneDialogVisible.value = true
}
// 默认今日定位功能
const defaultTodayLocate = () => {
// 优先使用Timeline组件的scrollToToday方法(已优化的定位逻辑)
if (timelineRef.value && typeof timelineRef.value.scrollToToday === 'function') {
timelineRef.value.scrollToToday()
return
}
// 如果Timeline组件或方法不可用,使用备用方案
const today = new Date()
const currentMonth = today.getMonth()
const currentDay = today.getDate()
// 计算今天在时间轴中的位置
let totalDays = 0
for (let i = 0; i < currentMonth; i++) {
totalDays += new Date(today.getFullYear(), i + 1, 0).getDate()
}
totalDays += currentDay
// 计算滚动位置(每个日期30px宽度)
const timelinePanel = document.querySelector('.gantt-panel-right')
const timelinePanelW = timelinePanel?.clientWidth
const offset = timelinePanelW ? timelinePanelW / 2 : 200 // 偏移量让今天居中显示
const scrollPosition = (totalDays - 1) * 30 - offset
if (timelinePanel) {
timelinePanel.scrollLeft = Math.max(0, scrollPosition)
}
}
// === 今日定位相关方法 ===
/**
* 滚动到今日位置
*/
const scrollToToday = () => {
if (timelineRef.value && typeof timelineRef.value.scrollToTodayCenter === 'function') {
timelineRef.value.scrollToTodayCenter()
} else {
defaultTodayLocate()
}
}
/**
* 滚动到指定任务
* @param taskId 任务ID
*/
const scrollToTask = (taskId: string | number) => {
// 查找任务并滚动到对应位置
const findTaskById = (tasks: Task[], id: string | number): Task | null => {
for (const task of tasks) {
if (task.id === id || String(task.id) === String(id)) {
return task
}
if (task.children && task.children.length > 0) {
const found = findTaskById(task.children, id)
if (found) return found
}
}
return null
}
if (props.tasks) {
const task = findTaskById(props.tasks, taskId)
if (task && task.startDate) {
// 使用Timeline的scrollToDate方法
if (timelineRef.value && typeof timelineRef.value.scrollToDate === 'function') {
timelineRef.value.scrollToDate(task.startDate)
}
}
}
}
/**
* 滚动到指定日期
* @param date 日期(Date对象或日期字符串)
*/
const scrollToDate = (date: Date | string) => {
// 使用Timeline的scrollToDate方法
if (timelineRef.value && typeof timelineRef.value.scrollToDate === 'function') {
timelineRef.value.scrollToDate(date)
}
}
// === 语言切换相关方法 ===
/**
* 获取当前语言
*/
const currentLocale = (): string => {
return i18nLocale.value
}
/**
* 设置语言
* @param locale 语言代码
*/
const setLocale = (locale: 'zh-CN' | 'en-US') => {
const { setLocale: setI18nLocale } = useI18n()
setI18nLocale(locale)
}
// 监听 Props locale 变化
watch(
() => props.locale,
(newLocale) => {
if (newLocale && newLocale !== i18nLocale.value) {
// 导入 setLocale 方法
const { setLocale } = useI18n()
setLocale(newLocale)
}
},
{ immediate: true },
)
// 窗口大小变化处理函数
const handleWindowResize = () => {
// 直接检查当前宽度是否仍然有效,如果超出限制则调整
const adjustedWidth = checkWidthLimits(leftPanelWidth.value)
if (adjustedWidth !== leftPanelWidth.value) {
leftPanelWidth.value = adjustedWidth
}
}
// 设置自定义多语言消息(支持多语言)
if (props.localeMessages) {
Object.keys(props.localeMessages).forEach(localeKey => {
const messages = props.localeMessages![localeKey as keyof typeof props.localeMessages]
if (messages) {
setCustomMessages(localeKey as import('../composables/useI18n').Locale, messages)
}
})
}
// 监听自定义多语言变化
watch(
() => props.localeMessages,
val => {
if (val) {
Object.keys(val).forEach(localeKey => {
const messages = val[localeKey as keyof typeof val]
if (messages) {
setCustomMessages(localeKey as import('../composables/useI18n').Locale, messages)
}
})
}
},
{ deep: true },
)
// 右键菜单状态管理
const contextMenuPosition = ref({ x: 0, y: 0 })
const contextMenuVisible = ref(false)
const contextMenuTask = ref<Task | null>(null)
// TaskDrawer 相关变量
const taskDrawerVisible = ref(false)
const taskDrawerTask = ref<Task | null>(null)
const taskDrawerEditMode = ref(false)
// MilestoneDialog 相关变量
const milestoneDialogVisible = ref(false)
const editingMilestone = ref<Milestone | null>(null)
// 添加前置任务功能相关变量
const taskToAddPredecessorTo = ref<Task | null>(null) // 要添加前置任务的目标任务
// 添加后置任务功能相关变量
const taskToAddSuccessorTo = ref<Task | null>(null) // 要添加后置任务的目标任务
// 处理任务条的右键菜单事件
const handleTaskContextMenu = (event: CustomEvent) => {
// 显示右键菜单
const { task, position } = event.detail
// 显示右键菜单
contextMenuTask.value = task
contextMenuPosition.value = position
contextMenuVisible.value = true
}
// 关闭右键菜单
const closeContextMenu = () => {
contextMenuVisible.value = false
}
// 工具栏新建任务事件处理
function handleToolbarAddTask() {
// 向外部发出 add-task 事件,让使用者决定如何处理
emit('add-task')
// 如果使用默认 TaskDrawer,则打开内置的任务抽屉
if (props.useDefaultDrawer) {
const newTask: Task = {
id: Date.now(), // 临时id,实际保存时应由后端分配
name: '',
type: 'task',
assignee: '',
startDate: '',
endDate: '',
predecessor: [],
estimatedHours: 0,
actualHours: 0,
progress: 0,
description: '',
parentId: undefined,
children: [],
}
taskDrawerTask.value = newTask
taskDrawerEditMode.value = false
taskDrawerVisible.value = true
}
}
// 监听TaskDrawer、TaskList、Timeline的计时事件,统一处理
const handleStartTimer = (task: Task) => {
// 任务树内状态同步
if (props.tasks) {
const updateTask = (tasks: Task[]): boolean => {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === task.id) {
tasks[i].isTimerRunning = true
tasks[i].timerStartTime = task.timerStartTime || Date.now()
tasks[i].timerEndTime = undefined
tasks[i].timerElapsedTime = 0
return true
}
if (tasks[i].children?.length) {
if (updateTask(tasks[i].children as Task[])) return true
}
}
return false
}
updateTask(props.tasks)
}
closeContextMenu()
emit('timer-started', task)
}
const handleStopTimer = (task: Task) => {
// 任务树内状态同步
if (props.tasks) {
const updateTask = (tasks: Task[]): boolean => {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === task.id) {
if (tasks[i].isTimerRunning && tasks[i].timerStartTime !== undefined) {
const elapsed = tasks[i].timerElapsedTime || 0
tasks[i].timerElapsedTime = elapsed + (Date.now() - tasks[i].timerStartTime!)
tasks[i].timerEndTime = Date.now()
}
tasks[i].isTimerRunning = false
if (
taskDrawerVisible.value &&
taskDrawerTask.value &&
taskDrawerTask.value.id === task.id
) {
taskDrawerTask.value.isTimerRunning = false
taskDrawerTask.value.timerEndTime = Date.now()
}
return true
}
if (tasks[i].children?.length) {
if (updateTask(tasks[i].children as Task[])) return true
}
}
return false
}
updateTask(props.tasks)
}
closeContextMenu()
emit('timer-stopped', task)
}
// 监听来自Timeline的任务单击事件
function handleTimelineClickTask(task: Task, event: MouseEvent) {
emit('task-click', task, event)
}
// 监听来自Timeline的任务编辑事件(双击)
function handleTimelineEditTask(task: Task) {
emit('task-double-click', task)
// 根据 useDefaultDrawer 决定是否打开内置 TaskDrawer
if (props.useDefaultDrawer) {
taskDrawerTask.value = task
taskDrawerEditMode.value = true
taskDrawerVisible.value = true
}
}
// 处理添加前置任务事件
function handleAddPredecessor(targetTask: Task) {
if (!targetTask) return
// 触发事件供外部使用
emit('add-predecessor', targetTask)
// 1. 记录要添加前置任务的目标任务
taskToAddPredecessorTo.value = targetTask
// 2. 打开TaskDrawer,进入新增模式
// 新建任务,parentId与目标任务一致
const newTask: Task = {
id: Date.now(), // 临时id,实际保存时应由后端分配
name: '',
type: 'task',
assignee: '',
startDate: '',
endDate: '',
predecessor: [],
estimatedHours: 0,
actualHours: 0,
progress: 0,
description: '',
parentId: targetTask.parentId,
children: [],
isTimerRunning: false,
timerStartTime: undefined,
timerEndTime: undefined,
timerElapsedTime: 0,
}
taskDrawerTask.value = newTask
taskDrawerEditMode.value = false
taskDrawerVisible.value = true
}
// 处理添加后置任务事件
function handleAddSuccessor(targetTask: Task) {
if (!targetTask) return
// 触发事件供外部使用
emit('add-successor', targetTask)
// 记录要添加后置任务的目标任务
taskToAddSuccessorTo.value = targetTask
// 构造新任务,parentId 与目标任务一致,predecessor 仅包含目标任务 id
const newTask: Task = {
id: Date.now(), // 临时id,实际保存时应由后端分配
name: '',
type: 'task',
assignee: '',
startDate: '',
endDate: '',
predecessor: [targetTask.id],
estimatedHours: 0,
actualHours: 0,
progress: 0,
description: '',
parentId: targetTask.parentId,
children: [],
isTimerRunning: false,
timerStartTime: undefined,
timerEndTime: undefined,
timerElapsedTime: 0,
}
taskDrawerTask.value = newTask
taskDrawerEditMode.value = false
taskDrawerVisible.value = true
}
// 处理前置任务已添加事件(拖拽连接完成)
function handlePredecessorAdded(event: { targetTask: Task; newTask: Task }) {
// 直接转发事件给外部
emit('predecessor-added', event)
}
// 处理后置任务已添加事件(拖拽连接完成)
function handleSuccessorAdded(event: { targetTask: Task; newTask: Task }) {
// 直接转发事件给外部
emit('successor-added', event)
}
// 新增Task插入到任务树中
// 插入新任务到任务树(parentId 已在打开 TaskDrawer 时预设好)
const insertTask = (tasks: Task[], newTask: Task) => {
if (!newTask.parentId) {
tasks.push(newTask)
return true
}
for (const t of tasks) {
if (t.id === newTask.parentId) {
if (!t.children) t.children = []
t.children.push(newTask)
return true
}
if (t.children && t.children.length > 0) {
if (insertTask(t.children, newTask)) return true
}
}
return false
}
// 编辑模式:递归查找并更新任务树节点
const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === updatedTask.id) {
tasks[i] = { ...tasks[i], ...updatedTask }
return true
}
if (tasks[i].children && (tasks[i].children as Task[]).length > 0) {
if (updateTaskInTree(tasks[i].children as Task[], updatedTask)) return true
}
}
return false
}
// 在 handleTaskDrawerSubmit 里补充:如果是添加前置任务,自动将新任务id加入目标任务的 predecessor
function handleTaskDrawerSubmit(task: Task) {
if (!taskDrawerEditMode.value) {
if (props.tasks) {
insertTask(props.tasks, task)
}
// emit 新增任务事件
emit('task-added', { task })
if (taskToAddPredecessorTo.value) {
if (!taskToAddPredecessorTo.value.predecessor) {
taskToAddPredecessorTo.value.predecessor = []
}
taskToAddPredecessorTo.value.predecessor.push(task.id)
// emit 添加前置任务事件
emit('predecessor-added', { targetTask: taskToAddPredecessorTo.value, newTask: task })
taskToAddPredecessorTo.value = null
}
if (taskToAddSuccessorTo.value) {
// emit 添加后置任务事件
emit('successor-added', { targetTask: taskToAddSuccessorTo.value, newTask: task })
taskToAddSuccessorTo.value = null
}
} else {
if (props.tasks) {
updateTaskInTree(props.tasks, task)
}
updateTaskTrigger.value++
// emit 任务更新事件
emit('task-updated', { task })
}
}
// 删除任务的递归工具函数,支持 deleteChildren 逻辑
function removeTaskFromTree(tasks: Task[], taskId: number, deleteChildren?: boolean): boolean {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === taskId) {
if (deleteChildren) {
// 递归删除该节点及所有子节点(直接 splice 即可)
tasks.splice(i, 1)
} else {
// 只删除该节点,把 children 提升到同级
const children = tasks[i].children || []
tasks.splice(i, 1, ...children)
}
return true
}
if (tasks[i].children && (tasks[i].children as Task[]).length > 0) {
if (removeTaskFromTree(tasks[i].children as Task[], taskId, deleteChildren)) return true
}
}
return false
}
// 处理 Task 的删除事件
function handleTaskDelete(task: Task, deleteChildren?: boolean) {
if (props.tasks) {
removeTaskFromTree(props.tasks, task.id, deleteChildren)
}
taskDrawerVisible.value = false
taskDrawerTask.value = null
// emit 删除事件
emit('task-deleted', { task })
}
// 处理链接删除事件
function handleLinkDeleted(event: {
sourceTaskId: number
targetTaskId: number
updatedTask: Task
}) {
if (props.tasks) {
// 在 tasks 树中找到目标任务并更新
const updateTaskInTree = (taskList: Task[]): boolean => {
for (let i = 0; i < taskList.length; i++) {
if (taskList[i].id === event.targetTaskId) {
// 使用新对象替换,确保响应式更新
taskList[i] = { ...taskList[i], predecessor: event.updatedTask.predecessor }
return true
}
if (taskList[i].children && taskList[i].children!.length > 0) {
if (updateTaskInTree(taskList[i].children!)) {
return true
}
}
}
return false
}
updateTaskInTree(props.tasks)
}
// 触发任务更新事件
emit('task-updated', { task: event.updatedTask })
// 向外部发送链接删除事件
emit('link-deleted', event)
}
// 处理里程碑双击事件
function handleMilestoneDoubleClick(milestone: Milestone) {
// 先触发外部事件,让外部可以自定义处理
emit('milestone-double-click', milestone)
// 根据 useDefaultMilestoneDialog 决定是否打开内置 MilestoneDialog
if (props.useDefaultMilestoneDialog) {
editingMilestone.value = milestone
milestoneDialogVisible.value = true
}
}
// 处理里程碑对话框关闭事件
function handleMilestoneDialogClose() {
milestoneDialogVisible.value = false
editingMilestone.value = null
}
// 处理里程碑对话框删除事件
function handleMilestoneDialogDelete(milestoneId: number) {
// 1. 先从 props.milestones 中删除(自动处理数据)
if (props.milestones) {
removeMilestone(props.milestones, milestoneId)
}
// 2. 触发里程碑删除事件(新的事件驱动 API)
emit('milestone-deleted', { milestoneId })
// 3. 强制更新任务触发器,确保Timeline重新计算
updateTaskTrigger.value++
// 4. 关闭对话框
handleMilestoneDialogClose()
}
// 暴露方法供外部调用
defineExpose({
// 全屏相关
enterFullscreen,
exitFullscreen,
toggleFullscreen,
isFullscreen: getIsFullscreen,
// 展开/收起相关
expandAll: expandAllTasks,
collapseAll: collapseAllTasks,
toggleExpandAll: toggleExpandAllTasks,
isExpandAll: getIsExpandAll,
// 今日定位相关
scrollToToday,
scrollToTask,
scrollToDate,
// 语言切换相关
setLocale,
currentLocale,
// 时间维度相关
setTimeScale,
zoomIn,
zoomOut,
currentScale,
// 主题相关
setTheme,
currentTheme,
})
</script>
<template>
<div
ref="ganttRootRef"
class="gantt-root"
:class="{ 'gantt-fullscreen': isFullscreen, 'splitter-dragging': dragging }"
>
<!-- 工具栏 -->
<GanttToolbar
v-if="props.showToolbar"
:config="props.toolbarConfig"
:time-scale="currentTimeScale"
:theme="currentThemeMode"
:fullscreen="isFullscreen"
:expand-all="getIsExpandAll()"
:on-today-locate="todayLocateHandler"
:on-export-csv="csvExportHandler"
:on-export-pdf="pdfExportHandler"
:on-language-change="props.onLanguageChange"
:on-theme-change="props.onThemeChange"
:on-fullscreen-change="props.onFullscreenChange"
:on-time-scale-change="handleTimeScaleChange"
:on-expand-all="handleExpandAll"
:on-collapse-all="handleCollapseAll"
@add-task="handleToolbarAddTask"
@add-milestone="milestoneAddHandler"
@expand-all="handleExpandAll"
@collapse-all="handleCollapseAll"
/>
<!-- 甘特图主体 -->
<div class="gantt-body">
<div
v-if="isTaskListVisible"
class="gantt-panel gantt-panel-left"
:style="{ width: leftPanelWidth + 'px' }"
>
<TaskList
:tasks="tasksForTaskList"
:use-default-drawer="props.useDefaultDrawer"
:task-list-config="props.taskListConfig"
:task-list-column-render-mode="props.taskListColumnRenderMode"
:enable-task-row-move="props.enableTaskRowMove"
:task-list-row-class-name="props.taskListRowClassName"
:task-list-row-style="props.taskListRowStyle"
@task-collapse-change="handleTaskCollapseChange"
@start-timer="handleStartTimer"
@stop-timer="handleStopTimer"
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@delete="handleTaskDelete"
@task-row-moved="handleTaskRowMoved"
>
<!-- 传递默认 slot (用于声明式列定义) -->
<template v-if="$slots.default" #default>
<slot />
</template>
<!-- 传递 custom-task-content slot -->
<template v-if="$slots['custom-task-content']" #custom-task-content="rowScope">
<slot name="custom-task-content" v-bind="rowScope" />
</template>
</TaskList>
</div>
<div class="gantt-splitter" @mousedown="onMouseDown">
<!-- TaskList切换按钮 - 贴合splitter右侧 -->
<div
class="task-list-toggle"
:title="isTaskListVisible ? collapseTaskListText : expandTaskListText"
:class="{
collapsed: !isTaskListVisible,
[animationClass]: isAnimating,
}"
@click.stop="toggleTaskList"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<!-- 收起图标 (显示任务列表时) -->
<polyline v-if="isTaskListVisible" points="15,18 9,12 15,6" />
<!-- 展开图标 (隐藏任务列表时) -->
<polyline v-else points="9,18 15,12 9,6" />
</svg>
</div>
</div>
<div class="gantt-panel gantt-panel-right" :class="{ 'full-width': !isTaskListVisible }">
<Timeline
ref="timelineRef"
:tasks="tasksForTimeline"
:milestones="milestonesForTimeline"
:start-date="timelineDateRange.min"
:end-date="timelineDateRange.max"
:working-hours="props.workingHours"
:task-bar-config="props.taskBarConfig"
:allow-drag-and-resize="props.allowDragAndResize"
:use-default-drawer="props.useDefaultDrawer"
:use-default-milestone-dialog="props.useDefaultMilestoneDialog"
:on-milestone-save="handleMilestoneSave"
@timeline-scale-changed="handleTimelineScaleChanged"
@click-task="handleTimelineClickTask"
@edit-task="handleTimelineEditTask"
@milestone-double-click="handleMilestoneDoubleClick"
@start-timer="handleStartTimer"
@stop-timer="handleStopTimer"
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@predecessor-added="handlePredecessorAdded"
@successor-added="handleSuccessorAdded"
@delete="handleTaskDelete"
@link-deleted="handleLinkDeleted"
>
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
<slot name="custom-task-content" v-bind="barScope" />
</template>
</Timeline>
<!-- 关闭聚焦按钮 - 固定在gantt-panel-right底部居中 -->
<div
v-if="showCloseButton"
class="focus-close-button"
@click.stop="handleClearHighlight"
>
<svg
class="close-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 6L6 18M6 6l12 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span class="close-text">{{ t.disableTaskbarFocusMode }}</span>
</div>
</div>
</div>
<!-- 任务抽屉组件 - 用于添加前置任务 -->
<TaskDrawer
v-if="props.useDefaultDrawer"
v-model:visible="taskDrawerVisible"
:task="taskDrawerTask"
:is-edit="taskDrawerEditMode"
:assignee-options="props.assigneeOptions"
@submit="handleTaskDrawerSubmit"
@close="taskDrawerVisible = false"
@start-timer="handleStartTimer"
@stop-timer="handleStopTimer"
@delete="handleTaskDelete"
/>
<!-- 里程碑对话框组件 - 用于编辑里程碑 -->
<MilestoneDialog
v-if="props.useDefaultMilestoneDialog"
v-model:visible="milestoneDialogVisible"
:milestone="editingMilestone"
@close="handleMilestoneDialogClose"
@save="handleMilestoneSave"
@delete="handleMilestoneDialogDelete"
/>
</div>
</template>
<style scoped>
@import '../styles/theme-variables.css';
.gantt-root {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: #f5f7fa;
box-shadow:
0 6px 32px 0 rgba(0, 0, 0, 0.1),
0 1.5px 6px 0 rgba(0, 0, 0, 0.08);
border-radius: 12px;
overflow: hidden;
}
.gantt-body {
display: flex;
flex: 1;
min-height: 0;
}
.gantt-panel {
height: 100%;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
}
.gantt-panel-left {
/* width 由js控制 */
min-width: 320px;
transition: width 0.1s;
}
.gantt-panel-right {
flex: 1;
min-width: 0;
position: relative; /* 为渐隐覆盖层定位 */
}
.gantt-panel-right.full-width {
flex: 1;
width: 100%;
}
/* 关闭聚焦按钮 - 固定在gantt-panel-right底部居中 */
.focus-close-button {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1004;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: #f56c6c;
border: 1px solid #f56c6c;
border-radius: 20px;
box-shadow: 0 4px 12px rgba(245, 108, 108, 0.4);
cursor: pointer;
transition: all 0.3s ease;
user-select: none;
}
.focus-close-button:hover {
background: #f78989;
border-color: #f78989;
box-shadow: 0 6px 16px rgba(245, 108, 108, 0.5);
transform: translateX(-50%) translateY(-2px);
}
.focus-close-button .close-icon {
width: 18px;
height: 18px;
color: #ffffff;
transition: color 0.3s ease;
}
.focus-close-button:hover .close-icon {
color: #ffffff;
}
.focus-close-button .close-text {
font-size: 14px;
color: #ffffff;
font-weight: 500;
transition: color 0.3s ease;
}
.focus-close-button:hover .close-text {
color: #ffffff;
}
.gantt-splitter {
position: relative; /* 添加相对定位以支持绝对定位的子元素 */
width: 6px;
cursor: col-resize;
background: var(--gantt-border-light, #e4e7ed);
transition: all 0.2s ease;
z-index: 999;
/* 禁用文本选择和拖拽干扰 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
touch-action: none; /* 禁用触摸事件 */
}
.gantt-splitter:hover {
background: var(--gantt-border-dark, #c0c4cc);
}
.placeholder {
color: #909399;
text-align: center;
margin: auto;
font-size: 18px;
}
/* 左侧撞击动画 */
@keyframes slideLeftImpact {
0% {
transform: translateX(0);
}
20% {
transform: translateX(-8px);
}
40% {
transform: translateX(2px);
}
60% {
transform: translateX(-3px);
}
80% {
transform: translateX(1px);
}
100% {
transform: translateX(0);
}
}
@keyframes slideRightImpact {
0% {
transform: translateX(0);
}
20% {
transform: translateX(8px);
}
40% {
transform: translateX(-2px);
}
60% {
transform: translateX(3px);
}
80% {
transform: translateX(-1px);
}
100% {
transform: translateX(0);
}
}
/* TaskList切换按钮样式 */
.task-list-toggle {
position: absolute;
top: 0px; /* 紧贴splitter顶端 */
left: 6px; /* 贴合splitter右侧,稍微调整以更好地附着 */
width: 15px;
height: 35px; /* 稍微增加高度,让按钮更明显 */
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.95);
border: 1px solid #d9d9d9;
border-radius: 0 6px 6px 0; /* 右侧圆角,左侧直角贴合splitter */
cursor: pointer;
z-index: 1000;
color: #666;
transition: all 0.2s ease;
backdrop-filter: blur(4px);
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.15);
}
.task-list-toggle.collapsed:hover {
box-shadow: -1px 2px 8px rgba(64, 158, 255, 0.25);
}
.task-list-toggle:active {
transform: scale(1);
}
.task-list-toggle svg {
transition: transform 0.2s ease;
}
.task-list-toggle:hover svg {
transform: scale(1.1);
}
/* 暗色主题支持 */
:global(html[data-theme='dark']) .gantt-root {
background: #1e1e1e !important;
color: #e5e5e5 !important;
}
:global(html[data-theme='dark']) .gantt-panel {
background: #2c2c2c !important;
}
:global(html[data-theme='dark']) .gantt-panel-left {
border-right-color: #4c4c4c !important;
}
:global(html[data-theme='dark']) .focus-close-button {
background: #d85555;
border-color: #d85555;
box-shadow: 0 4px 12px rgba(216, 85, 85, 0.4);
}
:global(html[data-theme='dark']) .focus-close-button:hover {
background: #e67676;
border-color: #e67676;
box-shadow: 0 6px 16px rgba(216, 85, 85, 0.5);
}
:global(html[data-theme='dark']) .focus-close-button .close-icon,
:global(html[data-theme='dark']) .focus-close-button .close-text {
color: #ffffff;
}
:global(html[data-theme='dark']) .focus-close-button:hover .close-icon,
:global(html[data-theme='dark']) .focus-close-button:hover .close-text {
color: #ffffff;
}
:global(html[data-theme='dark']) .gantt-splitter {
background: linear-gradient(
to right,
transparent,
var(--gantt-border-dark, #666666) 20%,
var(--gantt-border-dark, #666666) 80%,
transparent
) !important;
box-shadow: inset 0 0 2px rgba(255, 255, 255, 0.1) !important;
}
:global(html[data-theme='dark']) .gantt-splitter:hover {
background: linear-gradient(
to right,
transparent,
var(--gantt-primary, #409eff) 20%,
var(--gantt-primary, #409eff) 80%,
transparent
) !important;
border-left-color: var(--gantt-primary, #409eff) !important;
border-right-color: var(--gantt-primary, #409eff) !important;
box-shadow:
inset 0 0 4px rgba(64, 158, 255, 0.3),
0 0 8px rgba(64, 158, 255, 0.2) !important;
}
/* 暗色主题支持 - TaskList切换按钮 */
:global(html[data-theme='dark']) .task-list-toggle {
background: rgba(42, 42, 42, 0.95) !important;
border-color: #555555 !important;
color: #cccccc !important;
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.4) !important;
}
:global(html[data-theme='dark']) .task-list-toggle.collapsed {
box-shadow: -1px 1px 4px rgba(0, 0, 0, 0.4) !important;
}
:global(html[data-theme='dark']) .task-list-toggle:hover {
background: rgba(42, 42, 42, 1) !important;
color: #569cd6 !important;
box-shadow: 1px 2px 8px rgba(86, 156, 214, 0.3) !important;
}
:global(html[data-theme='dark']) .task-list-toggle.collapsed:hover {
box-shadow: -1px 2px 8px rgba(86, 156, 214, 0.3) !important;
}
/* 撞击动画 */
.task-list-toggle.impact-left {
animation: slideLeftImpact 0.4s ease-out;
}
.task-list-toggle.impact-right {
animation: slideRightImpact 0.4s ease-out;
}
/* 全屏模式样式 */
.gantt-fullscreen {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 9999 !important;
background: #f5f7fa !important;
border-radius: 0 !important;
box-shadow: none !important;
margin: 0 !important;
padding: 0 !important;
transition: all 0.3s ease-in-out;
animation: ganttFullscreenEnter 0.3s ease-out;
}
@keyframes ganttFullscreenEnter {
0% {
transform: scale(0.9);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* 全屏模式下的甘特图主体 */
.gantt-fullscreen .gantt-body {
height: calc(100vh - 60px); /* 减去工具栏高度 */
}
/* 全屏模式下的工具栏 */
.gantt-fullscreen .gantt-toolbar {
border-radius: 0 !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
}
/* 暗色主题下的全屏模式 */
:global(html[data-theme='dark']) .gantt-fullscreen {
background: #1e1e1e !important;
}
:global(html[data-theme='dark']) .gantt-fullscreen .gantt-toolbar {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important;
}
/* 拖拽分割器时的保护样式 */
.gantt-root.splitter-dragging {
/* 禁用整个甘特图的文本选择 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.gantt-root.splitter-dragging .gantt-panel-right {
/* 拖拽时高亮右侧面板 */
background: rgba(255, 255, 255, 0.1);
}
.gantt-root.splitter-dragging * {
/* 强制所有元素使用col-resize光标 */
cursor: col-resize !important;
}
</style>