v 1.6.0.preview1 - TaskList Slots

This commit is contained in:
LINING-PC\lining
2025-12-13 21:25:35 +08:00
parent 0c3620c7f7
commit c8aa1ba9a3
10 changed files with 995 additions and 55 deletions
+8 -5
View File
@@ -44,6 +44,7 @@ const props = withDefaults(defineProps<Props>(), {
afternoon: { start: 13, end: 17 },
}),
taskListConfig: undefined,
taskListColumnRenderMode: 'default',
taskBarConfig: undefined,
autoSortByStartDate: false,
allowDragAndResize: true,
@@ -84,11 +85,6 @@ const emit = defineEmits([
const { showMessage } = useMessage()
const slots = useSlots()
// 获取所有列级 slot 名称并通过 provide 传递给子组件
const columnSlotNames = computed(() => {
return Object.keys(slots).filter(name => name.startsWith('column-'))
})
// 提供 slots 给子组件(TaskList 和 TaskRow
provide('gantt-column-slots', slots)
@@ -135,6 +131,8 @@ interface Props {
}
// 任务列表配置
taskListConfig?: TaskListConfig
// 任务列表列渲染模式:'default' 使用 taskListConfig.columns 配置,'declarative' 使用声明式 <task-list-column> 标签
taskListColumnRenderMode?: 'default' | 'declarative'
// TaskBar 配置
taskBarConfig?: TaskBarConfig
// 是否启用自动排序(根据开始时间排序任务)
@@ -2307,6 +2305,7 @@ function handleMilestoneDialogDelete(milestoneId: number) {
: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-collapse-change="handleTaskCollapseChange"
@start-timer="handleStartTimer"
@@ -2316,6 +2315,10 @@ function handleMilestoneDialogDelete(milestoneId: number) {
@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" />
+87 -22
View File
@@ -8,15 +8,20 @@ import { DEFAULT_TASK_LIST_COLUMNS } from '../models/configs/TaskListConfig'
import { useTaskRowDrag } from '../composables/useTaskRowDrag'
import { moveTask } from '../utils/taskTreeUtils'
import type { Slots } from 'vue'
import { useTaskListColumns } from '../composables/useTaskListColumns'
import type { DeclarativeColumnConfig } from '../composables/useTaskListColumns'
interface Props {
tasks?: Task[]
useDefaultDrawer?: boolean
taskListConfig?: TaskListConfig
taskListColumnRenderMode?: 'default' | 'declarative'
enableTaskRowMove?: boolean
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
taskListColumnRenderMode: 'default',
})
// 定义emit事件
// const emit = defineEmits(['task-collapse-change', 'start-timer', 'stop-timer'])
@@ -49,6 +54,24 @@ const columnSlots = inject<Slots>('gantt-column-slots', {})
// 多语言支持
const { t } = useI18n()
// 使用声明式列管理 composable
const { declarativeColumns, finalColumns, getColumnWidthStyle: getDeclarativeColumnWidth } =
useTaskListColumns(
props.taskListColumnRenderMode || 'default',
slots,
props.taskListConfig?.columns || DEFAULT_TASK_LIST_COLUMNS,
)
// 计算实际使用的列配置
const columnsToUse = computed(() => {
if (props.taskListColumnRenderMode === 'declarative') {
return declarativeColumns.value
}
// 默认模式:使用 taskListConfig 中的列配置
const columns = props.taskListConfig?.columns || DEFAULT_TASK_LIST_COLUMNS
return columns.filter(col => col.visible !== false)
})
// TaskList 容器引用
const taskListRef = ref<HTMLElement | null>(null)
const taskListBodyRef = ref<HTMLElement | null>(null)
@@ -68,6 +91,12 @@ const taskListBodyHeight = ref(0)
// 获取列宽度样式(百分比转像素)
const getColumnWidthStyle = (column: { width?: number | string }) => {
// 声明式模式:使用 composable 中的方法
if (props.taskListColumnRenderMode === 'declarative') {
return getDeclarativeColumnWidth(column, cachedContainerWidth.value)
}
// 默认模式:原有逻辑
if (!column.width) return {}
let widthPx: string
@@ -94,13 +123,8 @@ const getColumnWidthStyle = (column: { width?: number | string }) => {
}
}
// 计算可见的列配置
const visibleColumns = computed(() => {
const columns = props.taskListConfig?.columns || DEFAULT_TASK_LIST_COLUMNS
// 过滤出可见的列(visible !== false
return columns.filter(col => col.visible !== false)
})
// 计算可见的列配置(已被 columnsToUse 替代,保留以兼容)
const visibleColumns = computed(() => columnsToUse.value)
// 使用 props.tasks 的引用,不创建副本
// 这样可以直接修改对象,触发响应式更新,不需要外部监听事件
@@ -596,20 +620,58 @@ onUnmounted(() => {
<template>
<div ref="taskListRef" class="task-list">
<div class="task-list-header">
<!-- 任务名称列始终显示 -->
<div class="col col-name">
{{ (t as any).taskName || '任务名称' }}
</div>
<!-- 可配置的其他列 -->
<div
v-for="column in visibleColumns"
:key="column.key"
class="col"
:class="column.cssClass || `col-${column.key}`"
:style="getColumnWidthStyle(column)"
>
{{ (t as any)[column.key] || column.label }}
</div>
<!-- 声明式模式 -->
<template v-if="taskListColumnRenderMode === 'declarative'">
<div
v-for="(column, index) in columnsToUse"
:key="index"
class="col"
:class="column.cssClass"
:style="{
...getColumnWidthStyle(column),
justifyContent: column.align === 'center' ? 'center' : column.align === 'right' ? 'flex-end' : 'flex-start',
textAlign: column.align || 'left'
}"
>
<!-- 使用 header slot 或显示 label -->
<template v-if="column.headerSlot">
<component :is="column.headerSlot" />
</template>
<template v-else>
{{ column.label }}
</template>
</div>
</template>
<!-- 默认模式使用配置的列 -->
<template v-else>
<!-- 任务名称列始终显示 -->
<div class="col col-name">
<!-- 检查是否有 header-name slot -->
<template v-if="columnSlots['header-name']">
<component :is="columnSlots['header-name']" />
</template>
<template v-else>
{{ (t as any).taskName || '任务名称' }}
</template>
</div>
<!-- 可配置的其他列 -->
<div
v-for="column in visibleColumns"
:key="column.key"
class="col"
:class="column.cssClass || `col-${column.key}`"
:style="getColumnWidthStyle(column)"
>
<!-- 检查是否有对应的 header-{key} slot -->
<template v-if="columnSlots[`header-${column.key}`]">
<component :is="columnSlots[`header-${column.key}`]" />
</template>
<template v-else>
{{ (t as any)[column.key] || column.label }}
</template>
</div>
</template>
</div>
<div ref="taskListBodyRef" class="task-list-body" @scroll="handleTaskListScroll">
<div class="task-list-body-spacer" :style="{ height: `${startSpacerHeight}px` }"></div>
@@ -623,6 +685,8 @@ onUnmounted(() => {
:hovered-task-id="hoveredTaskId"
:on-hover="handleTaskRowHover"
:columns="visibleColumns"
:declarative-columns="taskListColumnRenderMode === 'declarative' ? columnsToUse : undefined"
:render-mode="taskListColumnRenderMode"
:get-column-width-style="getColumnWidthStyle"
:disable-children-render="true"
:show-task-icon="props.taskListConfig?.showTaskIcon"
@@ -691,6 +755,7 @@ onUnmounted(() => {
color: var(--gantt-text-header);
border-right-color: var(--gantt-border-medium);
padding: 0 10px;
box-sizing: border-box; /* 确保 padding 包含在宽度内 */
}
.task-list-body {
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
/**
* TaskListColumn 声明式列组件
* 用于在 GanttChart 中声明式定义任务列表的列
* 类似于 Element Plus el-table-column
*/
//
defineOptions({
name: 'TaskListColumn',
})
interface Props {
// 访
prop?: string
//
label?: string
//
width?: number | string
//
align?: 'left' | 'center' | 'right'
// CSS
cssClass?: string
}
const props = withDefaults(defineProps<Props>(), {
align: 'left',
})
// slot
defineSlots<{
//
header?: () => any
// scope { row: Task, $index: number }
default?: (scope: { row: any; $index: number }) => any
}>()
//
// TaskList TaskRow
</script>
<template>
<!-- 此组件不渲染内容 -->
</template>
+234 -24
View File
@@ -5,6 +5,7 @@ import { useI18n } from '../composables/useI18n'
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
import type { Task } from '../models/classes/Task'
import type { TaskListColumnConfig } from '../models/configs/TaskListConfig'
import type { DeclarativeColumnConfig } from '../composables/useTaskListColumns'
import TaskContextMenu from './TaskContextMenu.vue'
interface TaskRowSlotProps {
@@ -36,6 +37,8 @@ interface Props {
hoveredTaskId?: number | null
onHover?: (taskId: number | null) => void
columns: TaskListColumnConfig[]
declarativeColumns?: DeclarativeColumnConfig[]
renderMode?: 'default' | 'declarative'
getColumnWidthStyle?: (column: { width?: number | string }) => StyleValue
disableChildrenRender?: boolean
showTaskIcon?: boolean // true
@@ -43,7 +46,9 @@ interface Props {
dragStart?: (task: Task, element: HTMLElement, event: MouseEvent) => void
dragOver?: (task: Task, element: HTMLElement, event: MouseEvent) => void
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
renderMode: 'default',
})
const emit = defineEmits([
'toggle',
'dblclick',
@@ -85,6 +90,142 @@ const renderColumnSlot = (columnKey: string, slotProps: any) => {
return null
}
//
const columnsToRender = computed(() => {
if (props.renderMode === 'declarative' && props.declarativeColumns) {
return props.declarativeColumns
}
return props.columns
})
//
const isFirstColumn = (index: number) => index === 0
//
const getDeclarativeColumnAlign = (column: DeclarativeColumnConfig) => {
// if (!column.align || column.align === 'left') return {}
return {
justifyContent: column.align === 'center' ? 'center' : column.align === 'right' ? 'flex-end' : 'flex-start',
textAlign: column.align || 'left',
}
}
//
const renderDeclarativeColumn = (
column: DeclarativeColumnConfig,
index: number,
): any => {
//
if (isFirstColumn(index)) {
// 使 prop name
const columnValue = column.prop ? (props.task as any)[column.prop] : props.task.name
// slot
if (column.defaultSlot) {
return h('div', { class: 'task-name-content' }, [
//
h(
'span',
{
class: ['task-name-text', { 'parent-task': isParentTask.value }],
title: columnValue,
},
column.defaultSlot({
row: props.task,
$index: index,
}),
),
])
}
// 使 prop
return h('div', { class: 'task-name-content' }, [
//
props.showTaskIcon !== false
? h(
'span',
{ class: 'task-icon' },
isMilestoneGroup.value
? h(
'svg',
{
width: 16,
height: 16,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': 2,
class: 'milestone-group-icon',
},
h('polygon', { points: '12,2 22,12 12,22 2,12' }),
)
: isStoryTask.value || hasChildren.value
? h(
'svg',
{
width: 16,
height: 16,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': 2,
},
h('path', {
d: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z',
}),
)
: h(
'svg',
{
width: 16,
height: 16,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': 2,
},
[
h('path', {
d: 'M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z',
}),
h('polyline', { points: '14,2 14,8 20,8' }),
h('line', { x1: 16, y1: 13, x2: 8, y2: 13 }),
h('line', { x1: 16, y1: 17, x2: 8, y2: 17 }),
h('polyline', { points: '10,9 9,9 8,9' }),
],
),
)
: null,
// 使 prop name
h(
'span',
{
class: ['task-name-text', { 'parent-task': isParentTask.value }],
title: columnValue,
},
columnValue || '-',
),
])
}
//
// slot使 slot
if (column.defaultSlot) {
return column.defaultSlot({
row: props.task,
$index: index,
})
}
// 使 prop 访
if (column.prop) {
const value = (props.task as any)[column.prop]
return value !== undefined && value !== null ? value : '-'
}
return '-'
}
const baseIndent = 10
const indent = computed(() => `${baseIndent + props.level * 20}px`)
const hasChildren = computed(() => !!(props.task.children && props.task.children.length > 0))
@@ -373,33 +514,85 @@ onUnmounted(() => {
@mouseleave="handleMouseLeave"
@contextmenu="handleContextMenu"
>
<div class="col col-name" :style="{ paddingLeft: indent }">
<span
v-if="(isStoryTask || hasChildren) && !isMilestoneGroup"
class="collapse-btn"
@click.stop="handleToggle"
<!-- 声明式渲染模式 -->
<template v-if="renderMode === 'declarative' && declarativeColumns">
<div
v-for="(column, index) in columnsToRender"
:key="index"
class="col"
:class="[column.cssClass, { 'col-name': isFirstColumn(index) }]"
:style="{
...(getColumnWidthStyle ? getColumnWidthStyle(column) : {}),
...getDeclarativeColumnAlign(column),
}"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<!-- 第一列保留 collapse-btn, milestone-spacer, leaf-spacer -->
<div v-if="isFirstColumn(index)"
:style="{ paddingLeft: indent }"
class="first-col-wrapper">
<span
v-if="(isStoryTask || hasChildren) && !isMilestoneGroup"
class="collapse-btn"
@click.stop="handleToggle"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline v-if="props.task.collapsed" points="9,18 15,12 9,6" />
<polyline v-else points="18,15 12,9 6,15" />
</svg>
</span>
<span v-if="isMilestoneGroup" class="milestone-spacer"></span>
<span
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false"
class="leaf-spacer"
></span>
<!-- 渲染列内容 -->
<component :is="() => renderDeclarativeColumn(column, index)" />
</div>
<!-- 其他列直接渲染内容 -->
<component v-else :is="() => renderDeclarativeColumn(column, index)" />
</div>
</template>
<!-- 默认渲染模式 -->
<template v-else>
<div class="col col-name" :style="{ paddingLeft: indent }">
<span
v-if="(isStoryTask || hasChildren) && !isMilestoneGroup"
class="collapse-btn"
@click.stop="handleToggle"
>
<polyline v-if="props.task.collapsed" points="9,18 15,12 9,6" />
<polyline v-else points="18,15 12,9 6,15" />
</svg>
</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline v-if="props.task.collapsed" points="9,18 15,12 9,6" />
<polyline v-else points="18,15 12,9 6,15" />
</svg>
</span>
<!-- 里程碑分组的占位空间用于与有折叠按钮的任务对齐 -->
<span v-if="isMilestoneGroup" class="milestone-spacer"></span>
<!-- 里程碑分组的占位空间用于与有折叠按钮的任务对齐 -->
<span v-if="isMilestoneGroup" class="milestone-spacer"></span>
<!-- 叶子节点的占位空间无折叠按钮且无图标显示时 -->
<span
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false"
class="leaf-spacer"
></span>
<!-- 叶子节点的占位空间无折叠按钮且无图标显示时 -->
<span
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false"
class="leaf-spacer"
></span>
<!-- 优先级1: 列级自定义 Slot (#column-name) - 覆盖整列内容图标+文本+徽章 -->
<component
@@ -642,6 +835,8 @@ onUnmounted(() => {
</template>
</template>
</div>
</template>
<!-- 结束默认渲染模式 -->
</div>
<template
v-if="
@@ -657,6 +852,8 @@ onUnmounted(() => {
:hovered-task-id="props.hoveredTaskId"
:on-hover="props.onHover"
:columns="props.columns"
:declarative-columns="props.declarativeColumns"
:render-mode="props.renderMode"
:get-column-width-style="props.getColumnWidthStyle"
:disable-children-render="props.disableChildrenRender"
:show-task-icon="props.showTaskIcon"
@@ -1151,4 +1348,17 @@ onUnmounted(() => {
:global(html[data-theme='dark']) .task-row-drop-target.drop-child {
background-color: rgba(125, 180, 240, 0.1) !important;
}
/* 声明式列第一列的包装器 */
.first-col-wrapper {
display: flex;
align-items: center;
width: 100%;
box-sizing: border-box;
}
/* 声明式列的 padding,与 header 保持一致 */
.task-row .col {
padding: 0 10px;
}
</style>
+1
View File
@@ -1,6 +1,7 @@
export { default as GanttChart } from './GanttChart.vue'
export { default as GanttToolbar } from './GanttToolbar.vue'
export { default as TaskList } from './TaskList.vue'
export { default as TaskListColumn } from './TaskListColumn.vue'
export { default as TaskRow } from './TaskRow.vue'
export { default as Timeline } from './Timeline.vue'
export { default as TaskBar } from './TaskBar.vue'