Files
jordium-gantt-vue3/src/components/TaskRow.vue

1393 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch, useSlots, inject, h } from 'vue'
import type { StyleValue, Slots } from 'vue'
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 {
isRowContent: boolean
task: Task
level: number
indent: string
isHovered: boolean
hoveredTaskId: number | null
isParent: boolean
hasChildren: boolean
collapsed: boolean
formattedTimer: string
timerRunning: boolean
timerElapsed: number
isOvertime: number | boolean | undefined
overdueDays: number
overtimeText: string
overdueText: string
daysText: string
progressClass: string
type?: string
}
interface Props {
task: Task
level: number
rowIndex?: number
isHovered?: boolean
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
enableDrag?: boolean
dragStart?: (task: Task, element: HTMLElement, event: MouseEvent) => void
dragOver?: (task: Task, element: HTMLElement, event: MouseEvent) => void
taskListRowClassName?: string | ((row: Task, rowIndex: number) => string)
taskListRowStyle?: StyleValue | ((row: Task, rowIndex: number) => StyleValue)
}
const props = withDefaults(defineProps<Props>(), {
renderMode: 'default',
})
const emit = defineEmits([
'toggle',
'dblclick',
'contextmenu',
'start-timer',
'stop-timer',
'add-predecessor',
'add-successor',
'delete',
])
defineSlots<{
'custom-task-content'(props: TaskRowSlotProps): unknown
}>()
const { t } = useI18n()
const overtimeText = computed(() => t.value?.overtime ?? '')
const overdueText = computed(() => t.value?.overdue ?? '')
const daysText = computed(() => t.value?.days ?? '')
const slots = useSlots()
const hasContentSlot = computed(() => Boolean(slots['custom-task-content']))
// 从 GanttChart 注入列级 slots
const columnSlots = inject<Slots>('gantt-column-slots', {})
// 检查指定列是否有自定义 slot从注入的 slots 中查找)
const hasColumnSlot = (columnKey: string) => {
const slotName = `column-${columnKey}`
return Boolean(columnSlots[slotName])
}
// 渲染列级 slot
const renderColumnSlot = (columnKey: string, slotProps: any) => {
const slotName = `column-${columnKey}`
const slotFn = columnSlots[slotName]
if (slotFn) {
return slotFn(slotProps)
}
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))
const isStoryTask = computed(() => props.task.type === 'story')
const isMilestoneGroup = computed(() => props.task.type === 'milestone-group')
const isMilestoneTask = computed(() => props.task.type === 'milestone')
const isParentTask = computed(
() => isStoryTask.value || hasChildren.value || isMilestoneGroup.value,
)
// 计算自定义行类名
const customRowClass = computed(() => {
if (!props.taskListRowClassName) return ''
if (typeof props.taskListRowClassName === 'function') {
return props.taskListRowClassName(props.task, props.rowIndex ?? 0)
}
return props.taskListRowClassName
})
// 计算自定义行样式(优先级高于类名)
const customRowStyle = computed(() => {
if (!props.taskListRowStyle) return {}
if (typeof props.taskListRowStyle === 'function') {
return props.taskListRowStyle(props.task, props.rowIndex ?? 0)
}
return props.taskListRowStyle
})
function handleToggle() {
emit('toggle', props.task)
}
function handleRowClick() {
// 如果是普通父级任务story类型或有子任务的任务非里程碑分组点击行也可以展开/收起
if ((isStoryTask.value || hasChildren.value) && !isMilestoneGroup.value) {
emit('toggle', props.task)
}
}
// 处理TaskRow双击事件 (与TaskBar逻辑保持一致)
const handleTaskRowDoubleClick = (e: MouseEvent) => {
// 阻止事件冒泡
e.stopPropagation()
// 发出双击事件给父组件
emit('dblclick', props.task)
}
// 处理悬停事件
const handleMouseEnter = () => {
// 如果正在拖拽Splitter忽略悬停事件
if (isSplitterDragging.value) return
if (props.onHover) {
props.onHover(props.task.id)
}
}
const handleMouseLeave = () => {
// 如果正在拖拽Splitter忽略悬停事件
if (isSplitterDragging.value) return
if (props.onHover) {
props.onHover(null)
}
}
// 获取进度值的样式类
function getProgressClass() {
const progress = props.task.progress || 0
const today = new Date()
const endDate = props.task.endDate ? new Date(props.task.endDate) : null
// 超期且未完成
if (endDate && today > endDate && progress < 100) {
return 'progress-danger'
}
// 已完成
if (progress >= 100) {
return 'progress-success'
}
// 进行中
if (progress > 0) {
return 'progress-warning'
}
return ''
}
// 检查是否超时
function isOvertime() {
return (
props.task.actualHours &&
props.task.estimatedHours &&
props.task.actualHours > props.task.estimatedHours
)
}
// 检查是否逾期,返回天数
function overdueDays() {
const today = new Date()
const endDate = props.task.endDate ? new Date(props.task.endDate) : null
const progress = props.task.progress || 0
if (endDate && today > endDate && progress < 100) {
// 只计算日期部分
const t = new Date(today.getFullYear(), today.getMonth(), today.getDate())
const e = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
const diff = Math.floor((t.getTime() - e.getTime()) / (1000 * 60 * 60 * 24))
return diff
}
return 0
}
// 拖拽状态管理
const isSplitterDragging = ref(false)
// 处理拖拽开始事件
const handleSplitterDragStart = () => {
isSplitterDragging.value = true
}
// 处理拖拽结束事件
const handleSplitterDragEnd = () => {
isSplitterDragging.value = false
}
// 任务计时器状态
const timerElapsed = ref(0)
const timerInterval = ref<number | null>(null)
// 格式化计时器显示:转换为 HH:MM:SS 格式
const formattedTimer = computed(() => {
const totalSeconds = Math.floor(timerElapsed.value / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
})
// 更新计时器显示
const updateTimer = () => {
if (props.task.isTimerRunning && props.task.timerStartTime) {
// 计算已经运行的时间 = 当前时间 - 开始时间 + 之前累积的时间
const previousElapsed = props.task.timerElapsedTime || 0
timerElapsed.value = Date.now() - props.task.timerStartTime + previousElapsed
} else if (props.task.timerElapsedTime) {
// 如果任务不在运行中,但有累计时间,显示累计时间
timerElapsed.value = props.task.timerElapsedTime
} else {
// 默认情况下计时器为0
timerElapsed.value = 0
}
}
// 监听任务的计时状态变化
watch(
() => [props.task.isTimerRunning, props.task.timerStartTime, props.task.timerElapsedTime],
() => {
// 清除之前的计时器
if (timerInterval.value) {
clearInterval(timerInterval.value)
timerInterval.value = null
}
// 如果任务正在计时,开始计时器
if (props.task.isTimerRunning) {
updateTimer()
timerInterval.value = window.setInterval(updateTimer, 1000)
} else {
// 更新一次最终值
updateTimer()
}
},
{ immediate: true },
)
// 右键菜单相关状态
const contextMenuVisible = ref(false)
const contextMenuPosition = ref({ x: 0, y: 0 })
const contextMenuTask = computed(() => props.task)
// 处理右键菜单显示
function handleContextMenu(event: MouseEvent) {
// 先广播关闭所有TaskRow菜单
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
if (props.task.type !== 'task' && props.task.type !== 'story') {
// 为了排除里程碑类型
event.preventDefault()
contextMenuVisible.value = false
return
}
event.preventDefault()
contextMenuVisible.value = true
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
}
// 关闭右键菜单
function closeContextMenu() {
contextMenuVisible.value = false
}
const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
// 触发删除事件
emit('delete', task, deleteChildren)
closeContextMenu()
}
const progressClass = computed(() => getProgressClass())
const slotPayload = computed(() => ({
isRowContent: true,
task: props.task,
level: props.level,
indent: indent.value,
isHovered: props.isHovered ?? false,
hoveredTaskId: props.hoveredTaskId ?? null,
isParent: isParentTask.value,
hasChildren: hasChildren.value,
collapsed: !!props.task.collapsed,
formattedTimer: formattedTimer.value,
timerRunning: !!props.task.isTimerRunning,
timerElapsed: timerElapsed.value,
isOvertime: isOvertime(),
overdueDays: overdueDays(),
overtimeText: overtimeText.value,
overdueText: overdueText.value,
daysText: daysText.value,
progressClass: progressClass.value,
}))
// TaskRow拖拽功能
const taskRowRef = ref<HTMLElement | null>(null)
const handleMouseDown = (event: MouseEvent) => {
// 如果未启用拖拽,直接返回
if (!props.enableDrag) return
// 如果点击的是按钮、输入框等交互元素,不触发拖拽
const target = event.target as HTMLElement
if (
target.tagName === 'BUTTON' ||
target.tagName === 'INPUT' ||
target.tagName === 'SELECT' ||
target.tagName === 'TEXTAREA' ||
target.closest('.collapse-btn') ||
target.closest('.task-context-menu')
) {
return
}
if (taskRowRef.value && props.dragStart) {
props.dragStart(props.task, taskRowRef.value, event)
}
}
// 处理全局拖拽悬停事件
const handleTaskRowDragOver = (event: CustomEvent) => {
if (!props.enableDrag || !taskRowRef.value || !props.dragOver) return
const { taskId, event: mouseEvent } = event.detail
if (taskId === props.task.id) {
props.dragOver(props.task, taskRowRef.value, mouseEvent)
}
}
// 生命周期钩子 - 注册事件监听器
onMounted(() => {
window.addEventListener('splitter-drag-start', handleSplitterDragStart)
window.addEventListener('splitter-drag-end', handleSplitterDragEnd)
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
window.addEventListener('task-row-drag-over', handleTaskRowDragOver as EventListener)
})
onUnmounted(() => {
window.removeEventListener('splitter-drag-start', handleSplitterDragStart)
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd)
window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
window.removeEventListener('task-row-drag-over', handleTaskRowDragOver as EventListener)
if (timerInterval.value) {
clearInterval(timerInterval.value)
}
})
</script>
<template>
<div>
<div
ref="taskRowRef"
class="task-row"
:data-task-id="props.task.id"
:class="{
'task-row-hovered': isHovered,
'parent-task': isParentTask,
'milestone-group-row': isMilestoneGroup,
'task-type-story': isStoryTask,
'task-type-task': props.task.type === 'task',
'task-type-milestone': isMilestoneTask,
[customRowClass]: customRowClass,
}"
:style="customRowStyle"
@click="handleRowClick"
@dblclick="handleTaskRowDoubleClick"
@mousedown="handleMouseDown"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@contextmenu="handleContextMenu"
>
<!-- 声明式渲染模式 -->
<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),
}"
>
<!-- 第一列保留 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"
>
<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>
<!-- 优先级1: 列级自定义 Slot (#column-name) - 覆盖整列内容图标+文本+徽章 -->
<component
:is="() => renderColumnSlot('name', {
task: props.task,
column: { key: 'name' },
value: props.task.name,
isParentTask,
hasChildren,
isStoryTask: isStoryTask.value,
isMilestoneGroup: isMilestoneGroup.value,
isMilestoneTask: isMilestoneTask.value,
showTaskIcon: props.showTaskIcon,
formattedTimer: formattedTimer.value,
isOvertime: isOvertime(),
overdueDays: overdueDays(),
overtimeText: overtimeText.value,
overdueText: overdueText.value,
daysText: daysText.value,
})"
v-if="hasColumnSlot('name')"
/>
<!-- 优先级2: custom-task-content Slot (向后兼容) - 仅替换文本部分 -->
<template v-else-if="hasContentSlot">
<!-- 任务图标 -->
<span v-if="showTaskIcon !== false" class="task-icon">
<!-- 里程碑分组图标 - 使用菱形图标 -->
<svg
v-if="isMilestoneGroup"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="milestone-group-icon"
>
<polygon points="12,2 22,12 12,22 2,12" />
</svg>
<!-- 父级任务图标 -->
<svg
v-else-if="isStoryTask || hasChildren"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<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" />
</svg>
<!-- 普通任务图标 -->
<svg
v-else
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10,9 9,9 8,9" />
</svg>
</span>
<span
class="task-name-text"
:class="{ 'parent-task': isParentTask }"
:title="props.task.name"
>
<slot
name="custom-task-content"
v-bind="slotPayload"
type="task-row"
/>
<!-- 计时器显示 -->
<span
v-if="props.task.isTimerRunning || props.task.timerElapsedTime"
class="timer-badge"
:class="{ 'timer-active': props.task.isTimerRunning }"
>
<span v-if="props.task.isTimerRunning" class="timer-dot"></span>
{{ formattedTimer }}
</span>
<span v-if="isOvertime()" class="status-badge overtime">{{ overtimeText }}</span>
<span v-if="overdueDays() > 0" class="status-badge overdue">
{{ overdueText }}{{ overdueDays() > 0 ? overdueDays() + daysText : '' }}
</span>
</span>
</template>
<!-- 优先级3: 默认渲染 -->
<template v-else>
<!-- 任务图标 -->
<span v-if="showTaskIcon !== false" class="task-icon">
<!-- 里程碑分组图标 - 使用菱形图标 -->
<svg
v-if="isMilestoneGroup"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="milestone-group-icon"
>
<polygon points="12,2 22,12 12,22 2,12" />
</svg>
<!-- 父级任务图标 -->
<svg
v-else-if="isStoryTask || hasChildren"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<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" />
</svg>
<!-- 普通任务图标 -->
<svg
v-else
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10,9 9,9 8,9" />
</svg>
</span>
<span
class="task-name-text"
:class="{ 'parent-task': isParentTask }"
:title="props.task.name"
>
{{ props.task.name }}
<!-- 计时器显示 -->
<span
v-if="props.task.isTimerRunning || props.task.timerElapsedTime"
class="timer-badge"
:class="{ 'timer-active': props.task.isTimerRunning }"
>
<span v-if="props.task.isTimerRunning" class="timer-dot"></span>
{{ formattedTimer }}
</span>
<span v-if="isOvertime()" class="status-badge overtime">{{ overtimeText }}</span>
<span v-if="overdueDays() > 0" class="status-badge overdue">
{{ overdueText }}{{ overdueDays() > 0 ? overdueDays() + daysText : '' }}
</span>
</span>
</template>
</div>
<!-- 动态渲染列 -->
<div
v-for="column in columns"
:key="column.key"
class="col"
:class="column.cssClass || `col-${column.key}`"
:style="getColumnWidthStyle ? getColumnWidthStyle(column) : undefined"
>
<!-- 里程碑分组显示空列 -->
<template v-if="isMilestoneGroup">
<div class="milestone-empty-col"></div>
</template>
<!-- 普通任务显示具体内容 -->
<template v-else>
<!-- 优先级1: 列级自定义 Slot (约定: #column-{key})通过 renderColumnSlot 动态渲染 -->
<component
:is="() =>
renderColumnSlot(column.key,
{ task: props.task, column, value: (props.task as any)[column.key] })"
v-if="hasColumnSlot(column.key)"
/>
<!-- 优先级2: Formatter 函数 -->
<template v-else-if="column.formatter">
{{ column.formatter(props.task, column) }}
</template>
<!-- 优先级3: 内置列类型渲染 -->
<!-- 前置任务列 -->
<template v-else-if="column.key === 'predecessor'">
{{ formatPredecessorDisplay(props.task.predecessor) }}
</template>
<!-- 负责人列 -->
<template v-else-if="column.key === 'assignee'">
<div class="assignee-info">
<div class="avatar">
{{ props.task.assignee ? props.task.assignee.charAt(0) : '-' }}
</div>
<span class="assignee-name">{{ props.task.assignee || '-' }}</span>
</div>
</template>
<!-- 开始日期列 -->
<template v-else-if="column.key === 'startDate'">
{{ props.task.startDate || '-' }}
</template>
<!-- 结束日期列 -->
<template v-else-if="column.key === 'endDate'">
{{ props.task.endDate || '-' }}
</template>
<!-- 预估工时列 -->
<template v-else-if="column.key === 'estimatedHours'">
{{ props.task.estimatedHours || '-' }}
</template>
<!-- 实际工时列 -->
<template v-else-if="column.key === 'actualHours'">
{{ props.task.actualHours || '-' }}
</template>
<!-- 进度列 -->
<template v-else-if="column.key === 'progress'">
<span class="progress-value" :class="progressClass">
{{ props.task.progress != null ? props.task.progress + '%' : '-' }}
</span>
</template>
<!-- 优先级4: 默认渲染 - 自定义列通过task对象的key动态获取值 -->
<template v-else>
{{ (props.task as any)[column.key] || '-' }}
</template>
</template>
</div>
</template>
<!-- 结束默认渲染模式 -->
</div>
<template
v-if="
!props.disableChildrenRender && hasChildren && !props.task.collapsed && !isMilestoneGroup
"
>
<TaskRow
v-for="child in props.task.children"
:key="child.id"
:task="child"
:level="props.level + 1"
:is-hovered="props.hoveredTaskId === child.id"
: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"
:enable-drag="props.enableDrag"
:drag-start="props.dragStart"
:drag-over="props.dragOver"
@toggle="emit('toggle', $event)"
@dblclick="emit('dblclick', $event)"
@start-timer="emit('start-timer', $event)"
@stop-timer="emit('stop-timer', $event)"
@add-predecessor="emit('add-predecessor', $event)"
@add-successor="emit('add-successor', $event)"
@delete="handleTaskDelete"
>
<!-- 传递 custom-task-content slot -->
<template v-if="hasContentSlot" #custom-task-content="slotProps">
<slot name="custom-task-content" v-bind="slotProps as TaskRowSlotProps" />
</template>
</TaskRow>
</template>
<TaskContextMenu
:visible="contextMenuVisible"
:task="contextMenuTask"
:position="contextMenuPosition"
@close="closeContextMenu"
@start-timer="$emit('start-timer', props.task)"
@stop-timer="$emit('stop-timer', props.task)"
@add-predecessor="$emit('add-predecessor', props.task)"
@add-successor="$emit('add-successor', props.task)"
@delete="handleTaskDelete"
/>
</div>
</template>
<style scoped>
@import '../styles/theme-variables.css';
@import '../styles/list.css';
.task-row {
display: flex;
border-bottom: 1px solid var(--gantt-border-light);
height: 51px; /* 修改为51px与Timeline中的task-row高度保持一致包含border-bottom 1px */
box-sizing: border-box; /* 确保border包含在高度计算中 */
background: var(--gantt-bg-primary);
align-items: center;
color: var(--gantt-text-secondary);
cursor: pointer;
transition: all 0.3s ease;
transform: scale(1);
transform-origin: 5px center; /* 从左侧偏右5px的位置作为放大中心 */
z-index: 1;
position: relative;
}
.task-row:hover {
background-color: var(--gantt-bg-hover);
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
}
.task-row-hovered {
background-color: var(--gantt-bg-hover) !important;
transform: scale(1.02) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
z-index: 10 !important;
}
.task-row.parent-task {
background: var(--gantt-bg-tertiary);
font-weight: 600;
}
.task-row.parent-task:hover {
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover));
transform: scale(1.02);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
z-index: 10;
}
.task-row.parent-task.task-row-hovered {
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover)) !important;
transform: scale(1.02) !important;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2) !important;
z-index: 10 !important;
}
/* 里程碑分组行特殊样式 - 使用红色边框 */
.milestone-group-row {
border-left: 3px solid var(--gantt-danger, #f56c6c);
background: linear-gradient(90deg, var(--gantt-bg-tertiary) 0%, var(--gantt-bg-primary) 100%);
}
.milestone-group-row:hover {
background: linear-gradient(90deg, var(--gantt-bg-hover-parent) 0%, var(--gantt-bg-hover) 100%);
transform: scale(1.02);
box-shadow:
0 6px 16px rgba(245, 108, 108, 0.3),
0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 10;
border-left-color: var(--gantt-danger, #f56c6c);
border-left-width: 4px; /* 悬停时边框稍微加粗 */
}
/* 任务类型左边框颜色 */
.task-type-story {
border-left: 3px solid var(--gantt-primary, #409eff);
}
.task-type-task {
border-left: 3px solid var(--gantt-warning, #e6a23c);
}
.task-type-milestone {
border-left: 3px solid var(--gantt-danger, #f56c6c);
}
/* 任务类型悬停时保持并增强左边框 */
.task-type-story:hover {
border-left: 5px solid var(--gantt-primary, #409eff);
}
.task-type-task:hover {
border-left: 5px solid var(--gantt-warning, #e6a23c);
}
.task-type-milestone:hover {
border-left: 5px solid var(--gantt-danger, #f56c6c);
}
/* 悬停状态下的左边框保持 */
.task-row-hovered.task-type-story {
border-left: 5px solid var(--gantt-primary, #409eff) !important;
}
.task-row-hovered.task-type-task {
border-left: 5px solid var(--gantt-warning, #e6a23c) !important;
}
.task-row-hovered.task-type-milestone {
border-left: 5px solid var(--gantt-danger, #f56c6c) !important;
}
:global(html[data-theme='dark']) .milestone-group-row {
border-left-color: var(--gantt-danger, #f67c7c);
}
/* 暗黑模式下的任务类型左边框颜色 */
:global(html[data-theme='dark']) .task-type-story {
border-left-color: var(--gantt-primary, #7db4f0);
}
:global(html[data-theme='dark']) .task-type-task {
border-left-color: var(--gantt-warning, #f0b83c);
}
:global(html[data-theme='dark']) .task-type-milestone {
border-left-color: var(--gantt-danger, #f67c7c);
}
.collapse-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
cursor: pointer;
margin-right: 4px;
color: var(--gantt-primary);
border-radius: 2px;
transition: background-color 0.2s ease;
}
.collapse-btn:hover {
background-color: var(--gantt-primary-light);
}
.collapse-btn svg {
transition: transform 0.2s ease;
}
/* 里程碑分组占位空间 - 与折叠按钮对齐 */
.milestone-spacer {
display: inline-flex;
width: 18px;
height: 18px;
margin-right: 4px;
}
/* 叶子节点占位空间 - 当不显示图标时保持缩进层级 */
.leaf-spacer {
display: inline-flex;
width: 20px; /* 18px (折叠按钮宽度) + 2px (额外间距) */
height: 18px;
}
.task-name-text {
display: inline-block;
max-width: calc(100% - 24px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.task-name-text.parent-task {
font-weight: bold;
color: var(--gantt-text-parent, var(--gantt-text-primary));
}
.task-icon {
margin-right: 4px;
color: var(--gantt-text-muted);
}
.task-icon svg {
vertical-align: middle;
}
.assignee-info {
display: flex;
align-items: center;
gap: 8px;
}
.avatar {
min-width: 25px;
min-height: 25px;
border-radius: 50%;
background: var(--gantt-primary);
color: var(--gantt-text-white);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 500;
border: 2px solid var(--gantt-border-medium);
box-sizing: border-box;
}
.assignee-name {
font-size: 14px;
color: var(--gantt-text-secondary);
}
.progress-value {
font-weight: 500;
color: var(--gantt-text-secondary);
}
.progress-success {
color: var(--gantt-success);
}
.progress-warning {
color: var(--gantt-warning);
}
.progress-danger {
color: var(--gantt-danger);
}
.status-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 2px;
font-size: 10px;
font-weight: bold;
margin-left: 6px;
color: white;
}
.status-badge.overtime {
background-color: transparent;
border: 1px solid var(--gantt-danger);
color: var(--gantt-danger);
}
.status-badge.overdue {
background-color: var(--gantt-danger);
}
/* 里程碑分组图标样式 - 统一使用红色并添加发光效果 */
.milestone-group-icon {
color: var(--gantt-danger, #f56c6c);
fill: var(--gantt-danger, #f56c6c);
opacity: 0.9;
filter: drop-shadow(0 0 6px var(--gantt-danger, #f56c6c));
animation: milestone-icon-glow 2.5s ease-in-out infinite alternate;
}
/* 里程碑图标悬停效果 */
.task-row:hover .milestone-group-icon {
filter: drop-shadow(0 0 10px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 16px rgba(245, 108, 108, 0.4));
animation: milestone-icon-glow-intense 1.8s ease-in-out infinite alternate;
}
/* 里程碑图标发光动画 */
@keyframes milestone-icon-glow {
from {
filter: drop-shadow(0 0 3px var(--gantt-danger, #f56c6c));
}
to {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 12px rgba(245, 108, 108, 0.3));
}
}
@keyframes milestone-icon-glow-intense {
from {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 12px rgba(245, 108, 108, 0.3));
}
to {
filter: drop-shadow(0 0 12px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 20px rgba(245, 108, 108, 0.5));
}
}
/* 里程碑行样式 - 统一使用红色 */
.milestone-item-icon {
color: var(--gantt-danger, #f56c6c);
}
.milestone-empty-col {
color: var(--gantt-text-disabled, #c0c4cc);
/* 确保边框颜色与普通数据行一致 */
border-right-color: var(--gantt-border-light) !important;
}
.milestone-empty-col::after {
content: '-';
}
/* 暗黑模式适配 */
:global(html[data-theme='dark']) .milestone-row-icon {
color: var(--gantt-danger, #f67c7c);
}
/* 暗黑模式下的里程碑图标发光效果 */
:global(html[data-theme='dark']) .milestone-group-icon {
color: var(--gantt-danger, #f67c7c);
fill: var(--gantt-danger, #f67c7c);
filter: drop-shadow(0 0 6px var(--gantt-danger, #f67c7c));
animation: milestone-icon-glow-dark 2.5s ease-in-out infinite alternate;
}
:global(html[data-theme='dark']) .task-row:hover .milestone-group-icon {
filter: drop-shadow(0 0 10px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 16px rgba(246, 124, 124, 0.4));
animation: milestone-icon-glow-intense-dark 1.8s ease-in-out infinite alternate;
}
/* 暗黑模式发光动画 */
@keyframes milestone-icon-glow-dark {
from {
filter: drop-shadow(0 0 3px var(--gantt-danger, #f67c7c));
}
to {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 12px rgba(246, 124, 124, 0.3));
}
}
@keyframes milestone-icon-glow-intense-dark {
from {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 12px rgba(246, 124, 124, 0.3));
}
to {
filter: drop-shadow(0 0 12px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 20px rgba(246, 124, 124, 0.5));
}
}
:global(html[data-theme='dark']) .milestone-empty-col {
color: var(--gantt-text-disabled, #606266);
/* 确保暗黑模式下边框颜色与普通数据行一致 */
border-right-color: var(--gantt-border-light) !important;
}
/* 暗黑模式的悬停效果 */
:global(html[data-theme='dark']) .task-row:hover {
box-shadow:
0 4px 12px rgba(255, 255, 255, 0.1),
0 2px 8px rgba(0, 0, 0, 0.3);
}
:global(html[data-theme='dark']) .task-row.task-row-hovered {
background-color: var(--gantt-bg-hover) !important;
box-shadow:
0 4px 12px rgba(255, 255, 255, 0.1),
0 2px 8px rgba(0, 0, 0, 0.3) !important;
}
:global(html[data-theme='dark']) .task-row.parent-task:hover {
box-shadow:
0 6px 16px rgba(255, 255, 255, 0.15),
0 2px 8px rgba(0, 0, 0, 0.4);
}
:global(html[data-theme='dark']) .task-row.parent-task.task-row-hovered {
background: var(--gantt-bg-hover-parent) !important;
box-shadow:
0 6px 16px rgba(255, 255, 255, 0.15),
0 2px 8px rgba(0, 0, 0, 0.4) !important;
}
:global(html[data-theme='dark']) .milestone-group-row:hover {
box-shadow:
0 6px 16px rgba(246, 124, 124, 0.4),
0 2px 8px rgba(255, 255, 255, 0.1);
}
/* 计时器样式 */
.timer-badge {
display: inline-flex;
align-items: center;
font-size: 12px;
font-weight: 700;
margin-left: 8px;
padding: 1px 6px;
border-radius: 10px;
background-color: rgba(0, 0, 0, 0.05);
color: var(--text-color-secondary);
}
.timer-badge.timer-active {
color: #e6a23c;
}
.timer-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #67c23a; /* 绿色 */
margin-right: 4px;
animation: pulse 1s infinite;
}
@keyframes pulse {
0% {
transform: scale(0.8);
opacity: 0.8;
}
50% {
transform: scale(1.2);
opacity: 1;
}
100% {
transform: scale(0.8);
opacity: 0.8;
}
}
:global(html[data-theme='dark']) .timer-badge {
background-color: rgba(255, 255, 255, 0.1);
color: var(--text-color-secondary-dark);
}
:global(html[data-theme='dark']) .timer-badge.timer-active {
color: #e6c07b;
}
:global(html[data-theme='dark']) .timer-dot {
background-color: #85ce61; /* 暗色主题下的绿色 */
}
/* TaskRow拖拽样式 */
.task-row-dragging {
opacity: 0.6 !important;
cursor: move !important;
}
/* 拖拽目标高亮样式 */
.task-row-drop-target.drop-after {
border-bottom: 3px solid var(--gantt-primary, #409eff) !important;
background-color: rgba(64, 158, 255, 0.05) !important;
}
.task-row-drop-target.drop-child {
border: 2px solid var(--gantt-primary, #409eff) !important;
background-color: rgba(64, 158, 255, 0.05) !important;
}
:global(html[data-theme='dark']) .task-row-drop-target.drop-after {
background-color: rgba(125, 180, 240, 0.1) !important;
}
: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>