1974 lines
60 KiB
Vue
1974 lines
60 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onUnmounted, onMounted, computed, watch, nextTick, defineEmits } from 'vue'
|
|
import TaskList from './TaskList.vue'
|
|
import Timeline from './Timeline.vue'
|
|
import GanttToolbar from './GanttToolbar.vue'
|
|
import TaskDrawer from './TaskDrawer.vue'
|
|
import { useI18n, setCustomMessages } from '../composables/useI18n'
|
|
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
|
|
import jsPDF from 'jspdf'
|
|
import html2canvas from 'html2canvas'
|
|
import type { Task } from '../models/classes/Task'
|
|
import type { Milestone } from '../models/classes/Milestone'
|
|
import type { ToolbarConfig } from '../models/configs/ToolbarConfig'
|
|
import type { TaskListConfig } from '../models/configs/TaskListConfig'
|
|
import { TimelineScale } from '../models/types/TimelineScale'
|
|
import { useMessage } from '../composables/useMessage'
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
tasks: () => [],
|
|
milestones: () => [],
|
|
onTaskDoubleClick: undefined,
|
|
editComponent: undefined,
|
|
useDefaultDrawer: true,
|
|
onTaskDelete: undefined,
|
|
onMilestoneSave: undefined,
|
|
onMilestoneDelete: undefined,
|
|
onTaskUpdate: undefined,
|
|
onTaskAdd: undefined,
|
|
onMilestoneIconChange: undefined,
|
|
toolbarConfig: () => ({}),
|
|
showToolbar: true,
|
|
onAddTask: undefined,
|
|
onAddMilestone: undefined,
|
|
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,
|
|
})
|
|
|
|
const emit = defineEmits([
|
|
'taskbar-drag-end',
|
|
'taskbar-resize-end',
|
|
'milestone-drag-end',
|
|
'timer-started',
|
|
'timer-stopped',
|
|
'predecessor-added',
|
|
'successor-added',
|
|
'task-deleted',
|
|
'task-added',
|
|
'task-updated',
|
|
])
|
|
|
|
const { showMessage } = useMessage()
|
|
|
|
interface Props {
|
|
// 任务数据
|
|
tasks?: Task[]
|
|
// 里程碑数据
|
|
milestones?: Task[]
|
|
// TaskBar双击事件处理器API
|
|
onTaskDoubleClick?: (task: Task) => void
|
|
// 自定义编辑组件
|
|
editComponent?: any
|
|
// 是否使用默认的TaskDrawer
|
|
useDefaultDrawer?: boolean
|
|
// 自定义删除处理器API
|
|
onTaskDelete?: (task: Task, deleteChildren?: boolean) => void
|
|
// 里程碑保存处理器API
|
|
onMilestoneSave?: (milestone: Task) => void
|
|
// 里程碑删除处理器API
|
|
onMilestoneDelete?: (milestoneId: number) => void
|
|
// 任务更新处理器API
|
|
onTaskUpdate?: (task: Task) => void
|
|
// 任务添加处理器API
|
|
onTaskAdd?: (task: Task) => void
|
|
// 里程碑图标变更处理器API
|
|
onMilestoneIconChange?: (milestoneId: number, icon: string) => void
|
|
// 工具栏配置
|
|
toolbarConfig?: ToolbarConfig
|
|
// 是否显示工具栏
|
|
showToolbar?: boolean
|
|
// 工具栏事件处理器
|
|
onAddTask?: () => void
|
|
onAddMilestone?: () => void
|
|
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
|
|
/**
|
|
* 自定义多语言(国际化)配置,结构参考内置 messages['zh-CN'],只需传递需要覆盖的 key 即可。
|
|
* 例如:
|
|
* localeMessages={{ taskName: '自定义任务名', addTask: '自定义新增任务' }}
|
|
* 支持嵌套对象(如 csvHeaders、taskTypeMap 等)。
|
|
* 仅在组件初始化时合并,运行时变更会自动响应。
|
|
*/
|
|
localeMessages?: Partial<import('../composables/useI18n').Messages['zh-CN']>
|
|
// 工作时间配置
|
|
workingHours?: {
|
|
morning?: { start: number; end: number } // 上午工作时间,如 { start: 8, end: 11 }
|
|
afternoon?: { start: number; end: number } // 下午工作时间,如 { start: 13, end: 17 }
|
|
}
|
|
// 任务列表配置
|
|
taskListConfig?: TaskListConfig
|
|
}
|
|
|
|
const leftPanelWidth = ref(320)
|
|
|
|
// Timeline组件的引用
|
|
const timelineRef = ref<InstanceType<typeof Timeline> | null>(null)
|
|
|
|
// 时间刻度状态
|
|
const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
|
|
|
|
watch(
|
|
() => timelineRef.value,
|
|
newTimeline => {
|
|
if (newTimeline) {
|
|
newTimeline.updateTimeScale(currentTimeScale.value)
|
|
}
|
|
},
|
|
)
|
|
|
|
// TaskList的固定总长度(所有列的最小宽度之和 + 边框等额外空间)
|
|
// 列宽: 300+120+120+140+140+100+100+100 = 1120px
|
|
// 边框: 7个列间边框 * 1px = 7px
|
|
// 滚动条预留: 20px
|
|
// 额外边距: 13px (task-list-header的左边距3px + 其他10px预留)
|
|
const TASK_LIST_MAX_WIDTH = 1120 + 7 + 20 + 13 // = 1160px
|
|
const TASK_LIST_MIN_WIDTH = 320 // TaskList最小宽度
|
|
|
|
const taskListBodyWidth = ref(TASK_LIST_MAX_WIDTH) // TaskList默认宽度
|
|
const ganttPanelLeftMinWidth = ref(TASK_LIST_MIN_WIDTH) // 左侧面板最小宽度
|
|
const ganttPanelLeftCurrentWidth = ref(TASK_LIST_MIN_WIDTH) // 当前左侧面板宽度
|
|
const taskListBodyProposedWidth = ref(TASK_LIST_MAX_WIDTH)
|
|
const taskListBodyWidthLimit = ref(TASK_LIST_MAX_WIDTH)
|
|
|
|
// 简化的限制检查函数:直接基于面板实际宽度判断
|
|
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)
|
|
}
|
|
}
|
|
|
|
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, taskListBodyWidth.value)
|
|
|
|
// 广播拖拽开始事件,通知其他组件暂停悬停效果
|
|
window.dispatchEvent(new CustomEvent('splitter-drag-start'))
|
|
|
|
// 在拖拽期间禁用页面选择
|
|
document.body.style.userSelect = 'none'
|
|
document.body.style.webkitUserSelect = 'none'
|
|
document.body.style.cursor = 'col-resize'
|
|
|
|
function onMouseMove(ev: MouseEvent) {
|
|
if (!dragging.value) return
|
|
|
|
// 阻止默认行为,防止滚动等
|
|
ev.preventDefault()
|
|
ev.stopPropagation()
|
|
|
|
const delta = ev.clientX - startX
|
|
const proposedWidth = startWidth + delta
|
|
|
|
// 直接使用面板宽度限制检查,无需复杂的坐标计算
|
|
const finalWidth = checkWidthLimits(proposedWidth)
|
|
leftPanelWidth.value = finalWidth
|
|
}
|
|
|
|
function onMouseUp() {
|
|
dragging.value = false
|
|
|
|
// 广播拖拽结束事件,通知其他组件恢复悬停效果
|
|
window.dispatchEvent(new CustomEvent('splitter-drag-end'))
|
|
|
|
// 恢复页面选择和光标
|
|
document.body.style.userSelect = ''
|
|
document.body.style.webkitUserSelect = ''
|
|
document.body.style.cursor = ''
|
|
|
|
taskListBodyWidth.value = TASK_LIST_MAX_WIDTH // TaskList默认宽度
|
|
ganttPanelLeftMinWidth.value = TASK_LIST_MIN_WIDTH // 左侧面板最小宽度
|
|
taskListBodyProposedWidth.value = TASK_LIST_MAX_WIDTH
|
|
taskListBodyWidthLimit.value = TASK_LIST_MAX_WIDTH
|
|
ganttPanelLeftCurrentWidth.value = TASK_LIST_MIN_WIDTH // 当前左侧面板宽度
|
|
|
|
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 传递上来的拖拽/拉伸事件,并通过 props 回调暴露 ---
|
|
function handleTaskBarDragEnd(event: CustomEvent) {
|
|
emit('taskbar-drag-end', event.detail)
|
|
}
|
|
function handleTaskBarResizeEnd(event: CustomEvent) {
|
|
emit('taskbar-resize-end', event.detail)
|
|
}
|
|
function handleMilestoneDragEnd(event: CustomEvent) {
|
|
emit('milestone-drag-end', event.detail)
|
|
}
|
|
|
|
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)
|
|
})
|
|
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)
|
|
})
|
|
|
|
// 处理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++
|
|
}
|
|
|
|
// 全部展开任务
|
|
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++
|
|
}
|
|
}
|
|
}
|
|
|
|
// 全部折叠任务
|
|
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++
|
|
}
|
|
}
|
|
}
|
|
|
|
// 用于强制触发Timeline重新计算的响应式值
|
|
const updateTaskTrigger = ref(0)
|
|
|
|
// 处理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('task-updated', handleTaskUpdate as EventListener)
|
|
window.addEventListener('task-added', handleTaskAdd as EventListener)
|
|
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('task-updated', handleTaskUpdate as EventListener)
|
|
window.removeEventListener('task-added', handleTaskAdd 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 isFullscreen = ref(false)
|
|
|
|
// 多语言支持
|
|
const { t, locale } = useI18n()
|
|
const collapseTaskListText = computed(() => t.value.collapseTaskList)
|
|
const expandTaskListText = computed(() => t.value.expandTaskList)
|
|
|
|
// 为TaskList提供层级数据(保持原始层级结构,只扁平化里程碑)
|
|
const tasksForTaskList = computed(() => {
|
|
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) {
|
|
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 (!props.milestones) return []
|
|
|
|
// 过滤出有startDate的里程碑,并转换为Milestone类型
|
|
return 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,
|
|
}))
|
|
})
|
|
|
|
// 计算所有任务和里程碑的最小开始时间和最大结束时间
|
|
const timelineDateRange = computed(() => {
|
|
// 扁平化所有任务和子任务
|
|
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!))
|
|
|
|
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 }
|
|
}
|
|
|
|
// 找到最小开始和最大结束
|
|
const minDate = new Date(Math.min(...startDates.map(d => d.getTime())))
|
|
const maxDate = new Date(Math.max(...endDates.map(d => d.getTime())))
|
|
|
|
// 日视图前后各延伸6个月
|
|
let min = new Date(minDate.getFullYear(), minDate.getMonth() - 6, 1)
|
|
let max = new Date(maxDate.getFullYear(), maxDate.getMonth() + 6 + 1, 0)
|
|
if (currentTimeScale.value === TimelineScale.WEEK) {
|
|
// 月视图Timeline周期为往前1年~往后1年
|
|
min = new Date(minDate.getFullYear() - 1, minDate.getMonth(), 1)
|
|
max = new Date(maxDate.getFullYear() + 1, maxDate.getMonth() + 1, 0)
|
|
} else if (currentTimeScale.value === TimelineScale.MONTH) {
|
|
// 月视图Timeline周期为往前2年~往后2年
|
|
min = new Date(minDate.getFullYear() - 2, minDate.getMonth(), 1)
|
|
max = new Date(maxDate.getFullYear() + 2, maxDate.getMonth() + 1, 0)
|
|
}
|
|
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)
|
|
})
|
|
}
|
|
|
|
// 默认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(locale.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)
|
|
}
|
|
}
|
|
|
|
// 处理里程碑保存事件
|
|
const handleMilestoneSave = (milestone: Task) => {
|
|
// 调用外部传入的里程碑保存处理器
|
|
if (props.onMilestoneSave && typeof props.onMilestoneSave === 'function') {
|
|
props.onMilestoneSave(milestone)
|
|
}
|
|
|
|
// 如果没有外部处理器,执行默认的更新逻辑
|
|
// 这里可以添加默认的里程碑数据更新逻辑,比如更新本地数据或者触发重新渲染
|
|
}
|
|
|
|
// 处理任务更新事件
|
|
const handleTaskUpdate = (event: CustomEvent) => {
|
|
const updatedTask = event.detail
|
|
|
|
// 如果更新的是task类型且有parentId,需要同时更新对应的story进度
|
|
if (updatedTask.type === 'task' && updatedTask.parentId) {
|
|
const updatedStory = calculateStoryProgress(updatedTask.parentId, updatedTask)
|
|
|
|
// 先调用外部的任务更新处理器更新子任务
|
|
if (props.onTaskUpdate && typeof props.onTaskUpdate === 'function') {
|
|
props.onTaskUpdate(updatedTask)
|
|
}
|
|
|
|
// 如果story进度有变化,也更新story
|
|
if (updatedStory && props.onTaskUpdate && typeof props.onTaskUpdate === 'function') {
|
|
props.onTaskUpdate(updatedStory)
|
|
}
|
|
} else if (props.onTaskUpdate && typeof props.onTaskUpdate === 'function') {
|
|
// 普通任务更新
|
|
props.onTaskUpdate(updatedTask)
|
|
}
|
|
|
|
// 关键:任务更新后强制刷新Timeline时间轴
|
|
updateTaskTrigger.value++
|
|
}
|
|
|
|
// 计算story的进度(根据其下所有task的进度计算)
|
|
const calculateStoryProgress = (storyId: number, updatedTask?: Task): Task | null => {
|
|
// 获取所有任务的扁平列表
|
|
const allTasks = [...(props.tasks || [])]
|
|
const flatTasks: Task[] = []
|
|
|
|
const flattenTasks = (tasks: Task[]) => {
|
|
tasks.forEach(task => {
|
|
flatTasks.push(task)
|
|
if (task.children && task.children.length > 0) {
|
|
flattenTasks(task.children)
|
|
}
|
|
})
|
|
}
|
|
|
|
flattenTasks(allTasks)
|
|
|
|
// 找到对应的story
|
|
const storyTask = flatTasks.find(task => task.id === storyId && task.type === 'story')
|
|
if (!storyTask) return null
|
|
|
|
// 获取该story下所有的task
|
|
let childTasks = flatTasks.filter(task => task.parentId === storyId && task.type === 'task')
|
|
if (childTasks.length === 0) return null
|
|
|
|
// 如果有正在更新的task,使用最新的数据替换旧数据
|
|
if (updatedTask && updatedTask.type === 'task' && updatedTask.parentId === storyId) {
|
|
childTasks = childTasks.map(task => (task.id === updatedTask.id ? updatedTask : task))
|
|
}
|
|
|
|
// 计算平均进度
|
|
const totalProgress = childTasks.reduce((sum, task) => sum + (task.progress || 0), 0)
|
|
const avgProgress = Math.round(totalProgress / childTasks.length)
|
|
|
|
// 如果进度有变化,返回更新后的story
|
|
if (storyTask.progress !== avgProgress) {
|
|
return { ...storyTask, progress: avgProgress }
|
|
}
|
|
|
|
// 进度没有变化,返回null
|
|
return null
|
|
}
|
|
|
|
// 处理任务添加事件
|
|
const handleTaskAdd = (event: CustomEvent) => {
|
|
const newTask = event.detail
|
|
if (props.onTaskAdd && typeof props.onTaskAdd === 'function') {
|
|
props.onTaskAdd(newTask)
|
|
}
|
|
// 关键:任务新增后强制刷新Timeline时间轴
|
|
updateTaskTrigger.value++
|
|
}
|
|
|
|
// 处理里程碑图标变更事件
|
|
const handleMilestoneIconChangeEvent = (event: CustomEvent) => {
|
|
const { milestoneId, icon } = event.detail
|
|
if (props.onMilestoneIconChange && typeof props.onMilestoneIconChange === 'function') {
|
|
props.onMilestoneIconChange(milestoneId, icon)
|
|
}
|
|
}
|
|
|
|
// 处理里程碑删除事件
|
|
const handleMilestoneDeleted = (event: CustomEvent) => {
|
|
const { milestoneId } = event.detail
|
|
|
|
// 调用外部的里程碑删除处理器
|
|
if (props.onMilestoneDelete && typeof props.onMilestoneDelete === 'function') {
|
|
props.onMilestoneDelete(milestoneId)
|
|
}
|
|
|
|
// 强制更新任务触发器,确保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 = () => {
|
|
// 如果有外部处理器,先调用它
|
|
if (props.onAddMilestone && typeof props.onAddMilestone === 'function') {
|
|
props.onAddMilestone()
|
|
return
|
|
}
|
|
|
|
// 使用默认里程碑添加实现
|
|
defaultAddMilestone()
|
|
}
|
|
|
|
// 默认里程碑添加功能
|
|
const defaultAddMilestone = () => {
|
|
// 派发全局事件,让其他组件处理
|
|
window.dispatchEvent(new CustomEvent('add-milestone'))
|
|
}
|
|
|
|
// 默认今日定位功能
|
|
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
|
|
|
|
// 滚动到指定位置
|
|
const timeline = document.querySelector('.timeline') as HTMLElement
|
|
if (timeline) {
|
|
timeline.scrollLeft = Math.max(0, scrollPosition)
|
|
|
|
// 添加今日高亮效果到对应的日期列
|
|
const todayColumn = timeline.querySelector('.day-column.today') as HTMLElement
|
|
if (todayColumn) {
|
|
todayColumn.classList.add('today-highlight')
|
|
setTimeout(() => {
|
|
todayColumn.classList.remove('today-highlight')
|
|
}, 2000)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 窗口大小变化处理函数
|
|
const handleWindowResize = () => {
|
|
// 直接检查当前宽度是否仍然有效,如果超出限制则调整
|
|
const adjustedWidth = checkWidthLimits(leftPanelWidth.value)
|
|
if (adjustedWidth !== leftPanelWidth.value) {
|
|
leftPanelWidth.value = adjustedWidth
|
|
}
|
|
}
|
|
|
|
// 设置自定义多语言消息
|
|
|
|
if (props.localeMessages) {
|
|
setCustomMessages(locale.value, props.localeMessages)
|
|
}
|
|
watch(
|
|
() => props.localeMessages,
|
|
val => {
|
|
if (val) setCustomMessages(locale.value, val)
|
|
},
|
|
{ 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)
|
|
|
|
// 添加前置任务功能相关变量
|
|
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() {
|
|
// 构造一个空的新任务对象
|
|
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 handleTimelineEditTask(task: Task) {
|
|
taskDrawerTask.value = task
|
|
taskDrawerEditMode.value = true
|
|
taskDrawerVisible.value = true
|
|
}
|
|
|
|
// 处理添加前置任务事件
|
|
function handleAddPredecessor(targetTask: Task) {
|
|
if (!targetTask) return
|
|
|
|
// 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
|
|
// 记录要添加后置任务的目标任务
|
|
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
|
|
}
|
|
|
|
// 新增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 })
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="gantt-root"
|
|
:class="{ 'gantt-fullscreen': isFullscreen, 'splitter-dragging': dragging }"
|
|
>
|
|
<!-- 工具栏 -->
|
|
<GanttToolbar
|
|
v-if="props.showToolbar"
|
|
:config="props.toolbarConfig"
|
|
:on-add-task="props.onAddTask"
|
|
:on-add-milestone="milestoneAddHandler"
|
|
: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"
|
|
@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"
|
|
:on-task-double-click="props.onTaskDoubleClick"
|
|
:edit-component="props.editComponent"
|
|
:use-default-drawer="props.useDefaultDrawer"
|
|
:task-list-config="props.taskListConfig"
|
|
@task-collapse-change="handleTaskCollapseChange"
|
|
@start-timer="handleStartTimer"
|
|
@stop-timer="handleStopTimer"
|
|
@add-predecessor="handleAddPredecessor"
|
|
@add-successor="handleAddSuccessor"
|
|
@delete="handleTaskDelete"
|
|
>
|
|
<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"
|
|
:on-task-double-click="props.onTaskDoubleClick"
|
|
:edit-component="props.editComponent"
|
|
:use-default-drawer="props.useDefaultDrawer"
|
|
:on-milestone-save="handleMilestoneSave"
|
|
@timeline-scale-changed="handleTimelineScaleChanged"
|
|
@edit-task="handleTimelineEditTask"
|
|
@start-timer="handleStartTimer"
|
|
@stop-timer="handleStopTimer"
|
|
@add-predecessor="handleAddPredecessor"
|
|
@add-successor="handleAddSuccessor"
|
|
@delete="handleTaskDelete"
|
|
>
|
|
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
|
|
<slot name="custom-task-content" v-bind="barScope" />
|
|
</template>
|
|
</Timeline>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 任务抽屉组件 - 用于添加前置任务 -->
|
|
<TaskDrawer
|
|
v-if="props.useDefaultDrawer"
|
|
v-model:visible="taskDrawerVisible"
|
|
:task="taskDrawerTask"
|
|
:is-edit="taskDrawerEditMode"
|
|
@submit="handleTaskDrawerSubmit"
|
|
@close="taskDrawerVisible = false"
|
|
@start-timer="handleStartTimer"
|
|
@stop-timer="handleStopTimer"
|
|
@delete="handleTaskDelete"
|
|
/>
|
|
</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-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']) .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>
|