@@ -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 {
diff --git a/src/components/TaskListColumn.vue b/src/components/TaskListColumn.vue
new file mode 100644
index 0000000..fc24948
--- /dev/null
+++ b/src/components/TaskListColumn.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
diff --git a/src/components/TaskRow.vue b/src/components/TaskRow.vue
index 48fe6e2..6847c87 100644
--- a/src/components/TaskRow.vue
+++ b/src/components/TaskRow.vue
@@ -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
()
+const props = withDefaults(defineProps(), {
+ 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"
>
-
-
+
+
-
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
-
-
+
+
-
-
+
+
{
+
+
{
: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;
+}
diff --git a/src/components/index.ts b/src/components/index.ts
index 543692c..d3803ce 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -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'
diff --git a/src/composables/useTaskListColumns.ts b/src/composables/useTaskListColumns.ts
new file mode 100644
index 0000000..be6f849
--- /dev/null
+++ b/src/composables/useTaskListColumns.ts
@@ -0,0 +1,191 @@
+import { computed, type VNode, type Slots, type VNodeChild } from 'vue'
+import type { Task } from '../models/classes/Task'
+import type { TaskListColumnConfig } from '../models/configs/TaskListConfig'
+
+/**
+ * 声明式列配置接口
+ * 从 task-list-column 组件解析出的列配置
+ */
+export interface DeclarativeColumnConfig {
+ prop?: string
+ label?: string
+ width?: number | string
+ align?: 'left' | 'center' | 'right'
+ cssClass?: string
+ // slot 函数
+ headerSlot?: () => VNodeChild
+ defaultSlot?: (scope: { row: Task; $index: number }) => VNodeChild
+}
+
+/**
+ * 从 VNode 中提取 task-list-column 的配置
+ */
+export function extractColumnConfig(vnode: VNode): DeclarativeColumnConfig | null {
+ // 检查是否是 task-list-column 组件
+ // 支持多种识别方式
+ let isTaskListColumn = false
+
+ if (typeof vnode.type === 'object') {
+ const component = vnode.type as any
+ // 检查组件名称的多种可能性
+ isTaskListColumn =
+ component.name === 'TaskListColumn' ||
+ component.__name === 'TaskListColumn' ||
+ // 检查文件名(setup script 组件)
+ (component.__file && component.__file.includes('TaskListColumn'))
+ }
+
+ if (!isTaskListColumn) {
+ return null
+ }
+
+ // 提取 props
+ const props = vnode.props || {}
+ type ChildrenType = {
+ header?: () => VNodeChild
+ default?: (scope: { row: Task; $index: number }) => VNodeChild
+ } | null
+ const children = vnode.children as ChildrenType
+
+ // 提取 slots
+ const headerSlot = children?.header
+ const defaultSlot = children?.default
+
+ return {
+ prop: props.prop,
+ label: props.label,
+ width: props.width,
+ align: props.align || 'left',
+ cssClass: props.cssClass,
+ headerSlot,
+ defaultSlot,
+ }
+}
+
+/**
+ * 解析声明式列配置
+ * 从默认 slot 的 VNode 中提取所有 task-list-column 的配置
+ */
+export function parseDeclarativeColumns(slots: Slots): DeclarativeColumnConfig[] {
+ const defaultSlot = slots.default?.()
+
+ if (!defaultSlot || !Array.isArray(defaultSlot)) {
+ return []
+ }
+
+ const columns: DeclarativeColumnConfig[] = []
+
+ // 递归提取所有 task-list-column
+ const extractColumns = (vnodes: VNode[]) => {
+ for (const vnode of vnodes) {
+ // 跳过注释节点和文本节点,但继续处理 Fragment
+ if (!vnode) {
+ continue
+ }
+
+ // 如果是 Fragment 或其他 Symbol 类型,直接处理子节点
+ if (typeof vnode.type === 'symbol') {
+ if (vnode.children && Array.isArray(vnode.children)) {
+ extractColumns(vnode.children as VNode[])
+ }
+ continue
+ }
+
+ const config = extractColumnConfig(vnode)
+ if (config) {
+ columns.push(config)
+ }
+
+ // 递归处理子节点
+ if (vnode.children && Array.isArray(vnode.children)) {
+ extractColumns(vnode.children as VNode[])
+ }
+ }
+ }
+
+ extractColumns(defaultSlot)
+
+ return columns
+}/**
+ * Task List Columns Composable
+ * 用于管理任务列表的列配置(支持声明式和配置式)
+ */
+export function useTaskListColumns(
+ renderMode: 'default' | 'declarative',
+ slots: Slots,
+ defaultColumns?: TaskListColumnConfig[],
+) {
+ // 声明式列配置
+ const declarativeColumns = computed(() => {
+ if (renderMode !== 'declarative') {
+ return []
+ }
+ return parseDeclarativeColumns(slots)
+ })
+
+ // 最终使用的列配置
+ const finalColumns = computed(() => {
+ if (renderMode === 'declarative') {
+ return declarativeColumns.value
+ }
+ return defaultColumns || []
+ })
+
+ // 获取列宽度样式
+ const getColumnWidthStyle = (
+ column: { width?: number | string },
+ containerWidth?: number,
+ ) => {
+ if (!column.width) return {}
+
+ let widthPx: string
+
+ // 如果是百分比,转换为像素
+ if (typeof column.width === 'string' && column.width.includes('%')) {
+ if (containerWidth && containerWidth > 0) {
+ const percentage = parseFloat(column.width) / 100
+ const pixels = Math.floor(containerWidth * percentage)
+ widthPx = `${pixels}px`
+ } else {
+ return {} // 容器宽度未知时返回空
+ }
+ } else {
+ // 像素值:处理数字或字符串数字
+ if (typeof column.width === 'number') {
+ widthPx = `${column.width}px`
+ } else if (typeof column.width === 'string') {
+ // 如果字符串已经包含单位(px, rem, em等),直接使用
+ if (/\d+(px|rem|em|%)/.test(column.width)) {
+ widthPx = column.width
+ } else {
+ // 纯数字字符串,添加 px 单位
+ widthPx = `${column.width}px`
+ }
+ } else {
+ widthPx = String(column.width)
+ }
+ }
+
+ return {
+ flex: `0 0 ${widthPx}`,
+ minWidth: widthPx,
+ maxWidth: widthPx,
+ }
+ }
+
+ // 获取列对齐样式
+ const getColumnAlignStyle = (align?: 'left' | 'center' | 'right') => {
+ if (!align || align === 'left') return {}
+ return {
+ justifyContent: align === 'center' ? 'center' : 'flex-end',
+ textAlign: align,
+ }
+ }
+
+ return {
+ declarativeColumns,
+ finalColumns,
+ getColumnWidthStyle,
+ getColumnAlignStyle,
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index 9156814..afa319e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,6 +1,7 @@
// 导出所有组件
export { default as GanttChart } from './components/GanttChart.vue'
export { default as TaskList } from './components/TaskList.vue'
+export { default as TaskListColumn } from './components/TaskListColumn.vue'
export { default as Timeline } from './components/Timeline.vue'
export { default as TaskBar } from './components/TaskBar.vue'
export { default as TaskDrawer } from './components/TaskDrawer.vue'
@@ -26,9 +27,11 @@ import './styles/theme-variables.css'
// 导出安装函数(可选,用于Vue.use())
import type { App } from 'vue'
import GanttChart from './components/GanttChart.vue'
+import TaskListColumn from './components/TaskListColumn.vue'
export const install = (app: App) => {
app.component('GanttChart', GanttChart)
+ app.component('TaskListColumn', TaskListColumn)
}
export default {