v1.7.0 - add TaskListContextMenu and TaskBarContextMenu components & slots
This commit is contained in:
@@ -13,6 +13,8 @@ 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 {
|
||||
@@ -54,7 +56,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
assigneeOptions: () => [],
|
||||
taskListRowClassName: undefined,
|
||||
taskListRowStyle: undefined,
|
||||
useDefaultContextMenu: true,
|
||||
enableTaskListContextMenu: true,
|
||||
enableTaskBarContextMenu: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -94,29 +97,24 @@ const slots = useSlots()
|
||||
provide('gantt-column-slots', slots)
|
||||
|
||||
// 提供右键菜单配置给子组件
|
||||
provide('use-default-context-menu', computed(() => props.useDefaultContextMenu))
|
||||
provide('enable-task-list-context-menu', computed(() => props.enableTaskListContextMenu))
|
||||
provide('enable-task-bar-context-menu', computed(() => props.enableTaskBarContextMenu))
|
||||
|
||||
// 计算是否使用自定义 TaskList 右键菜单
|
||||
const hasTaskListContextMenuSlot = computed(() => Boolean(slots['task-list-context-menu']))
|
||||
const shouldUseCustomTaskListContextMenu = computed(() => {
|
||||
if (!props.useDefaultContextMenu) {
|
||||
return hasTaskListContextMenuSlot.value
|
||||
}
|
||||
return hasTaskListContextMenuSlot.value
|
||||
})
|
||||
// 使用声明式右键菜单 composables
|
||||
const { hasDeclarativeContextMenu: hasDeclarativeTaskListContextMenu, declarativeContextMenu: declarativeTaskListContextMenu } =
|
||||
useTaskListContextMenu(slots)
|
||||
const { hasDeclarativeContextMenu: hasDeclarativeTaskBarContextMenu, declarativeContextMenu: declarativeTaskBarContextMenu } =
|
||||
useTaskBarContextMenu(slots)
|
||||
|
||||
// 计算是否使用自定义 TaskBar 右键菜单
|
||||
const hasTaskBarContextMenuSlot = computed(() => Boolean(slots['task-bar-context-menu']))
|
||||
const shouldUseCustomTaskBarContextMenu = computed(() => {
|
||||
if (!props.useDefaultContextMenu) {
|
||||
return hasTaskBarContextMenuSlot.value
|
||||
}
|
||||
return hasTaskBarContextMenuSlot.value
|
||||
})
|
||||
// 计算是否使用自定义 TaskList 右键菜单(声明式方式)
|
||||
const hasTaskListContextMenuSlot = computed(() => hasDeclarativeTaskListContextMenu.value)
|
||||
provide('task-list-context-menu-slot', hasTaskListContextMenuSlot)
|
||||
provide('declarative-task-list-context-menu', declarativeTaskListContextMenu)
|
||||
|
||||
// 提供右键菜单 slot 给子组件
|
||||
provide('task-list-context-menu-slot', computed(() => shouldUseCustomTaskListContextMenu.value))
|
||||
provide('task-bar-context-menu-slot', computed(() => shouldUseCustomTaskBarContextMenu.value))
|
||||
// 计算是否使用自定义 TaskBar 右键菜单(声明式方式)
|
||||
const hasTaskBarContextMenuSlot = computed(() => hasDeclarativeTaskBarContextMenu.value)
|
||||
provide('task-bar-context-menu-slot', hasTaskBarContextMenuSlot)
|
||||
provide('declarative-task-bar-context-menu', declarativeTaskBarContextMenu)
|
||||
|
||||
interface Props {
|
||||
// 任务数据
|
||||
@@ -181,10 +179,16 @@ interface Props {
|
||||
// 任务行自定义样式,支持对象或函数,优先级高于 taskListRowClassName
|
||||
// 函数形式:(row: Task, rowIndex: number) => StyleValue
|
||||
taskListRowStyle?: StyleValue | ((row: Task, rowIndex: number) => StyleValue)
|
||||
// 是否使用默认的右键菜单(默认为 true)
|
||||
// 当设置为 false 时,将关闭内置的 TaskRow 和 TaskBar 右键菜单
|
||||
// 可以通过 task-list-context-menu 和 task-bar-context-menu 插槽自定义菜单
|
||||
useDefaultContextMenu?: boolean
|
||||
// 是否启用 TaskList(TaskRow)右键菜单(默认为 true)
|
||||
// 当设置为 true 且未声明 TaskListContextMenu 组件时,使用系统默认菜单
|
||||
// 当设置为 true 且声明了 TaskListContextMenu 组件时,使用自定义菜单
|
||||
// 当设置为 false 时,无论是否声明组件,TaskRow 右键菜单都失效
|
||||
enableTaskListContextMenu?: boolean
|
||||
// 是否启用 TaskBar 右键菜单(默认为 true)
|
||||
// 当设置为 true 且未声明 TaskBarContextMenu 组件时,使用系统默认菜单
|
||||
// 当设置为 true 且声明了 TaskBarContextMenu 组件时,使用自定义菜单
|
||||
// 当设置为 false 时,无论是否声明组件,TaskBar 右键菜单都失效
|
||||
enableTaskBarContextMenu?: boolean
|
||||
}
|
||||
|
||||
// TaskList的固定总长度(所有列的最小宽度之和 + 边框等额外空间)
|
||||
@@ -2389,13 +2393,6 @@ function handleMilestoneDialogDelete(milestoneId: number) {
|
||||
<template v-if="$slots['custom-task-content']" #custom-task-content="rowScope">
|
||||
<slot name="custom-task-content" v-bind="rowScope" />
|
||||
</template>
|
||||
<!-- 传递 task-list-context-menu slot -->
|
||||
<template
|
||||
v-if="$slots['task-list-context-menu']"
|
||||
#task-list-context-menu="contextMenuScope"
|
||||
>
|
||||
<slot name="task-list-context-menu" v-bind="contextMenuScope" />
|
||||
</template>
|
||||
</TaskList>
|
||||
</div>
|
||||
<div class="gantt-splitter" @mousedown="onMouseDown">
|
||||
@@ -2454,13 +2451,6 @@ function handleMilestoneDialogDelete(milestoneId: number) {
|
||||
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
|
||||
<slot name="custom-task-content" v-bind="barScope" />
|
||||
</template>
|
||||
<!-- 传递 task-bar-context-menu slot -->
|
||||
<template
|
||||
v-if="$slots['task-bar-context-menu']"
|
||||
#task-bar-context-menu="contextMenuScope"
|
||||
>
|
||||
<slot name="task-bar-context-menu" v-bind="contextMenuScope" />
|
||||
</template>
|
||||
</Timeline>
|
||||
|
||||
<!-- 关闭聚焦按钮 - 固定在gantt-panel-right底部居中 -->
|
||||
|
||||
+68
-50
@@ -17,6 +17,7 @@ interface Props {
|
||||
startDate: Date
|
||||
isParent?: boolean
|
||||
onClick?: (task: Task, event: MouseEvent) => void
|
||||
rowIndex?: number
|
||||
// 新增:用于粘性文字显示的滚动位置信息
|
||||
scrollLeft?: number
|
||||
containerWidth?: number
|
||||
@@ -92,47 +93,50 @@ const emit = defineEmits([
|
||||
|
||||
defineSlots<{
|
||||
'custom-task-content'(props: TaskBarSlotProps): unknown
|
||||
'task-bar-context-menu'(props: {
|
||||
task: Task
|
||||
position: { x: number; y: number }
|
||||
visible: boolean
|
||||
allTasks?: Task[]
|
||||
onClose: () => void
|
||||
onStartTimer: () => void
|
||||
onStopTimer: () => void
|
||||
onAddPredecessor: () => void
|
||||
onAddSuccessor: () => void
|
||||
onDelete: (task: Task, deleteChildren?: boolean) => void
|
||||
onDeleteLink: (event: { sourceTaskId: number; targetTaskId: number }) => void
|
||||
}): unknown
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
// 注入右键菜单配置
|
||||
const useDefaultContextMenu = inject<ComputedRef<boolean>>('use-default-context-menu', computed(() => true))
|
||||
const enableTaskBarContextMenu = inject<ComputedRef<boolean>>('enable-task-bar-context-menu', computed(() => true))
|
||||
const hasTaskBarContextMenuSlot = inject<ComputedRef<boolean>>('task-bar-context-menu-slot', computed(() => false))
|
||||
const declarativeTaskBarContextMenu = inject<ComputedRef<any>>('declarative-task-bar-context-menu', computed(() => null))
|
||||
|
||||
// 判断是否应该显示任何右键菜单
|
||||
const shouldShowAnyContextMenu = computed(() => {
|
||||
// 如果 useDefaultContextMenu 为 false 且没有自定义 slot,则不显示任何菜单
|
||||
if (!useDefaultContextMenu.value && !hasTaskBarContextMenuSlot.value) {
|
||||
// 如果 enableTaskBarContextMenu 为 false,则不显示任何菜单
|
||||
if (!enableTaskBarContextMenu.value) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// 判断是否显示默认右键菜单(没有自定义 slot 时显示)
|
||||
// 判断是否显示默认右键菜单(enableTaskBarContextMenu=true 且没有自定义 slot 时显示)
|
||||
const shouldShowDefaultContextMenu = computed(() => {
|
||||
if (!useDefaultContextMenu.value) {
|
||||
if (!enableTaskBarContextMenu.value) {
|
||||
return false
|
||||
}
|
||||
return !hasTaskBarContextMenuSlot.value
|
||||
})
|
||||
|
||||
// 判断是否显示自定义右键菜单(有自定义 slot 时显示)
|
||||
// 判断是否显示自定义右键菜单(enableTaskBarContextMenu=true 且有自定义 slot 时显示)
|
||||
const shouldShowCustomContextMenu = computed(() => {
|
||||
return hasTaskBarContextMenuSlot.value
|
||||
if (!enableTaskBarContextMenu.value) {
|
||||
return false
|
||||
}
|
||||
if (!hasTaskBarContextMenuSlot.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查 taskType 过滤
|
||||
const config = declarativeTaskBarContextMenu.value
|
||||
if (config?.taskType !== undefined) {
|
||||
const taskType = props.task.type || 'task'
|
||||
const allowedTypes = Array.isArray(config.taskType) ? config.taskType : [config.taskType]
|
||||
return allowedTypes.includes(taskType)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const { getTranslation } = useI18n()
|
||||
@@ -1330,6 +1334,7 @@ onMounted(() => {
|
||||
|
||||
// 监听全局关闭菜单事件
|
||||
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
|
||||
// 清理函数
|
||||
onUnmounted(() => {
|
||||
@@ -1342,12 +1347,19 @@ onMounted(() => {
|
||||
window.removeEventListener('timeline-scale-updated', handleTimelineScaleUpdate)
|
||||
window.removeEventListener('timeline-force-recalculate', handleForceRecalculate)
|
||||
window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
|
||||
// 清除长按定时器
|
||||
if (longPressTimer.value !== null) {
|
||||
clearTimeout(longPressTimer.value)
|
||||
longPressTimer.value = null
|
||||
}
|
||||
|
||||
// 清理单击定时器
|
||||
if (clickTimer !== null) {
|
||||
clearTimeout(clickTimer)
|
||||
clickTimer = null
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2303,7 +2315,8 @@ function handleContextMenu(event: MouseEvent) {
|
||||
|
||||
// 先广播关闭所有TaskBar菜单
|
||||
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
|
||||
if (props.task.type !== 'task' && props.task.type !== 'story') {
|
||||
const taskType = props.task.type || 'task'
|
||||
if (taskType !== 'task' && taskType !== 'story') {
|
||||
// 为了排除里程碑类型
|
||||
event.preventDefault()
|
||||
contextMenuVisible.value = false
|
||||
@@ -2317,6 +2330,20 @@ function closeContextMenu() {
|
||||
contextMenuVisible.value = false
|
||||
}
|
||||
|
||||
// 处理文档点击事件,点击菜单外部时关闭
|
||||
function handleDocumentClick(event: MouseEvent) {
|
||||
if (!contextMenuVisible.value) return
|
||||
|
||||
const target = event.target as HTMLElement
|
||||
// 检查点击是否在右键菜单内部
|
||||
const contextMenuElement = document.querySelector('.task-context-menu')
|
||||
if (contextMenuElement && contextMenuElement.contains(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
|
||||
// 触发删除事件
|
||||
emit('delete', task, deleteChildren)
|
||||
@@ -2370,19 +2397,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
task: props.task,
|
||||
type: anchorEvent.type,
|
||||
})
|
||||
}// 监听全局关闭菜单事件
|
||||
onMounted(() => {
|
||||
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
|
||||
|
||||
// 清理单击定时器
|
||||
if (clickTimer !== null) {
|
||||
clearTimeout(clickTimer)
|
||||
clickTimer = null
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -2594,22 +2609,25 @@ onUnmounted(() => {
|
||||
@delete-link="handleDeleteLink"
|
||||
/>
|
||||
|
||||
<!-- 自定义右键菜单 Slot -->
|
||||
<slot
|
||||
v-if="shouldShowCustomContextMenu"
|
||||
name="task-bar-context-menu"
|
||||
:task="contextMenuTask"
|
||||
:position="contextMenuPosition"
|
||||
:visible="contextMenuVisible"
|
||||
:all-tasks="allTasks"
|
||||
:on-close="closeContextMenu"
|
||||
:on-start-timer="() => $emit('start-timer', props.task)"
|
||||
:on-stop-timer="() => $emit('stop-timer', props.task)"
|
||||
:on-add-predecessor="() => $emit('add-predecessor', props.task)"
|
||||
:on-add-successor="() => $emit('add-successor', props.task)"
|
||||
:on-delete="handleTaskDelete"
|
||||
:on-delete-link="handleDeleteLink"
|
||||
/>
|
||||
<!-- 声明式右键菜单 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="shouldShowCustomContextMenu && contextMenuVisible && declarativeTaskBarContextMenu?.defaultSlot"
|
||||
class="gantt-context-menu-wrapper"
|
||||
:style="{
|
||||
position: 'fixed',
|
||||
left: `${contextMenuPosition.x}px`,
|
||||
top: `${contextMenuPosition.y}px`,
|
||||
zIndex: 9999,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="declarativeTaskBarContextMenu.defaultSlot"
|
||||
:row="contextMenuTask"
|
||||
:$index="props.rowIndex ?? -1"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip 弹窗 -->
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TaskListContextMenu 声明式右键菜单组件
|
||||
* 用于在 GanttChart 中声明式定义 TaskList (TaskRow) 的右键菜单
|
||||
*/
|
||||
|
||||
// 显式定义组件名称,用于识别
|
||||
defineOptions({
|
||||
name: 'TaskListContextMenu',
|
||||
})
|
||||
|
||||
interface Props {
|
||||
// 指定哪些任务类型显示此右键菜单
|
||||
// 不设置时遵循现有逻辑,设置后仅对指定类型任务显示
|
||||
taskType?: string | string[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
// 定义 slot
|
||||
defineSlots<{
|
||||
// 默认插槽,接收菜单上下文对象
|
||||
default?: (scope: {
|
||||
row: any
|
||||
$index: number
|
||||
}) => any
|
||||
}>()
|
||||
|
||||
// 注意:此组件不渲染任何内容,仅用于声明菜单配置
|
||||
// 实际渲染由 TaskRow 处理,菜单定位和显示状态由内部自动管理
|
||||
// 菜单会在点击外部或滚动时自动关闭
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 此组件不渲染内容 -->
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
import { computed, type VNode, type Slots, type VNodeChild, type Component } from 'vue'
|
||||
import type { Task } from '../../../../models/classes/Task'
|
||||
|
||||
/**
|
||||
* 声明式右键菜单配置接口
|
||||
*/
|
||||
export interface DeclarativeContextMenuConfig {
|
||||
// slot 渲染函数
|
||||
defaultSlot?: (scope: {
|
||||
row: Task
|
||||
$index: number
|
||||
}) => VNodeChild
|
||||
// 指定哪些任务类型显示此右键菜单
|
||||
taskType?: string | string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 VNode 中提取 task-list-context-menu 的配置
|
||||
*/
|
||||
export function extractTaskListContextMenuConfig(
|
||||
vnode: VNode,
|
||||
): DeclarativeContextMenuConfig | null {
|
||||
// 检查是否是 TaskListContextMenu 组件
|
||||
let isTaskListContextMenu = false
|
||||
|
||||
if (typeof vnode.type === 'object') {
|
||||
const component = vnode.type as Component & {
|
||||
__name?: string
|
||||
__file?: string
|
||||
}
|
||||
// 检查组件名称的多种可能性
|
||||
isTaskListContextMenu =
|
||||
component.name === 'TaskListContextMenu' ||
|
||||
component.__name === 'TaskListContextMenu' ||
|
||||
// 检查文件名(setup script 组件)
|
||||
(component.__file !== undefined && component.__file.includes('TaskListContextMenu'))
|
||||
}
|
||||
|
||||
if (!isTaskListContextMenu) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 提取 props
|
||||
const props = vnode.props || {}
|
||||
|
||||
// 提取 slots
|
||||
type ChildrenType =
|
||||
| {
|
||||
default?: (scope: {
|
||||
row: Task
|
||||
$index: number
|
||||
}) => VNodeChild
|
||||
}
|
||||
| null
|
||||
const children = vnode.children as ChildrenType
|
||||
|
||||
const defaultSlot = children?.default
|
||||
|
||||
if (!defaultSlot) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
defaultSlot,
|
||||
taskType: props.taskType || props['task-type'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析声明式右键菜单配置
|
||||
* 从默认 slot 的 VNode 中提取 TaskListContextMenu 的配置
|
||||
*/
|
||||
export function parseDeclarativeTaskListContextMenu(
|
||||
slots: Slots,
|
||||
): DeclarativeContextMenuConfig | null {
|
||||
const defaultSlot = slots.default?.()
|
||||
|
||||
if (!defaultSlot || !Array.isArray(defaultSlot)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 递归查找 TaskListContextMenu
|
||||
const findContextMenu = (vnodes: VNode[]): DeclarativeContextMenuConfig | null => {
|
||||
for (const vnode of vnodes) {
|
||||
if (!vnode) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果是 Fragment 或其他 Symbol 类型,直接处理子节点
|
||||
if (typeof vnode.type === 'symbol') {
|
||||
if (vnode.children && Array.isArray(vnode.children)) {
|
||||
const result = findContextMenu(vnode.children as VNode[])
|
||||
if (result) return result
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const config = extractTaskListContextMenuConfig(vnode)
|
||||
if (config) {
|
||||
return config
|
||||
}
|
||||
|
||||
// 递归处理子节点
|
||||
if (vnode.children && Array.isArray(vnode.children)) {
|
||||
const result = findContextMenu(vnode.children as VNode[])
|
||||
if (result) return result
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return findContextMenu(defaultSlot)
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskList Context Menu Composable
|
||||
* 用于管理 TaskList 的右键菜单(支持声明式)
|
||||
*/
|
||||
export function useTaskListContextMenu(slots: Slots) {
|
||||
// 声明式菜单配置
|
||||
const declarativeContextMenu = computed(() => {
|
||||
return parseDeclarativeTaskListContextMenu(slots)
|
||||
})
|
||||
|
||||
// 是否有声明式菜单
|
||||
const hasDeclarativeContextMenu = computed(() => {
|
||||
return !!declarativeContextMenu.value
|
||||
})
|
||||
|
||||
return {
|
||||
declarativeContextMenu,
|
||||
hasDeclarativeContextMenu,
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,9 @@ export function useTaskListEventHandlers(options: TaskListEventHandlersOptions)
|
||||
const scrollTop = target.scrollTop
|
||||
taskListScrollTop.value = scrollTop
|
||||
|
||||
// 滚动时关闭所有右键菜单
|
||||
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('task-list-vertical-scroll', {
|
||||
detail: { scrollTop },
|
||||
|
||||
@@ -79,7 +79,8 @@ export function useTaskRowContextMenu(
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
// 先广播关闭所有TaskRow菜单
|
||||
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
|
||||
if (task.value.type !== 'task' && task.value.type !== 'story') {
|
||||
const taskType = task.value.type || 'task'
|
||||
if (taskType !== 'task' && taskType !== 'story') {
|
||||
// 为了排除里程碑类型
|
||||
event.preventDefault()
|
||||
contextMenuVisible.value = false
|
||||
@@ -95,6 +96,20 @@ export function useTaskRowContextMenu(
|
||||
contextMenuVisible.value = false
|
||||
}
|
||||
|
||||
// 处理文档点击事件,点击菜单外部时关闭
|
||||
const handleDocumentClick = (event: MouseEvent) => {
|
||||
if (!contextMenuVisible.value) return
|
||||
|
||||
const target = event.target as HTMLElement
|
||||
// 检查点击是否在右键菜单内部
|
||||
const contextMenuElement = document.querySelector('.task-context-menu')
|
||||
if (contextMenuElement && contextMenuElement.contains(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
// 处理任务删除
|
||||
const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
|
||||
emit('delete', task, deleteChildren)
|
||||
@@ -104,10 +119,12 @@ export function useTaskRowContextMenu(
|
||||
// 生命周期钩子 - 注册事件监听器
|
||||
onMounted(() => {
|
||||
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
// 清理定时器和watch
|
||||
if (timerInterval.value) {
|
||||
clearInterval(timerInterval.value)
|
||||
|
||||
@@ -13,6 +13,7 @@ export function useTaskRowDeclarativeColumns(
|
||||
isStoryTask: Ref<boolean>,
|
||||
hasChildren: Ref<boolean>,
|
||||
showTaskIcon: Ref<boolean | undefined>,
|
||||
rowIndex: Ref<number | undefined>,
|
||||
) {
|
||||
// 判断是否是第一列
|
||||
const isFirstColumn = (index: number) => index === 0
|
||||
@@ -47,7 +48,7 @@ export function useTaskRowDeclarativeColumns(
|
||||
},
|
||||
[column.defaultSlot({
|
||||
row: task.value,
|
||||
$index: index,
|
||||
$index: rowIndex.value ?? -1,
|
||||
})],
|
||||
),
|
||||
])
|
||||
@@ -128,7 +129,7 @@ export function useTaskRowDeclarativeColumns(
|
||||
if (column.defaultSlot) {
|
||||
return column.defaultSlot({
|
||||
row: task.value,
|
||||
$index: index,
|
||||
$index: rowIndex.value ?? -1,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -72,17 +72,6 @@ const emit = defineEmits([
|
||||
|
||||
defineSlots<{
|
||||
'custom-task-content'(props: TaskRowSlotProps): unknown
|
||||
'task-list-context-menu'(props: {
|
||||
task: Task
|
||||
position: { x: number; y: number }
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onStartTimer: () => void
|
||||
onStopTimer: () => void
|
||||
onAddPredecessor: () => void
|
||||
onAddSuccessor: () => void
|
||||
onDelete: (task: Task, deleteChildren?: boolean) => void
|
||||
}): unknown
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -94,29 +83,45 @@ const slots = useSlots()
|
||||
const hasContentSlot = computed(() => Boolean(slots['custom-task-content']))
|
||||
|
||||
// 注入右键菜单配置
|
||||
const useDefaultContextMenu = inject<ComputedRef<boolean>>('use-default-context-menu', computed(() => true))
|
||||
const enableTaskListContextMenu = inject<ComputedRef<boolean>>('enable-task-list-context-menu', computed(() => true))
|
||||
const hasTaskListContextMenuSlot = inject<ComputedRef<boolean>>('task-list-context-menu-slot', computed(() => false))
|
||||
const declarativeTaskListContextMenu = inject<ComputedRef<any>>('declarative-task-list-context-menu', computed(() => null))
|
||||
|
||||
// 判断是否应该显示任何右键菜单
|
||||
const shouldShowAnyContextMenu = computed(() => {
|
||||
// 如果 useDefaultContextMenu 为 false 且没有自定义 slot,则不显示任何菜单
|
||||
if (!useDefaultContextMenu.value && !hasTaskListContextMenuSlot.value) {
|
||||
// 如果 enableTaskListContextMenu 为 false,则不显示任何菜单
|
||||
if (!enableTaskListContextMenu.value) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// 判断是否显示默认右键菜单(没有自定义 slot 时显示)
|
||||
// 判断是否显示默认右键菜单(enableTaskListContextMenu=true 且没有自定义 slot 时显示)
|
||||
const shouldShowDefaultContextMenu = computed(() => {
|
||||
if (!useDefaultContextMenu.value) {
|
||||
if (!enableTaskListContextMenu.value) {
|
||||
return false
|
||||
}
|
||||
return !hasTaskListContextMenuSlot.value
|
||||
})
|
||||
|
||||
// 判断是否显示自定义右键菜单(有自定义 slot 时显示)
|
||||
// 判断是否显示自定义右键菜单(enableTaskListContextMenu=true 且有自定义 slot 时显示)
|
||||
const shouldShowCustomContextMenu = computed(() => {
|
||||
return hasTaskListContextMenuSlot.value
|
||||
if (!enableTaskListContextMenu.value) {
|
||||
return false
|
||||
}
|
||||
if (!hasTaskListContextMenuSlot.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查 taskType 过滤
|
||||
const config = declarativeTaskListContextMenu.value
|
||||
if (config?.taskType !== undefined) {
|
||||
const taskType = props.task.type || 'task'
|
||||
const allowedTypes = Array.isArray(config.taskType) ? config.taskType : [config.taskType]
|
||||
return allowedTypes.includes(taskType)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// 性能优化:只对会变化的props使用toRef,其他直接传值
|
||||
@@ -143,6 +148,7 @@ const { isFirstColumn, getDeclarativeColumnAlign, renderDeclarativeColumn } =
|
||||
isStoryTask,
|
||||
hasChildren,
|
||||
computed(() => props.showTaskIcon),
|
||||
computed(() => props.rowIndex),
|
||||
)
|
||||
|
||||
const {
|
||||
@@ -431,20 +437,25 @@ const slotPayload = computed(() => ({
|
||||
@delete="handleTaskDelete"
|
||||
/>
|
||||
|
||||
<!-- 自定义右键菜单 Slot -->
|
||||
<slot
|
||||
v-if="shouldShowCustomContextMenu"
|
||||
name="task-list-context-menu"
|
||||
:task="contextMenuTask"
|
||||
:position="contextMenuPosition"
|
||||
:visible="contextMenuVisible"
|
||||
:on-close="closeContextMenu"
|
||||
:on-start-timer="() => $emit('start-timer', props.task)"
|
||||
:on-stop-timer="() => $emit('stop-timer', props.task)"
|
||||
:on-add-predecessor="() => $emit('add-predecessor', props.task)"
|
||||
:on-add-successor="() => $emit('add-successor', props.task)"
|
||||
:on-delete="handleTaskDelete"
|
||||
/>
|
||||
<!-- 声明式右键菜单 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="shouldShowCustomContextMenu && contextMenuVisible && declarativeTaskListContextMenu?.defaultSlot"
|
||||
class="gantt-context-menu-wrapper"
|
||||
:style="{
|
||||
position: 'fixed',
|
||||
left: `${contextMenuPosition.x}px`,
|
||||
top: `${contextMenuPosition.y}px`,
|
||||
zIndex: 9999,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="declarativeTaskListContextMenu.defaultSlot"
|
||||
:row="contextMenuTask"
|
||||
:$index="props.rowIndex ?? -1"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3061,6 +3061,9 @@ const handleTimelineScroll = (event: Event) => {
|
||||
// 立即更新关键滚动位置信息(用于虚拟滚动)
|
||||
timelineScrollLeft.value = scrollLeft
|
||||
|
||||
// 滚动时关闭所有右键菜单
|
||||
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
|
||||
|
||||
// 优化:滚动时失效 bodyRect 缓存(用于连接线拖拽)
|
||||
bodyRectInvalidated = true
|
||||
|
||||
@@ -4306,6 +4309,7 @@ const handleAddSuccessor = (task: Task) => {
|
||||
v-else-if="task.type !== 'milestone-group' && task.type !== 'milestone'"
|
||||
:key="`taskbar-${task.id}-${taskBarRenderKey}`"
|
||||
:task="task"
|
||||
:row-index="originalIndex"
|
||||
:row-height="50"
|
||||
:day-width="dayWidth"
|
||||
:start-date="
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TaskBarContextMenu 声明式右键菜单组件
|
||||
* 用于在 GanttChart 中声明式定义 TaskBar 的右键菜单
|
||||
*/
|
||||
|
||||
// 显式定义组件名称,用于识别
|
||||
defineOptions({
|
||||
name: 'TaskBarContextMenu',
|
||||
})
|
||||
|
||||
interface Props {
|
||||
// 指定哪些任务类型显示此右键菜单
|
||||
// 不设置时遵循现有逻辑,设置后仅对指定类型任务显示
|
||||
taskType?: string | string[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
// 定义 slot
|
||||
defineSlots<{
|
||||
// 默认插槽,接收菜单上下文对象
|
||||
default?: (scope: {
|
||||
row: any
|
||||
$index: number
|
||||
}) => any
|
||||
}>()
|
||||
|
||||
// 注意:此组件不渲染任何内容,仅用于声明菜单配置
|
||||
// 实际渲染由 TaskBar 处理,菜单定位和显示状态由内部自动管理
|
||||
// 菜单会在点击外部或滚动时自动关闭
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 此组件不渲染内容 -->
|
||||
</template>
|
||||
@@ -0,0 +1,133 @@
|
||||
import { computed, type VNode, type Slots, type VNodeChild, type Component } from 'vue'
|
||||
import type { Task } from '../../../models/classes/Task'
|
||||
|
||||
/**
|
||||
* 声明式右键菜单配置接口
|
||||
*/
|
||||
export interface DeclarativeContextMenuConfig {
|
||||
// slot 渲染函数
|
||||
defaultSlot?: (scope: {
|
||||
row: Task
|
||||
$index: number
|
||||
}) => VNodeChild
|
||||
// 指定哪些任务类型显示此右键菜单
|
||||
taskType?: string | string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 VNode 中提取 task-bar-context-menu 的配置
|
||||
*/
|
||||
export function extractTaskBarContextMenuConfig(vnode: VNode): DeclarativeContextMenuConfig | null {
|
||||
// 检查是否是 TaskBarContextMenu 组件
|
||||
let isTaskBarContextMenu = false
|
||||
|
||||
if (typeof vnode.type === 'object') {
|
||||
const component = vnode.type as Component & {
|
||||
__name?: string
|
||||
__file?: string
|
||||
}
|
||||
// 检查组件名称的多种可能性
|
||||
isTaskBarContextMenu =
|
||||
component.name === 'TaskBarContextMenu' ||
|
||||
component.__name === 'TaskBarContextMenu' ||
|
||||
// 检查文件名(setup script 组件)
|
||||
(component.__file !== undefined && component.__file.includes('TaskBarContextMenu'))
|
||||
}
|
||||
|
||||
if (!isTaskBarContextMenu) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 提取 props
|
||||
const props = vnode.props || {}
|
||||
|
||||
// 提取 slots
|
||||
type ChildrenType =
|
||||
| {
|
||||
default?: (scope: {
|
||||
row: Task
|
||||
$index: number
|
||||
}) => VNodeChild
|
||||
}
|
||||
| null
|
||||
const children = vnode.children as ChildrenType
|
||||
|
||||
const defaultSlot = children?.default
|
||||
|
||||
if (!defaultSlot) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
defaultSlot,
|
||||
taskType: props.taskType || props['task-type'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析声明式右键菜单配置
|
||||
* 从默认 slot 的 VNode 中提取 TaskBarContextMenu 的配置
|
||||
*/
|
||||
export function parseDeclarativeTaskBarContextMenu(
|
||||
slots: Slots,
|
||||
): DeclarativeContextMenuConfig | null {
|
||||
const defaultSlot = slots.default?.()
|
||||
|
||||
if (!defaultSlot || !Array.isArray(defaultSlot)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 递归查找 TaskBarContextMenu
|
||||
const findContextMenu = (vnodes: VNode[]): DeclarativeContextMenuConfig | null => {
|
||||
for (const vnode of vnodes) {
|
||||
if (!vnode) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果是 Fragment 或其他 Symbol 类型,直接处理子节点
|
||||
if (typeof vnode.type === 'symbol') {
|
||||
if (vnode.children && Array.isArray(vnode.children)) {
|
||||
const result = findContextMenu(vnode.children as VNode[])
|
||||
if (result) return result
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const config = extractTaskBarContextMenuConfig(vnode)
|
||||
if (config) {
|
||||
return config
|
||||
}
|
||||
|
||||
// 递归处理子节点
|
||||
if (vnode.children && Array.isArray(vnode.children)) {
|
||||
const result = findContextMenu(vnode.children as VNode[])
|
||||
if (result) return result
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return findContextMenu(defaultSlot)
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskBar Context Menu Composable
|
||||
* 用于管理 TaskBar 的右键菜单(支持声明式)
|
||||
*/
|
||||
export function useTaskBarContextMenu(slots: Slots) {
|
||||
// 声明式菜单配置
|
||||
const declarativeContextMenu = computed(() => {
|
||||
return parseDeclarativeTaskBarContextMenu(slots)
|
||||
})
|
||||
|
||||
// 是否有声明式菜单
|
||||
const hasDeclarativeContextMenu = computed(() => {
|
||||
return !!declarativeContextMenu.value
|
||||
})
|
||||
|
||||
return {
|
||||
declarativeContextMenu,
|
||||
hasDeclarativeContextMenu,
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ export { default as GanttChart } from './GanttChart.vue'
|
||||
export { default as GanttToolbar } from './GanttToolbar.vue'
|
||||
export { default as TaskList } from './TaskList/TaskList.vue'
|
||||
export { default as TaskListColumn } from './TaskList/TaskListColumn.vue'
|
||||
export { default as TaskListContextMenu } from './TaskList/TaskListContextMenu.vue'
|
||||
export { default as TaskRow } from './TaskList/taskRow/TaskRow.vue'
|
||||
export { default as Timeline } from './Timeline.vue'
|
||||
export { default as TaskBar } from './TaskBar.vue'
|
||||
export { default as TaskBarContextMenu } from './Timeline/TaskBarContextMenu.vue'
|
||||
export { default as MilestonePoint } from './MilestonePoint.vue'
|
||||
export { default as MilestoneDialog } from './MilestoneDialog.vue'
|
||||
export { default as TaskDrawer } from './TaskDrawer.vue'
|
||||
|
||||
Reference in New Issue
Block a user