@@ -1819,12 +1876,16 @@ const handleCustomMenuAction = (action: string, task: Task) => {
:allow-drag-and-resize="allowDragAndResize"
:enable-task-row-move="enableTaskRowMove"
:assignee-options="assigneeOptions"
- :task-list-row-class-name="getTaskRowClassName"
- :task-list-row-style="getTaskRowStyle"
:on-export-csv="handleCustomCsvExport"
:on-language-change="handleLanguageChange"
:on-theme-change="handleThemeChange"
:task-list-column-render-mode="taskListColumnRenderMode"
+ :show-actual-taskbar="showActualTaskBar"
+ :enable-task-bar-tooltip="true"
+ :pending-task-background-color="pendingTaskBackgroundColor"
+ :delay-task-background-color="delayTaskBackgroundColor"
+ :complete-task-background-color="completeTaskBackgroundColor"
+ :ongoing-task-background-color="ongoingTaskBackgroundColor"
@milestone-saved="handleMilestoneSaved"
@milestone-deleted="handleMilestoneDeleted"
@milestone-icon-changed="handleMilestoneIconChanged"
@@ -2528,6 +2589,62 @@ const handleCustomMenuAction = (action: string, task: Task) => {
color: var(--gantt-primary-color, #409eff);
}
+/* 自定义颜色配置组 */
+.color-config-group {
+ margin-top: 16px;
+ padding: 12px;
+ background: var(--gantt-bg-tertiary, #fafafa);
+ border-radius: 8px;
+ border: 1px solid var(--gantt-border-color, #e4e7ed);
+}
+
+.color-config-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--gantt-text-secondary, #606266);
+ margin: 0 0 12px 0;
+}
+
+.color-inputs-row {
+ display: flex;
+ gap: 12px;
+ flex-wrap: nowrap;
+}
+
+.color-input-item {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ flex: 1;
+ min-width: 0;
+}
+
+.color-label {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--gantt-text-secondary, #606266);
+}
+
+.color-input {
+ padding: 6px 10px;
+ font-size: 13px;
+ border: 1px solid var(--gantt-border-color, #dcdfe6);
+ border-radius: 4px;
+ background: var(--gantt-bg-primary, #ffffff);
+ color: var(--gantt-text-primary, #333);
+ transition: all 0.2s ease;
+}
+
+.color-input:focus {
+ outline: none;
+ border-color: var(--gantt-primary-color, #409eff);
+ box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
+}
+
+.color-input::placeholder {
+ color: var(--gantt-text-placeholder, #c0c4cc);
+}
+
/* TaskBar 高级配置样式 */
.taskbar-advanced-controls {
display: flex;
diff --git a/demo/data.json b/demo/data.json
index 555878f..c04c5bd 100644
--- a/demo/data.json
+++ b/demo/data.json
@@ -27,21 +27,25 @@
"description": "评估ADX-2024在健康志愿者和患者中的安全性、耐受性和药代动力学特征",
"custom": "test11111",
"priority": "高",
+ "barColor": "#7B3FF2",
"children": [
{
"id": 1101,
"name": "试验方案设计与
伦理审查",
"assignee": "方案设计师 李明",
- "avatar": "50-1.jpg",
+ "avatar": ["50-1.jpg", "50-2.jpg"],
"startDate": "2025-01-01",
"endDate": "2025-02-28",
+ "actualStartDate": "2025-01-03",
+ "actualEndDate": "2025-02-25",
"progress": 100,
"estimatedHours": 464,
"actualHours": 480,
"type": "task",
"parentId": 1100,
"description": "完成I期试验方案设计、伦理委员会审批及监管部门申报",
- "custom": "test11111"
+ "custom": "test11111",
+ "barColor": "#2E7D6B"
},
{
"id": 1102,
@@ -50,6 +54,8 @@
"avatar": "50-2.jpg",
"startDate": "2025-03-01",
"endDate": "2025-04-30",
+ "actualStartDate": "2025-03-05",
+ "actualEndDate": "2025-04-28",
"progress": 90,
"estimatedHours": 1392.5,
"actualHours": 1250.25,
@@ -57,15 +63,17 @@
"predecessor": [1101],
"parentId": 1100,
"description": "招募24名健康志愿者,完成医学筛选和入组评估",
- "custom": "test11111"
+ "custom": "test11111",
+ "barColor": "#FF4D4F"
},
{
"id": 1103,
"name": "药物给药与安全性监测",
"assignee": "临床医生 Dr. Liu",
- "avatar": "50-3.jpg",
+ "avatar": ["50-3.jpg", "50-2.jpg"],
"startDate": "2025-05-01",
"endDate": "2025-08-31",
+ "actualStartDate": "2025-05-10",
"progress": 40,
"estimatedHours": 3400,
"actualHours": 1687,
@@ -73,7 +81,8 @@
"predecessor": [1102],
"parentId": 1100,
"description": "按照剂量递增方案给药,密切监测不良反应和药代动力学参数",
- "custom": "test11111"
+ "custom": "test11111",
+ "barColor": "#7B3FF2"
}
],
"collapsed": false
@@ -91,14 +100,16 @@
"parentId": 1000,
"description": "在目标患者群体中评估ADX-2024的初步疗效和进一步的安全性",
"custom": "test11111",
+ "barColor": "#3F6DF6",
"children": [
{
"id": 1201,
"name": "多中心试验
启动",
"assignee": "项目经理 王芳",
- "avatar": "50-10.jpg",
+ "avatar": ["50-10.jpg", "50-2.jpg"],
"startDate": "2025-09-01",
"endDate": "2025-11-30",
+ "actualStartDate": "2025-09-01",
"progress": 25,
"estimatedHours": 2160,
"actualHours": 324,
@@ -106,13 +117,14 @@
"predecessor": [1103],
"parentId": 1200,
"description": "启动5个临床中心,完成研究者培训和质量体系建立",
- "custom": "test11111"
+ "custom": "test11111",
+ "barColor": "#3F6DF6"
},
{
"id": 1202,
"name": "患者入组与随机化",
"assignee": "数据管理员 陈静",
- "avatar": "50-6.jpg",
+ "avatar": ["50-6.jpg", "50-10.jpg"],
"startDate": "2025-12-01",
"endDate": "2026-03-31",
"progress": 0,
@@ -138,7 +150,8 @@
"predecessor": [1202],
"parentId": 1200,
"description": "定期评估患者疗效指标,收集安全性数据,进行中期分析",
- "custom": "test11111"
+ "custom": "test11111",
+ "barColor": "#FFA43A"
}
],
"collapsed": false
diff --git a/demo/locales/en-US.json b/demo/locales/en-US.json
index 5847606..3499556 100644
--- a/demo/locales/en-US.json
+++ b/demo/locales/en-US.json
@@ -111,5 +111,21 @@
"false": "False"
}
}
+ },
+ "taskBarConfig": {
+ "display": {
+ "showActualTaskBar": "Show Actual TaskBar"
+ },
+ "colorConfig": {
+ "title": "Custom Task Status Background Colors",
+ "pendingTask": "Pending Task",
+ "delayTask": "Overdue Task",
+ "completeTask": "Completed Task",
+ "ongoingTask": "Ongoing Task"
+ },
+ "enableTaskRowMove": {
+ "label": "Enable TaskRow Drag and Drop",
+ "hint": "Allow dragging TaskRow to adjust task hierarchy and order"
+ }
}
}
diff --git a/demo/locales/zh-CN.json b/demo/locales/zh-CN.json
index a1dec5f..221d2e0 100644
--- a/demo/locales/zh-CN.json
+++ b/demo/locales/zh-CN.json
@@ -111,5 +111,21 @@
"false": "假"
}
}
+ },
+ "taskBarConfig": {
+ "display": {
+ "showActualTaskBar": "显示实际TaskBar"
+ },
+ "colorConfig": {
+ "title": "自定义任务状态背景色",
+ "pendingTask": "待处理任务",
+ "delayTask": "逾期任务",
+ "completeTask": "已完成任务",
+ "ongoingTask": "进行中任务"
+ },
+ "enableTaskRowMove": {
+ "label": "启用TaskRow拖拽移动",
+ "hint": "允许通过拖拽TaskRow来调整任务的层级和顺序"
+ }
}
}
diff --git a/demo/useDemoLocale.ts b/demo/useDemoLocale.ts
index 2a75c72..c5e6d44 100644
--- a/demo/useDemoLocale.ts
+++ b/demo/useDemoLocale.ts
@@ -27,11 +27,114 @@ interface LocaleMessages {
task: string
}
}
+ taskBarConfig: {
+ display: {
+ showActualTaskBar: string
+ }
+ colorConfig: {
+ title: string
+ pendingTask: string
+ delayTask: string
+ completeTask: string
+ ongoingTask: string
+ }
+ enableTaskRowMove: {
+ label: string
+ hint: string
+ }
+ }
+ toolSettings: {
+ title: string
+ currentStatus: {
+ title: string
+ fullscreen: string
+ expandAll: string
+ locale: string
+ timeScale: string
+ theme: string
+ controlMode: string
+ active: string
+ inactive: string
+ expanded: string
+ collapsed: string
+ }
+ controlMode: {
+ title: string
+ expose: string
+ props: string
+ exposeHint: string
+ propsHint: string
+ }
+ exposeMethods: {
+ sectionTitle: string
+ fullscreen: {
+ title: string
+ enter: string
+ exit: string
+ toggle: string
+ }
+ expand: {
+ title: string
+ all: string
+ none: string
+ toggle: string
+ }
+ timeScale: {
+ title: string
+ hour: string
+ day: string
+ week: string
+ month: string
+ quarter: string
+ year: string
+ zoomIn: string
+ zoomOut: string
+ }
+ locale: {
+ title: string
+ zhCN: string
+ enUS: string
+ }
+ theme: {
+ title: string
+ light: string
+ dark: string
+ }
+ navigation: {
+ title: string
+ today: string
+ taskIdPlaceholder: string
+ go: string
+ }
+ }
+ propsControl: {
+ sectionTitle: string
+ locale: {
+ title: string
+ }
+ theme: {
+ title: string
+ }
+ timeScale: {
+ title: string
+ }
+ fullscreen: {
+ title: string
+ true: string
+ false: string
+ }
+ expandAll: {
+ title: string
+ true: string
+ false: string
+ }
+ }
+ }
}
const localeMessages: Record
= {
- 'zh-CN': zhCN,
- 'en-US': enUS,
+ 'zh-CN': zhCN as any,
+ 'en-US': enUS as any,
}
const currentLocale = ref('zh-CN')
diff --git a/demo/version-history.json b/demo/version-history.json
index bca4200..6d36d38 100644
--- a/demo/version-history.json
+++ b/demo/version-history.json
@@ -466,5 +466,31 @@
"🎉 Added: New property in GanttChart - enableLinkAnchor to control the relationship line anchor points of Taskbar",
"Special thanks to YQ6494@gitee for their use and suggestions"
]
+ },
+ {
+ "version": "1.8.0",
+ "date": "2026-01-17",
+ "notes": [
+ "🎉 新增:GanttChart允许展示实际Taskbar",
+ "🎉 新增:TaskDrawer增加实际开始日期和实际结束日期的属性",
+ "🎉 新增:TaskDrawer增加当前Task状态:待处理、进行中、已完成、已逾期",
+ "🎉 新增:GanttChart增加Taskbar不同状态下背景色预设属性pendingTaskBackgroundColor、delayTaskBackgroundColor、completeTaskBackgroundColor、ongoingTaskBackgroundColor",
+ "🎉 新增:Task对象增加barColor属性,允许自定义对应Taskbar的背景色,优先级高于GanttChart预设背景色",
+ "🎉 新增:Taskbar增加气泡提示框",
+ "🎉 新增:GanttChart增加showActualTaskbar属性用于控制是否展示实际Taskbar,默认值为false,需要时手动打开",
+ "🎉 新增:GanttChart增加enableTaskbarTooltip属性用于控制是否展示Taskbar的气泡提示框,默认值为true,需要时手动关闭",
+ "🎉 优化:Task对象assignee以及avatar属性允许接收数组,标记多为负责人",
+ "特别感谢 yunlbs/fhjfhj/jakepan@gitee的使用及建议",
+ "🎉 Added: GanttChart allows displaying actual Taskbar",
+ "🎉 Added: TaskDrawer adds properties for actual start date and actual end date",
+ "🎉 Added: TaskDrawer adds current Task status: Pending, Ongoing, Completed, Overdue",
+ "🎉 Added: GanttChart adds preset background color properties for Taskbar in different statuses: pendingTaskBackgroundColor, delayTaskBackgroundColor, completeTaskBackgroundColor, ongoingTaskBackgroundColor",
+ "🎉 Added: Task object adds barColor property to allow customization of the corresponding Taskbar background color, with higher priority than GanttChart preset background colors",
+ "🎉 Added: Taskbar adds tooltip",
+ "🎉 Added: GanttChart adds showActualTaskbar property to control whether to show actual Taskbar",
+ "🎉 Added: GanttChart adds enableTaskbarTooltip property to control whether to show Taskbar tooltip",
+ "🎉 Optimized: The assignee and avatar properties of the Task object now accept arrays to mark multiple assignees",
+ "Special thanks to yunlbs/fhjfhj/jakepan@gitee for their use and suggestions"
+ ]
}
]
diff --git a/design/screenshots/dark-theme-en.png b/design/screenshots/dark-theme-en.png
index 8835750..4f42a6c 100644
Binary files a/design/screenshots/dark-theme-en.png and b/design/screenshots/dark-theme-en.png differ
diff --git a/design/screenshots/dark-theme.png b/design/screenshots/dark-theme.png
index 2b979e2..7213541 100644
Binary files a/design/screenshots/dark-theme.png and b/design/screenshots/dark-theme.png differ
diff --git a/design/screenshots/light-theme-en.png b/design/screenshots/light-theme-en.png
index 2711144..05cd52e 100644
Binary files a/design/screenshots/light-theme-en.png and b/design/screenshots/light-theme-en.png differ
diff --git a/design/screenshots/light-theme.png b/design/screenshots/light-theme.png
index 1e36542..278b7f3 100644
Binary files a/design/screenshots/light-theme.png and b/design/screenshots/light-theme.png differ
diff --git a/package.json b/package.json
index 7c7cc0c..e3e3e0c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "jordium-gantt-vue3",
- "version": "1.7.2",
+ "version": "1.8.0",
"type": "module",
"main": "npm-package/dist/jordium-gantt-vue3.cjs.js",
"module": "npm-package/dist/jordium-gantt-vue3.es.js",
diff --git a/src/components/GanttChart.vue b/src/components/GanttChart.vue
index 8f08849..181ef02 100644
--- a/src/components/GanttChart.vue
+++ b/src/components/GanttChart.vue
@@ -59,6 +59,8 @@ const props = withDefaults(defineProps(), {
enableTaskListContextMenu: true,
enableTaskBarContextMenu: true,
enableLinkAnchor: true,
+ showActualTaskbar: false,
+ enableTaskBarTooltip: true,
fullscreen: false,
expandAll: true,
locale: 'zh-CN',
@@ -201,6 +203,21 @@ interface Props {
// 是否启用 LinkAnchor 连接触点功能(默认为 true)
// 当设置为 false 时,TaskBar 上不显示前置/后置任务的连接触点
enableLinkAnchor?: boolean
+ // 是否启用 TaskBar 气泡提示框(默认为 true)
+ // 当设置为 false 时,TaskBar 上不显示悬停气泡提示框
+ enableTaskBarTooltip?: boolean
+ // 是否显示实际任务条(默认为 false)
+ // 当设置为 true 且任务存在 actualStartDate 时,会在计划任务条下方显示实际任务条
+ showActualTaskbar?: boolean
+ // 自定义任务状态背景色(优先级高于默认配色,低于Task.barColor)
+ // 待处理任务背景色:任务未开始且未逾期时使用
+ pendingTaskBackgroundColor?: string
+ // 逾期任务背景色:当前日期晚于任务结束日期时使用
+ delayTaskBackgroundColor?: string
+ // 已完成任务背景色:任务进度达到100%时使用
+ completeTaskBackgroundColor?: string
+ // 进行中任务背景色:任务已开始但未完成且未逾期时使用
+ ongoingTaskBackgroundColor?: string
// 全屏状态控制(响应式)
fullscreen?: boolean
// 展开/收起所有任务(响应式)
@@ -1201,6 +1218,8 @@ const milestonesForTimeline = computed((): Milestone[] => {
startDate: task.startDate, // 此时已确保非空
endDate: task.endDate,
assignee: task.assignee,
+ assigneeName: task.assigneeName,
+ avatar: task.avatar,
type: task.type || 'milestone',
icon: task.icon,
description: task.description,
@@ -2808,6 +2827,12 @@ defineExpose({
:working-hours="props.workingHours"
:task-bar-config="props.taskBarConfig"
:allow-drag-and-resize="props.allowDragAndResize"
+ :show-actual-taskbar="props.showActualTaskbar"
+ :enable-task-bar-tooltip="props.enableTaskBarTooltip"
+ :pending-task-background-color="props.pendingTaskBackgroundColor"
+ :delay-task-background-color="props.delayTaskBackgroundColor"
+ :complete-task-background-color="props.completeTaskBackgroundColor"
+ :ongoing-task-background-color="props.ongoingTaskBackgroundColor"
:use-default-drawer="props.useDefaultDrawer"
:use-default-milestone-dialog="props.useDefaultMilestoneDialog"
:on-milestone-save="handleMilestoneSave"
@@ -2861,6 +2886,10 @@ defineExpose({
:task="taskDrawerTask"
:is-edit="taskDrawerEditMode"
:assignee-options="props.assigneeOptions"
+ :pending-task-background-color="props.pendingTaskBackgroundColor"
+ :delay-task-background-color="props.delayTaskBackgroundColor"
+ :complete-task-background-color="props.completeTaskBackgroundColor"
+ :ongoing-task-background-color="props.ongoingTaskBackgroundColor"
@submit="handleTaskDrawerSubmit"
@close="taskDrawerVisible = false"
@start-timer="handleStartTimer"
diff --git a/src/components/GanttLinks.vue b/src/components/GanttLinks.vue
index 94266c7..56064b1 100644
--- a/src/components/GanttLinks.vue
+++ b/src/components/GanttLinks.vue
@@ -509,7 +509,7 @@ defineExpose({
top: `${offsetTop}px`,
width: `${width}px`,
height: `${height}px`,
- zIndex: highlightedTaskId !== null ? 1001 : 25,
+ zIndex: highlightedTaskId !== null ? 2000 : 500,
pointerEvents: 'none',
}"
/>
diff --git a/src/components/TaskBar.vue b/src/components/TaskBar.vue
index 412480a..f1fc999 100644
--- a/src/components/TaskBar.vue
+++ b/src/components/TaskBar.vue
@@ -34,6 +34,15 @@ interface Props {
taskBarConfig?: TaskBarConfig
// 是否允许拖拽和拉伸(默认为 true)
allowDragAndResize?: boolean
+ // 是否显示实际任务条(默认为 false)
+ showActualTaskbar?: boolean
+ // 是否启用 TaskBar 气泡提示框(默认为 true)
+ enableTaskBarTooltip?: boolean
+ // 自定义任务状态背景色
+ pendingTaskBackgroundColor?: string
+ delayTaskBackgroundColor?: string
+ completeTaskBackgroundColor?: string
+ ongoingTaskBackgroundColor?: string
// 是否被高亮显示(前置或后置任务)
isHighlighted?: boolean
// 是否是主要高亮(被长按的任务)
@@ -528,6 +537,45 @@ const shouldRenderTaskBar = computed(() => {
// 计算任务状态和颜色
const taskStatus = computed(() => {
+ // 优先级最高:如果task设置了barColor自定义颜色
+ if (props.task.barColor) {
+ // 将十六进制颜色转换为RGB
+ const hexToRgb = (hex: string) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
+ return result ? {
+ r: parseInt(result[1], 16),
+ g: parseInt(result[2], 16),
+ b: parseInt(result[3], 16)
+ } : { r: 64, g: 158, b: 255 } // 默认蓝色
+ }
+
+ const rgb = hexToRgb(props.task.barColor)
+
+ // 父级任务直接使用自定义颜色作为主色
+ if (props.isParent) {
+ return {
+ type: 'custom-parent',
+ color: props.task.barColor,
+ bgColor: props.task.barColor,
+ borderColor: props.task.barColor,
+ }
+ }
+
+ // 普通任务生成浅色背景和中间色边框
+ // 生成浅色背景(接近白色,保持色调)- 约95%白色 + 5%原色
+ const bgColor = `rgb(${Math.round(255 * 0.95 + rgb.r * 0.05)}, ${Math.round(255 * 0.95 + rgb.g * 0.05)}, ${Math.round(255 * 0.95 + rgb.b * 0.05)})`
+
+ // 生成中间色边框(约70%白色 + 30%原色)
+ const borderColor = `rgb(${Math.round(255 * 0.7 + rgb.r * 0.3)}, ${Math.round(255 * 0.7 + rgb.g * 0.3)}, ${Math.round(255 * 0.7 + rgb.b * 0.3)})`
+
+ return {
+ type: 'custom',
+ color: props.task.barColor,
+ bgColor: bgColor,
+ borderColor: borderColor,
+ }
+ }
+
// 父级任务(Story类型)使用与新建按钮一致的配色
if (props.isParent) {
return {
@@ -538,45 +586,81 @@ const taskStatus = computed(() => {
}
}
+ // 辅助函数:根据主色生成浅色背景和中间色边框
+ const generateColorsFromMain = (mainColor: string) => {
+ const hexToRgb = (hex: string) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
+ return result ? {
+ r: parseInt(result[1], 16),
+ g: parseInt(result[2], 16),
+ b: parseInt(result[3], 16)
+ } : { r: 64, g: 158, b: 255 }
+ }
+ const rgb = hexToRgb(mainColor)
+ const bgColor = `rgb(${Math.round(255 * 0.95 + rgb.r * 0.05)}, ${Math.round(255 * 0.95 + rgb.g * 0.05)}, ${Math.round(255 * 0.95 + rgb.b * 0.05)})`
+ const borderColor = `rgb(${Math.round(255 * 0.7 + rgb.r * 0.3)}, ${Math.round(255 * 0.7 + rgb.g * 0.3)}, ${Math.round(255 * 0.7 + rgb.b * 0.3)})`
+ return { bgColor, borderColor }
+ }
+
// 使用缓存的今天日期,避免频繁创建日期对象
const today = cachedToday.value
const endDate = parsedEndDate.value
const progress = props.task.progress || 0
+ const actualStartDate = props.task.actualStartDate ? new Date(props.task.actualStartDate) : null
- // 已完成
+ // 已完成(优先级高)
if (progress >= 100) {
+ // 优先使用自定义颜色,否则使用默认
+ const mainColor = props.completeTaskBackgroundColor || '#909399'
+ const colors = generateColorsFromMain(mainColor)
return {
type: 'completed',
- color: '#909399', // info color
- bgColor: '#f4f4f5',
- borderColor: '#d3d4d6',
+ color: mainColor,
+ bgColor: colors.bgColor,
+ borderColor: colors.borderColor,
}
}
// 已延迟(结束日期早于今天且未完成)
if (endDate && endDate < today && progress < 100) {
+ const mainColor = props.delayTaskBackgroundColor || '#f56c6c'
+ const colors = generateColorsFromMain(mainColor)
return {
type: 'delayed',
- color: '#f56c6c', // danger color
- bgColor: '#fef0f0',
- borderColor: '#fbc4c4',
+ color: mainColor,
+ bgColor: colors.bgColor,
+ borderColor: colors.borderColor,
}
}
- // 进行中(结束日期晚于今天且进度>0)
- if (endDate && endDate >= today && progress > 0) {
+ // 进行中(actualStartDate存在,未完成,且未逾期)
+ if (actualStartDate && progress < 100 && endDate && endDate >= today) {
+ const mainColor = props.ongoingTaskBackgroundColor || '#e6a23c'
+ const colors = generateColorsFromMain(mainColor)
return {
- type: 'in-progress',
- color: '#e6a23c', // warning color
- bgColor: '#fdf6ec',
- borderColor: '#f5dab1',
+ type: 'ongoing',
+ color: mainColor,
+ bgColor: colors.bgColor,
+ borderColor: colors.borderColor,
}
}
- // 未开始(进度为0且未延迟)
+ // 待处理(未开始且未逾期)- actualStartDate不存在,当前日期早于计划结束日
+ if (!actualStartDate && (!endDate || endDate >= today)) {
+ const mainColor = props.pendingTaskBackgroundColor || '#409eff'
+ const colors = generateColorsFromMain(mainColor)
+ return {
+ type: 'pending',
+ color: mainColor,
+ bgColor: colors.bgColor,
+ borderColor: colors.borderColor,
+ }
+ }
+
+ // 默认状态(兜底)
return {
- type: 'not-started',
- color: '#409eff', // primary color
+ type: 'default',
+ color: '#409eff',
bgColor: '#ecf5ff',
borderColor: '#b3d8ff',
}
@@ -595,6 +679,26 @@ const slotPayload = computed(() => ({
dayWidth: props.dayWidth,
}))
+// 处理avatar数组和assignee生成头像
+const avatarList = computed(() => {
+ const avatar = props.task.avatar
+ const assignee = props.task.assignee
+
+ // 如果有avatar,使用avatar
+ if (avatar) {
+ return Array.isArray(avatar) ? avatar : [avatar]
+ }
+
+ // 如果没有avatar但有assignee,使用assignee生成头像数据
+ if (assignee) {
+ const assigneeList = Array.isArray(assignee) ? assignee : [assignee]
+ // 返回对象数组,包含名字信息用于生成文字头像
+ return assigneeList.map(name => ({ isText: true, name }))
+ }
+
+ return []
+})
+
// 判断是否已完成
const isCompleted = computed(() => (props.task.progress || 0) >= 100)
@@ -619,6 +723,128 @@ const isShortTaskBar = computed(() => {
return width < 80
})
+// 判断是否有实际进度数据
+const hasActualProgress = computed(() => {
+ return !!(props.task.actualStartDate || props.task.actualEndDate)
+})
+
+// 计算实际进度条的样式(独立的TaskBar,在下层)
+const actualBarStyle = computed(() => {
+ // 只有当showActualTaskbar=true且存在actualStartDate时才显示实际任务条
+ if (!props.showActualTaskbar || !props.task.actualStartDate || props.isParent) {
+ return null
+ }
+
+ const actualStart = createLocalDate(props.task.actualStartDate)
+ const actualEnd = createLocalDate(props.task.actualEndDate)
+ const planStart = createLocalDate(props.task.startDate)
+ const baseStartOnly = parsedBaseStartDate.value
+
+ if (!baseStartOnly) {
+ return null
+ }
+
+ // 实际开始日期,如果没有则使用计划开始日期
+ const effectiveStart = actualStart || planStart
+ // 实际结束日期,如果没有则使用当前日期(任务进行中)
+ const effectiveEnd = actualEnd || (actualStart ? createLocalToday() : null)
+
+ if (!effectiveStart || !effectiveEnd) {
+ return null
+ }
+
+ // 计算实际进度条的绝对位置(与计划条使用相同逻辑)
+ let actualLeft = 0
+ let actualWidth = 100
+
+ // 根据时间刻度计算位置(与taskBarStyle逻辑一致)
+ if (
+ props.timelineData &&
+ props.currentTimeScale &&
+ (props.currentTimeScale === TimelineScale.WEEK ||
+ props.currentTimeScale === TimelineScale.MONTH ||
+ props.currentTimeScale === TimelineScale.QUARTER ||
+ props.currentTimeScale === TimelineScale.YEAR)
+ ) {
+ const startPosition = calculatePositionFromTimelineData(
+ effectiveStart,
+ props.timelineData,
+ props.currentTimeScale,
+ )
+ const nextDay = new Date(effectiveEnd)
+ nextDay.setDate(nextDay.getDate() + 1)
+ let endPosition = calculatePositionFromTimelineData(
+ nextDay,
+ props.timelineData,
+ props.currentTimeScale,
+ )
+
+ if (endPosition === startPosition) {
+ let dayWidth = 60 / 30
+ if (props.currentTimeScale === TimelineScale.WEEK) {
+ dayWidth = 60 / 7
+ } else if (props.currentTimeScale === TimelineScale.QUARTER) {
+ dayWidth = 60 / 90
+ } else if (props.currentTimeScale === TimelineScale.YEAR) {
+ dayWidth = 180 / 182
+ }
+ endPosition = calculatePositionFromTimelineData(
+ effectiveEnd,
+ props.timelineData,
+ props.currentTimeScale,
+ ) + dayWidth
+ }
+
+ actualLeft = startPosition
+ actualWidth = Math.max(endPosition - startPosition, 4)
+ } else if (props.timelineData && props.currentTimeScale === TimelineScale.DAY) {
+ const startPosition = calculatePositionFromTimelineData(
+ effectiveStart,
+ props.timelineData,
+ props.currentTimeScale,
+ )
+ const nextDay = new Date(effectiveEnd)
+ nextDay.setDate(nextDay.getDate() + 1)
+ let endPosition = calculatePositionFromTimelineData(
+ nextDay,
+ props.timelineData,
+ props.currentTimeScale,
+ )
+
+ if (endPosition === startPosition) {
+ endPosition = calculatePositionFromTimelineData(
+ effectiveEnd,
+ props.timelineData,
+ props.currentTimeScale,
+ ) + 30
+ }
+
+ actualLeft = startPosition
+ actualWidth = Math.max(endPosition - startPosition, 4)
+ } else {
+ const startDiff = Math.floor(
+ (effectiveStart.getTime() - baseStartOnly.getTime()) / (1000 * 60 * 60 * 24),
+ )
+ const timeDiffMs = effectiveEnd.getTime() - effectiveStart.getTime()
+ const daysDiff = Math.round(timeDiffMs / (1000 * 60 * 60 * 24))
+ const duration = daysDiff === 0 ? 1 : daysDiff + 1
+
+ actualLeft = startDiff * props.dayWidth
+ actualWidth = duration * props.dayWidth
+ }
+
+ // 实际进度条固定高度20px,垂直居中显示
+ const actualHeight = 20
+ const topOffset = (props.rowHeight - actualHeight) / 2 // 上下居中对齐
+
+ return {
+ left: `${actualLeft}px`,
+ width: `${actualWidth}px`,
+ height: `${actualHeight}px`,
+ top: `${topOffset}px`,
+ }
+})
+
// 判断是否需要溢出效果(周视图且短TaskBar)
const needsOverflowEffect = computed(() => isWeekView.value && isShortTaskBar.value)
@@ -1524,13 +1750,18 @@ const stickyStyles = computed(() => {
// 估算文字内容的实际位置
const nameWidth = Math.max(nameTextWidth.value, 40) // 最小40px
const progressWidth = 35
- const avatarWidth = 22 // avatar 宽度
+ const singleAvatarWidth = 22 // 单个avatar 宽度
+ // 计算实际avatar总宽度(多个头像时会重叠,每个头像露出18px)
+ const actualAvatarCount = avatarList.value.length
+ const avatarTotalWidth = actualAvatarCount > 0
+ ? (actualAvatarCount === 1 ? singleAvatarWidth : singleAvatarWidth + (actualAvatarCount - 1) * 18)
+ : 0
const handleWidth = actualHandleWidth.value // 拉伸手柄宽度
// === 第一步:检测 Avatar 是否需要粘性定位 ===
const avatarDefaultLeft = handleWidth + 3 // 手柄宽度 + 3px 间距
const avatarLeftPos = taskLeft + avatarDefaultLeft
- const avatarRightPos = taskLeft + avatarDefaultLeft + avatarWidth
+ const avatarRightPos = taskLeft + avatarDefaultLeft + avatarTotalWidth
// Avatar 左侧粘性逻辑
const avatarNeedsLeftSticky =
@@ -1548,17 +1779,17 @@ const stickyStyles = computed(() => {
const offset = leftBoundary - taskLeft
avatarLeft = `${offset + handleWidth + 3}px` // 手柄宽度 + 3px 间距
avatarPosition = 'absolute'
- avatarStickyOffset = avatarWidth + 8 // avatar 宽度 + 右侧间距
+ avatarStickyOffset = avatarTotalWidth + 8 // avatar 总宽度 + 右侧间距
} else if (avatarNeedsRightSticky) {
// avatar 应该停靠在 name/progress 左侧 15px 的位置
// 计算 name/progress 在右边界时的位置(它们会贴在右边框上)
const maxContentWidth = Math.max(nameWidth, progressWidth)
// 内容贴右边框时的左侧位置,考虑手柄宽度
const contentRightPos = rightBoundary - taskLeft - maxContentWidth - handleWidth - 3
- const offset = contentRightPos - avatarWidth - 15 // avatar 在内容左侧 15px
+ const offset = contentRightPos - avatarTotalWidth - 15 // avatar 在内容左侧 15px
avatarLeft = `${offset}px`
avatarPosition = 'absolute'
- avatarStickyOffset = -(avatarWidth + 15) // 负值表示在右侧,avatar宽度 + 15px间距
+ avatarStickyOffset = -(avatarTotalWidth + 15) // 负值表示在右侧,avatar总宽度 + 15px间距
}
// === 第二步:处理名称粘性定位(考虑 avatar 偏移) ===
@@ -1574,7 +1805,7 @@ const stickyStyles = computed(() => {
// 如果 avatar 存在:当 name 左侧接近左边界(预留 avatar+间距 的空间)时触发
// name 默认居中,当它向左移动到需要为 avatar 留出空间时触发磁吸
const nameActualLeft = nameLeftPos // name 实际左侧位置
- const avatarReservedSpace = avatarWidth + 15 // avatar 需要的空间
+ const avatarReservedSpace = avatarTotalWidth + 15 // avatar 需要的空间(实际总宽度 + 间距)
nameNeedsLeftSticky =
nameActualLeft < leftBoundary + avatarReservedSpace && taskRight > leftBoundary
} else {
@@ -1589,6 +1820,7 @@ const stickyStyles = computed(() => {
if (nameNeedsLeftSticky) {
const offset = leftBoundary - taskLeft
// 如果 avatar 也在左侧粘性,则 title 需要在 avatar 右侧
+ // 使用avatarStickyOffset,已经包含avatar总宽度 + 8px间距
const extraOffset = avatarNeedsLeftSticky ? avatarStickyOffset : 0
nameLeft = `${offset + handleWidth + 3 + extraOffset}px` // 考虑手柄宽度 + 间距
namePosition = 'absolute'
@@ -1611,7 +1843,7 @@ const stickyStyles = computed(() => {
// 如果 avatar 存在:当 progress 左侧接近左边界(预留 avatar+间距 的空间)时触发
// progress 默认居中,当它向左移动到需要为 avatar 留出空间时触发磁吸
const progressActualLeft = progressLeftPos // progress 实际左侧位置
- const avatarReservedSpace = avatarWidth + 15 // avatar 需要的空间
+ const avatarReservedSpace = avatarTotalWidth + 15 // avatar 需要的空间(实际总宽度 + 间距)
progressNeedsLeftSticky =
progressActualLeft < leftBoundary + avatarReservedSpace && taskRight > leftBoundary
} else {
@@ -1626,6 +1858,7 @@ const stickyStyles = computed(() => {
if (progressNeedsLeftSticky) {
const offset = leftBoundary - taskLeft
// 如果 avatar 也在左侧粘性,则进度需要在 avatar 右侧
+ // 使用avatarStickyOffset,已经包含avatar总宽度 + 8px间距
const extraOffset = avatarNeedsLeftSticky ? avatarStickyOffset : 0
progressLeft = `${offset + handleWidth + 3 + extraOffset}px` // 考虑手柄宽度 + 间距
progressPosition = 'absolute'
@@ -1723,6 +1956,11 @@ const bubbleIndicator = computed(() => {
const showTooltip = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 })
+// TaskBar 悬停 tooltip 状态
+const showHoverTooltip = ref(false)
+const hoverTooltipPosition = ref({ x: 0, y: 0 })
+let hoverTooltipTimer: number | null = null
+
// 跟踪滚动状态,避免非滚动时的动画
const isScrollingContext = ref(false)
const scrollTimeout = ref(null)
@@ -1915,6 +2153,38 @@ const handleBubbleMouseDown = (event: MouseEvent) => {
showTooltip.value = false
}
+// 处理TaskBar悬停事件
+const handleTaskBarMouseEnter = (event: MouseEvent) => {
+ isTaskBarHovered.value = true
+
+ // 如果启用了TaskBar Tooltip(父级任务也显示tooltip)
+ if (props.enableTaskBarTooltip !== false) {
+ // 保存event.currentTarget的引用,因为在setTimeout回调中它会变成null
+ const targetElement = event.currentTarget as HTMLElement
+
+ // 延迟显示tooltip,避免快速滑过时显示
+ hoverTooltipTimer = window.setTimeout(() => {
+ showHoverTooltip.value = true
+ const rect = targetElement.getBoundingClientRect()
+ hoverTooltipPosition.value = {
+ x: rect.left + rect.width / 2,
+ y: rect.top - 10,
+ }
+ }, 300) // 300ms延迟
+ }
+}
+
+const handleTaskBarMouseLeave = () => {
+ isTaskBarHovered.value = false
+
+ // 清除定时器并隐藏tooltip
+ if (hoverTooltipTimer) {
+ clearTimeout(hoverTooltipTimer)
+ hoverTooltipTimer = null
+ }
+ showHoverTooltip.value = false
+}
+
// 格式化日期显示
const formatDisplayDate = (dateStr: string | undefined): string => {
if (!dateStr) return t('dateNotSet')
@@ -2404,6 +2674,71 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
+
+
+
+ {{ task.progress || 0 }}%
+
+
+
+
+
+
+
+
+ {{ avatarItem.name.charAt(0).toUpperCase() }}
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
-
-
+
+
{{ task.name }} ({{ task.progress || 0 }}%)
@@ -2495,36 +2835,47 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
}
"
>
-
+
-
-
![]()
-
-
- {{ task.assigneeName.charAt(0).toUpperCase() }}
-
-
-
+
+
+
+ {{ avatarItem.name.charAt(0).toUpperCase() }}
+
+
+
![]()
+
+
+
-
-
+
+
-
+
@@ -2692,6 +3043,40 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
+
+
+
+
+