v1.9.0 - 事件交叉Taskbar展示优化

This commit is contained in:
LINING-PC\lining
2026-01-30 16:53:59 +08:00
parent de6ce44000
commit 867c24186a
14 changed files with 1726 additions and 168 deletions

View File

@@ -86,7 +86,7 @@ const applyDataSource = (source: RawDataSource) => {
tasks.value = cloneData(payload.tasks ?? [])
milestones.value = cloneData(payload.milestones ?? [])
// 从独立的资源数据文件加载资源数据
// v1.9.0 从资源数据文件加载
const resourcePayload = resourcesData as { resources?: any[] }
if (resourcePayload.resources) {
resources.value = resourcePayload.resources.map(resData => {
@@ -100,9 +100,18 @@ const applyDataSource = (source: RawDataSource) => {
department: resData.department,
skills: resData.skills,
utilization: resData.utilization,
color: resData.color,
tasks: resData.tasks || []
})
})
// 更新assigneeOptions添加资源作为责任人选项
const resourceAssigneeOptions = resources.value.map(res => ({
value: res.id as string,
label: res.name,
avatar: res.avatar
}))
assigneeOptions.value = [...resourceAssigneeOptions]
} else {
// 向后兼容:如果没有独立的资源数据,从任务中生成
const resourceMap = new Map<string, Resource>()
@@ -278,23 +287,9 @@ const resourceListConfig = computed<ResourceListConfig>(() => ({
key: 'name',
label: '资源名称',
visible: true,
width: 150,
width: 200,
formatter: (resource: Resource) => resource.name || '-',
},
{
key: 'type',
label: '类型',
visible: true,
width: 100,
formatter: (resource: Resource) => resource.type || '-',
},
{
key: 'taskCount',
label: '任务数',
visible: true,
width: 100,
formatter: (resource: Resource) => resource.tasks?.length?.toString() || '0',
},
{
key: 'utilization',
label: '利用率',
@@ -310,6 +305,20 @@ const resourceListConfig = computed<ResourceListConfig>(() => ({
return `${utilizationPercent}%`
},
},
{
key: 'type',
label: '类型',
visible: true,
width: 100,
formatter: (resource: Resource) => resource.type || '-',
},
{
key: 'taskCount',
label: '任务数',
visible: true,
width: 100,
formatter: (resource: Resource) => resource.tasks?.length?.toString() || '0',
},
],
defaultWidth:
widthUnit.value === '%'
@@ -2145,7 +2154,7 @@ const handleCustomMenuAction = (action: string, task: Task) => {
<template #header-name>
<div style="display: flex; align-items: center; gap: 6px;">
<img src="https://foruda.gitee.com/avatar/1764902889653058860/565633_nelson820125_1764902889.png!avatar200" width="32" height="32" style="border-radius: 50%;" />
<strong style="font-size: 14px;">{{ t.taskName }}</strong>
<strong style="font-size: 14px;">{{ viewMode === 'task' ? t.taskName : t.resourceName }}</strong>
</div>
</template>
<template #column-name="{ task, column, value }">
@@ -2203,10 +2212,10 @@ const handleCustomMenuAction = (action: string, task: Task) => {
</template>
<!-- 使用 TaskListColumn 组件自定义列 声明式模式 -->
<TaskListColumn prop="name" :label="t.taskName" width="300" align="center">
<TaskListColumn prop="name" :label="viewMode === 'task' ? t.taskName : t.resourceName" width="300" align="center">
<template #header>
<img src="https://foruda.gitee.com/avatar/1764902889653058860/565633_nelson820125_1764902889.png!avatar200" width="32" height="32" style="border-radius: 50%;" />
<strong style="font-size: 14px;">{{ t.taskName }}</strong>
<strong style="font-size: 14px;">{{ viewMode === 'task' ? t.taskName : t.resourceName }}</strong>
</template>
<template #default="scope">
<div style="display: flex; align-items: center; gap: 6px;">

View File

@@ -0,0 +1,173 @@
{
"resources": [
{
"id": "resource-zhang",
"name": "张三 (前端开发)",
"type": "开发工程师",
"avatar": "https://i.pravatar.cc/150?img=11",
"department": "技术部",
"skills": ["Vue3", "TypeScript", "前端架构"],
"utilization": 125,
"color": "#2196f3",
"tasks": [
{
"id": 1001,
"name": "用户管理模块开发",
"startDate": "2026-01-20",
"endDate": "2026-01-30",
"progress": 60,
"type": "task",
"resources": [
{ "id": "resource-zhang", "name": "张三", "percent": 50 }
]
},
{
"id": 1002,
"name": "权限系统重构",
"startDate": "2026-01-22",
"endDate": "2026-02-05",
"progress": 30,
"type": "task",
"resources": [
{ "id": "resource-zhang", "name": "张三", "percent": 75 }
]
},
{
"id": 1003,
"name": "性能优化任务",
"startDate": "2026-02-08",
"endDate": "2026-02-15",
"progress": 0,
"type": "task",
"resources": [
{ "id": "resource-zhang", "name": "张三", "percent": 100 }
]
}
]
},
{
"id": "resource-li",
"name": "李四 (后端开发)",
"type": "开发工程师",
"avatar": "https://i.pravatar.cc/150?img=12",
"department": "技术部",
"skills": ["Java", "Spring Boot", "微服务"],
"utilization": 80,
"color": "#4caf50",
"tasks": [
{
"id": 2001,
"name": "API接口开发",
"startDate": "2026-01-15",
"endDate": "2026-01-28",
"progress": 80,
"type": "task",
"resources": [
{ "id": "resource-li", "name": "李四", "percent": 100 }
]
},
{
"id": 2002,
"name": "数据库优化",
"startDate": "2026-02-01",
"endDate": "2026-02-10",
"progress": 20,
"type": "task",
"resources": [
{ "id": "resource-li", "name": "李四", "percent": 50 }
]
}
]
},
{
"id": "resource-wang",
"name": "王五 (测试工程师)",
"type": "测试工程师",
"avatar": "https://i.pravatar.cc/150?img=13",
"department": "质量部",
"skills": ["自动化测试", "性能测试", "测试用例设计"],
"utilization": 60,
"color": "#ff9800",
"tasks": [
{
"id": 3001,
"name": "用户模块测试",
"startDate": "2026-01-25",
"endDate": "2026-02-03",
"progress": 40,
"type": "task",
"resources": [
{ "id": "resource-wang", "name": "王五", "percent": 75 }
]
},
{
"id": 3002,
"name": "集成测试",
"startDate": "2026-02-05",
"endDate": "2026-02-12",
"progress": 10,
"type": "task",
"resources": [
{ "id": "resource-wang", "name": "王五", "percent": 25 }
]
},
{
"id": 3003,
"name": "回归测试",
"startDate": "2026-02-14",
"endDate": "2026-02-20",
"progress": 0,
"type": "task",
"resources": [
{ "id": "resource-wang", "name": "王五", "percent": 50 }
]
}
]
},
{
"id": "resource-zhao",
"name": "赵六 (项目经理) [超负荷]",
"type": "项目管理",
"avatar": "https://i.pravatar.cc/150?img=14",
"department": "项目管理部",
"skills": ["项目管理", "敏捷开发", "团队协作"],
"utilization": 150,
"color": "#f56c6c",
"tasks": [
{
"id": 4001,
"name": "项目A管理",
"startDate": "2026-01-18",
"endDate": "2026-02-10",
"progress": 50,
"type": "task",
"resources": [
{ "id": "resource-zhao", "name": "赵六", "percent": 60 }
]
},
{
"id": 4002,
"name": "项目B管理",
"startDate": "2026-01-20",
"endDate": "2026-02-08",
"progress": 45,
"type": "task",
"resources": [
{ "id": "resource-zhao", "name": "赵六", "percent": 50 }
]
},
{
"id": 4003,
"name": "周报汇总",
"startDate": "2026-01-24",
"endDate": "2026-02-05",
"progress": 30,
"type": "task",
"resources": [
{ "id": "resource-zhao", "name": "赵六", "percent": 20 }
]
}
]
}
]
}

View File

@@ -8,6 +8,7 @@
"department": "临床研究部",
"skills": ["临床试验管理", "项目规划", "团队领导"],
"utilization": 85,
"color": "#2196f3",
"tasks": [
{
"id": 1000,
@@ -15,7 +16,10 @@
"startDate": "2025-01-01",
"endDate": "2027-12-31",
"progress": 15,
"type": "story"
"type": "story",
"resources": [
{ "id": "resource-chen", "name": "首席研究员 Dr. Chen", "percent": 100 }
]
},
{
"id": 5001,
@@ -23,7 +27,10 @@
"startDate": "2025-03-01",
"endDate": "2025-03-15",
"progress": 100,
"type": "task"
"type": "task",
"resources": [
{ "id": "resource-chen", "name": "首席研究员 Dr. Chen", "percent": 75 }
]
},
{
"id": 5002,
@@ -31,7 +38,10 @@
"startDate": "2025-06-10",
"endDate": "2025-06-12",
"progress": 80,
"type": "task"
"type": "task",
"resources": [
{ "id": "resource-chen", "name": "首席研究员 Dr. Chen", "percent": 50 }
]
}
]
},
@@ -43,6 +53,7 @@
"department": "I期临床研究组",
"skills": ["I期试验设计", "药代动力学", "安全性评估"],
"utilization": 92,
"color": "#4caf50",
"tasks": [
{
"id": 1100,
@@ -50,7 +61,10 @@
"startDate": "2025-01-01",
"endDate": "2025-08-31",
"progress": 65,
"type": "story"
"type": "story",
"resources": [
{ "id": "resource-wang", "name": "I期试验主任 Dr. Wang", "percent": 100 }
]
},
{
"id": 5003,
@@ -58,7 +72,10 @@
"startDate": "2025-03-01",
"endDate": "2025-04-30",
"progress": 100,
"type": "task"
"type": "task",
"resources": [
{ "id": "resource-wang", "name": "I期试验主任 Dr. Wang", "percent": 75 }
]
},
{
"id": 5004,
@@ -66,7 +83,10 @@
"startDate": "2025-05-01",
"endDate": "2025-07-31",
"progress": 45,
"type": "task"
"type": "task",
"resources": [
{ "id": "resource-wang", "name": "I期试验主任 Dr. Wang", "percent": 50 }
]
}
]
},
@@ -85,7 +105,10 @@
"startDate": "2025-01-01",
"endDate": "2025-02-28",
"progress": 100,
"type": "task"
"type": "task",
"resources": [
{ "id": "resource-li", "name": "方案设计师 李明", "percent": 100 }
]
},
{
"id": 5005,
@@ -93,7 +116,11 @@
"startDate": "2025-07-01",
"endDate": "2025-08-15",
"progress": 30,
"type": "task"
"type": "task",
"barColor": "#7B3FF2",
"resources": [
{ "id": "resource-li", "name": "方案设计师 李明", "percent": 75 }
]
}
]
},
@@ -105,6 +132,7 @@
"department": "数据管理与统计部",
"skills": ["统计分析", "数据可视化", "报告撰写"],
"utilization": 68,
"color": "#ff9800",
"tasks": [
{
"id": 1103,
@@ -112,7 +140,10 @@
"startDate": "2025-05-01",
"endDate": "2025-08-15",
"progress": 50,
"type": "task"
"type": "task",
"resources": [
{ "id": "resource-zhang", "name": "数据分析师 张伟", "percent": 100 }
]
},
{
"id": 5006,
@@ -120,7 +151,10 @@
"startDate": "2025-06-15",
"endDate": "2025-07-01",
"progress": 70,
"type": "task"
"type": "task",
"resources": [
{ "id": "resource-zhang", "name": "数据分析师 张伟", "percent": 50 }
]
},
{
"id": 5007,
@@ -128,7 +162,10 @@
"startDate": "2025-07-15",
"endDate": "2025-08-10",
"progress": 25,
"type": "task"
"type": "task",
"resources": [
{ "id": "resource-zhang", "name": "数据分析师 张伟", "percent": 75 }
]
}
]
},
@@ -229,6 +266,7 @@
"department": "III期临床研究组",
"skills": ["大型试验管理", "多中心协调", "里程碑管理"],
"utilization": 35,
"color": "#9c27b0",
"tasks": [
{
"id": 1300,
@@ -239,6 +277,225 @@
"type": "story"
}
]
},
{
"id": "resource-sun",
"name": "生物统计师 孙丽",
"type": "生物统计师",
"avatar": "https://i.pravatar.cc/150?img=9",
"department": "数据管理与统计部",
"skills": ["统计建模", "样本量计算", "数据分析"],
"utilization": 95,
"color": "#ff5733",
"tasks": [
{
"id": 6001,
"name": "I期统计分析计划",
"startDate": "2025-04-01",
"endDate": "2025-04-30",
"progress": 100,
"type": "task",
"resources": [
{ "id": "resource-sun", "name": "生物统计师 孙丽", "percent": 100 }
]
},
{
"id": 6002,
"name": "中期数据分析",
"startDate": "2025-05-15",
"endDate": "2025-06-15",
"progress": 75,
"type": "task",
"resources": [
{ "id": "resource-sun", "name": "生物统计师 孙丽", "percent": 75 }
]
},
{
"id": 6003,
"name": "II期样本量计算",
"startDate": "2025-05-20",
"endDate": "2025-06-10",
"progress": 85,
"type": "task",
"resources": [
{ "id": "resource-sun", "name": "生物统计师 孙丽", "percent": 50 }
]
},
{
"id": 6004,
"name": "最终报告统计",
"startDate": "2025-08-01",
"endDate": "2025-08-25",
"progress": 30,
"type": "task",
"resources": [
{ "id": "resource-sun", "name": "生物统计师 孙丽", "percent": 100 }
]
}
]
},
{
"id": "resource-zhou",
"name": "临床监查员 周杰",
"type": "临床监查员",
"avatar": "https://i.pravatar.cc/150?img=10",
"department": "临床监查部",
"skills": ["现场监查", "数据核查", "问题解决"],
"utilization": 110,
"color": "#3498db",
"tasks": [
{
"id": 6005,
"name": "中心A启动访视",
"startDate": "2025-03-10",
"endDate": "2025-03-15",
"progress": 100,
"type": "task",
"resources": [
{ "id": "resource-zhou", "name": "临床监查员 周杰", "percent": 100 }
]
},
{
"id": 6006,
"name": "中心B监查访视",
"startDate": "2025-03-12",
"endDate": "2025-03-18",
"progress": 100,
"type": "task",
"resources": [
{ "id": "resource-zhou", "name": "临床监查员 周杰", "percent": 75 }
]
},
{
"id": 6007,
"name": "中心C数据核查",
"startDate": "2025-04-01",
"endDate": "2025-04-10",
"progress": 90,
"type": "task",
"resources": [
{ "id": "resource-zhou", "name": "临床监查员 周杰", "percent": 50 }
]
},
{
"id": 6008,
"name": "中心D关闭访视",
"startDate": "2025-04-05",
"endDate": "2025-04-12",
"progress": 70,
"type": "task",
"resources": [
{ "id": "resource-zhou", "name": "临床监查员 周杰", "percent": 25 }
]
}
]
},
{
"id": "resource-xu",
"name": "医学撰写员 徐静",
"type": "医学撰写员",
"avatar": "https://i.pravatar.cc/150?img=11",
"department": "医学事务部",
"skills": ["研究报告撰写", "文献检索", "医学翻译"],
"utilization": 72,
"color": "#e74c3c",
"tasks": [
{
"id": 6009,
"name": "研究方案撰写",
"startDate": "2025-01-15",
"endDate": "2025-02-15",
"progress": 100,
"type": "task"
},
{
"id": 6010,
"name": "中期报告撰写",
"startDate": "2025-06-01",
"endDate": "2025-07-01",
"progress": 60,
"type": "task"
},
{
"id": 6011,
"name": "研究者手册更新",
"startDate": "2025-07-15",
"endDate": "2025-08-15",
"progress": 20,
"type": "task"
}
]
},
{
"id": "resource-ma",
"name": "药物警戒专员 马云飞",
"type": "药物警戒",
"avatar": "https://i.pravatar.cc/150?img=12",
"department": "药物警戒部",
"skills": ["不良事件管理", "安全性报告", "信号检测"],
"utilization": 88,
"color": "#f39c12",
"tasks": [
{
"id": 6012,
"name": "SAE快速报告",
"startDate": "2025-03-01",
"endDate": "2025-03-31",
"progress": 100,
"type": "task"
},
{
"id": 6013,
"name": "安全性季度报告",
"startDate": "2025-03-15",
"endDate": "2025-04-01",
"progress": 100,
"type": "task"
},
{
"id": 6014,
"name": "DSUR年度报告",
"startDate": "2025-06-01",
"endDate": "2025-07-15",
"progress": 45,
"type": "task"
}
]
},
{
"id": "resource-gao",
"name": "注册事务专员 高敏",
"type": "注册事务",
"avatar": "https://i.pravatar.cc/150?img=13",
"department": "注册事务部",
"skills": ["IND申报", "监管沟通", "文件管理"],
"utilization": 65,
"tasks": [
{
"id": 6015,
"name": "IND申报准备",
"startDate": "2024-12-01",
"endDate": "2025-01-15",
"progress": 100,
"type": "task"
},
{
"id": 6016,
"name": "与NMPA沟通会议",
"startDate": "2025-02-10",
"endDate": "2025-02-12",
"progress": 100,
"type": "task"
},
{
"id": 6017,
"name": "II期IND补充申请",
"startDate": "2025-08-01",
"endDate": "2025-08-31",
"progress": 15,
"type": "task"
}
]
}
]
}

View File

@@ -9,6 +9,7 @@ import MilestoneDialog from './MilestoneDialog.vue'
import { useI18n, setCustomMessages } from '../composables/useI18n'
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
import { moveTask } from '../utils/taskTreeUtils'
import { assignTaskRows } from '../utils/taskLayoutUtils'
import jsPDF from 'jspdf'
import html2canvas from 'html2canvas'
import type { Task } from '../models/classes/Task'
@@ -129,6 +130,103 @@ provide('gantt-view-mode', currentViewMode)
provide('gantt-data-source', currentDataSource)
provide('gantt-list-config', currentListConfig)
// 计算资源视图下的任务布局信息
const resourceTaskLayouts = computed(() => {
const layouts = new Map<string, { taskRowMap: Map<string | number, number>, rowHeights: number[], totalHeight: number }>()
if (currentViewMode.value === 'resource') {
const resources = currentDataSource.value as Resource[]
const baseRowHeight = 51
// 依赖 updateTaskTrigger 以便在任务更新时重新计算布局
if (updateTaskTrigger.value >= 0) {
resources.forEach(resource => {
const resourceId = String(resource.id)
if (resource.tasks && resource.tasks.length > 0) {
const layout = assignTaskRows(resource.tasks, resourceId, baseRowHeight)
layouts.set(resourceId, layout)
} else {
// 没有任务的资源使用默认高度
layouts.set(resourceId, {
taskRowMap: new Map(),
rowHeights: [baseRowHeight],
totalHeight: baseRowHeight
})
}
})
}
}
return layouts
})
// 计算资源行的累积位置
const resourceRowPositions = computed(() => {
const positions = new Map<string, number>()
if (currentViewMode.value === 'resource') {
const resources = currentDataSource.value as Resource[]
let cumulativeTop = 0
resources.forEach(resource => {
const resourceId = String(resource.id)
positions.set(resourceId, cumulativeTop)
const layout = resourceTaskLayouts.value.get(resourceId)
const resourceHeight = layout?.totalHeight || 51
cumulativeTop += resourceHeight
})
}
return positions
})
// 计算资源冲突状态依赖updateTaskTrigger以便实时更新
const resourceConflicts = computed(() => {
if (currentViewMode.value !== 'resource') return new Map()
const resources = currentDataSource.value as Resource[]
const conflictsMap = new Map<string, Set<number>>()
// 依赖 updateTaskTrigger 以便在任务更新时重新计算冲突
if (updateTaskTrigger.value >= 0) {
resources.forEach(resource => {
const conflicts = new Set<number>()
const tasks = resource.tasks || []
const validTasks = tasks.filter(task => task.startDate && task.endDate)
// 两两比较检测冲突
for (let i = 0; i < validTasks.length; i++) {
for (let j = i + 1; j < validTasks.length; j++) {
const task1 = validTasks[i]
const task2 = validTasks[j]
// 检查时间重叠
const start1 = new Date(task1.startDate!).getTime()
const end1 = new Date(task1.endDate!).getTime()
const start2 = new Date(task2.startDate!).getTime()
const end2 = new Date(task2.endDate!).getTime()
if (start1 < end2 && start2 < end1) {
conflicts.add(task1.id)
conflicts.add(task2.id)
}
}
}
if (conflicts.size > 0) {
conflictsMap.set(String(resource.id), conflicts)
}
})
}
return conflictsMap
})
// 提供资源布局信息给子组件
provide('resourceTaskLayouts', resourceTaskLayouts)
provide('resourceRowPositions', resourceRowPositions)
provide('resourceConflicts', resourceConflicts)
// 提供 slots 给子组件TaskList 和 TaskRow
provide('gantt-column-slots', slots)
@@ -641,19 +739,15 @@ const handleToggleTaskList = (event: CustomEvent) => {
// --- 事件链路:监听 Timeline 传递上来的拖拽/拉伸事件,更新数据并通过 emit 暴露 ---
function handleTaskBarDragEnd(event: CustomEvent) {
const updatedTask = event.detail
// 更新 props.tasks 中的任务数据
if (props.tasks) {
updateTaskInTree(props.tasks, updatedTask)
}
// 更新任务数据并同步到资源视图
updateTaskAndSyncToResources(updatedTask)
updateTaskTrigger.value++
emit('taskbar-drag-end', updatedTask)
}
function handleTaskBarResizeEnd(event: CustomEvent) {
const updatedTask = event.detail
// 更新 props.tasks 中的任务数据
if (props.tasks) {
updateTaskInTree(props.tasks, updatedTask)
}
// 更新任务数据并同步到资源视图
updateTaskAndSyncToResources(updatedTask)
updateTaskTrigger.value++
emit('taskbar-resize-end', updatedTask)
}
@@ -2423,59 +2517,39 @@ function handleToolbarAddTask() {
// 监听TaskDrawer、TaskList、Timeline的计时事件统一处理
const handleStartTimer = (task: Task) => {
// 任务树内状态同步
if (props.tasks) {
const updateTask = (tasks: Task[]): boolean => {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === task.id) {
tasks[i].isTimerRunning = true
tasks[i].timerStartTime = task.timerStartTime || Date.now()
tasks[i].timerEndTime = undefined
tasks[i].timerElapsedTime = 0
return true
}
if (tasks[i].children?.length) {
if (updateTask(tasks[i].children as Task[])) return true
}
}
return false
}
updateTask(props.tasks)
}
// 任务树内状态同步,同时同步到资源视图
updateTaskStateInTree(task.id, (t) => {
t.isTimerRunning = true
t.timerStartTime = task.timerStartTime || Date.now()
t.timerEndTime = undefined
t.timerElapsedTime = 0
})
closeContextMenu()
emit('timer-started', task)
}
const handleStopTimer = (task: Task) => {
// 任务树内状态同步
if (props.tasks) {
const updateTask = (tasks: Task[]): boolean => {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === task.id) {
if (tasks[i].isTimerRunning && tasks[i].timerStartTime !== undefined) {
const elapsed = tasks[i].timerElapsedTime || 0
tasks[i].timerElapsedTime = elapsed + (Date.now() - tasks[i].timerStartTime!)
tasks[i].timerEndTime = Date.now()
}
tasks[i].isTimerRunning = false
if (
taskDrawerVisible.value &&
taskDrawerTask.value &&
taskDrawerTask.value.id === task.id
) {
taskDrawerTask.value.isTimerRunning = false
taskDrawerTask.value.timerEndTime = Date.now()
}
return true
}
if (tasks[i].children?.length) {
if (updateTask(tasks[i].children as Task[])) return true
}
}
return false
// 任务树内状态同步,同时同步到资源视图
updateTaskStateInTree(task.id, (t) => {
if (t.isTimerRunning && t.timerStartTime !== undefined) {
const elapsed = t.timerElapsedTime || 0
t.timerElapsedTime = elapsed + (Date.now() - t.timerStartTime)
t.timerEndTime = Date.now()
}
updateTask(props.tasks)
}
t.isTimerRunning = false
// 同步更新TaskDrawer中的任务
if (
taskDrawerVisible.value &&
taskDrawerTask.value &&
taskDrawerTask.value.id === task.id
) {
taskDrawerTask.value.isTimerRunning = false
taskDrawerTask.value.timerEndTime = Date.now()
}
})
closeContextMenu()
emit('timer-stopped', task)
}
@@ -2613,6 +2687,75 @@ const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => {
return false
}
// v1.9.0 更新任务并同步到资源视图
const updateTaskAndSyncToResources = (updatedTask: Task) => {
// 1. 更新原始任务数据
if (props.tasks) {
updateTaskInTree(props.tasks, updatedTask)
}
// 2. 如果是资源视图,同步更新资源中的任务
if (currentViewMode.value === 'resource' && props.resources) {
// 查找任务所属的资源通过assignee
props.resources.forEach(resource => {
const taskIndex = resource.tasks.findIndex(t => t.id === updatedTask.id)
if (taskIndex !== -1) {
// 如果责任人变了,需要移动任务
if (updatedTask.assignee && updatedTask.assignee !== resource.id) {
// 从原资源中移除
resource.tasks.splice(taskIndex, 1)
// 添加到新资源
const newResource = props.resources.find(r => r.id === updatedTask.assignee)
if (newResource) {
newResource.tasks.push({ ...updatedTask })
}
} else {
// 责任人未变,只更新任务数据
resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask }
}
} else if (updatedTask.assignee === resource.id) {
// 任务新分配给这个资源
resource.tasks.push({ ...updatedTask })
}
})
}
}
// v1.9.0 在任务树中更新任务状态用于timer等部分更新
const updateTaskStateInTree = (taskId: number, updateFn: (task: Task) => void): boolean => {
const updateInList = (tasks: Task[]): boolean => {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === taskId) {
updateFn(tasks[i])
return true
}
if (tasks[i].children?.length) {
if (updateInList(tasks[i].children as Task[])) return true
}
}
return false
}
// 更新原始任务数据
let updated = false
if (props.tasks) {
updated = updateInList(props.tasks)
}
// 如果是资源视图,同步更新资源中的任务
if (updated && currentViewMode.value === 'resource' && props.resources) {
props.resources.forEach(resource => {
const task = resource.tasks.find(t => t.id === taskId)
if (task) {
updateFn(task)
}
})
}
return updated
}
// 在 handleTaskDrawerSubmit 里补充如果是添加前置任务自动将新任务id加入目标任务的 predecessor
function handleTaskDrawerSubmit(task: Task) {
if (!taskDrawerEditMode.value) {
@@ -2636,9 +2779,8 @@ function handleTaskDrawerSubmit(task: Task) {
taskToAddSuccessorTo.value = null
}
} else {
if (props.tasks) {
updateTaskInTree(props.tasks, task)
}
// 更新任务数据并同步到资源视图
updateTaskAndSyncToResources(task)
updateTaskTrigger.value++
// emit 任务更新事件
emit('task-updated', { task })
@@ -2671,6 +2813,17 @@ function handleTaskDelete(task: Task, deleteChildren?: boolean) {
if (props.tasks) {
removeTaskFromTree(props.tasks, task.id, deleteChildren)
}
// v1.9.0 如果是资源视图,同步从资源中删除任务
if (currentViewMode.value === 'resource' && props.resources) {
props.resources.forEach(resource => {
const taskIndex = resource.tasks.findIndex(t => t.id === task.id)
if (taskIndex !== -1) {
resource.tasks.splice(taskIndex, 1)
}
})
}
taskDrawerVisible.value = false
taskDrawerTask.value = null
// emit 删除事件

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
<script setup lang="ts">
import { ref, computed, onUnmounted, onMounted, nextTick, watch, useSlots, inject, type ComputedRef } from 'vue'
import { ref, computed, onUnmounted, onMounted, nextTick, watch, useSlots, inject, type ComputedRef, type Ref } from 'vue'
import type { Task } from '../models/classes/Task'
import { TimelineScale } from '../models/types/TimelineScale'
import TaskContextMenu from './TaskContextMenu.vue'
@@ -18,8 +18,29 @@ defineOptions({
// 从 GanttChart 注入 enableLinkAnchor 配置
const enableLinkAnchor = inject<ComputedRef<boolean>>('enable-link-anchor', computed(() => true))
// v1.9.0 注入视图模式,用于资源视图的垂直拖拽
const viewMode = inject<any>('gantt-view-mode', { value: 'task' })
// v1.9.0 计算当前资源的投入占比
const resourcePercent = computed(() => {
// 如果直接传递了占比,使用传递的值
if (props.resourceAllocationPercent !== undefined) {
return Math.max(20, Math.min(100, props.resourceAllocationPercent))
}
// 在资源视图中尝试从task.resources中查找当前资源的占比
if (viewMode.value === 'resource' && props.currentResourceId && props.task.resources) {
const allocation = props.task.resources.find((r: any) => r.id === props.currentResourceId)
if (allocation && allocation.percent !== undefined) {
return Math.max(20, Math.min(100, allocation.percent))
}
}
// 默认100%(向后兼容)
return 100
})
// v1.9.0 是否显示占比文字(占比<100%时显示)
const shouldShowPercentText = computed(() => {
return viewMode.value === 'resource' && resourcePercent.value < 100
})
interface Props {
task: Task
@@ -69,6 +90,14 @@ interface Props {
allTasks?: Task[]
// v1.9.0 资源视图:是否存在资源冲突(时间重叠)
hasResourceConflict?: boolean
// v1.9.0 资源视图:当前资源在任务中的投入占比 (20-100)
resourceAllocationPercent?: number
// v1.9.0 资源视图当前资源ID用于查找占比信息
currentResourceId?: string | number
// v1.9.0 资源视图:任务在子行中的索引(换行布局)
taskSubRow?: number
// v1.9.0 资源视图:每个子行的高度数组(换行布局)
rowHeights?: number[]
}
interface TaskStatus {
@@ -119,6 +148,13 @@ defineSlots<{
const slots = useSlots()
// 注入视图模式
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
// 注入资源布局信息(用于判断跨行拖拽边界)
const resourceRowPositions = inject<ComputedRef<Map<string, number>>>('resourceRowPositions', computed(() => new Map()))
const resourceTaskLayouts = inject<ComputedRef<Map<string, { taskRowMap: Map<string | number, number>, rowHeights: number[], totalHeight: number }>>>('resourceTaskLayouts', computed(() => new Map()))
// 注入右键菜单配置
const enableTaskBarContextMenu = inject<ComputedRef<boolean>>('enable-task-bar-context-menu', computed(() => true))
const hasTaskBarContextMenuSlot = inject<ComputedRef<boolean>>('task-bar-context-menu-slot', computed(() => false))
@@ -540,9 +576,42 @@ const taskBarStyle = computed(() => {
}
}
const taskBarHeight = props.rowHeight - 10
// v1.9.0 资源视图中使用固定top值因为resource-row已经绝对定位
const topOffset = viewMode.value === 'resource' ? 5 : (props.rowHeight - taskBarHeight) / 2
// v1.9.0 计算TaskBar高度保持基础高度一致资源视图中应用占比缩放
const baseTaskBarHeight = props.rowHeight - 10 // 基础高度(与任务视图一致)
let taskBarHeight = baseTaskBarHeight
if (viewMode.value === 'resource') {
// 资源视图:在基础高度上应用占比缩放
const percentValue = resourcePercent.value / 100
taskBarHeight = baseTaskBarHeight * percentValue
}
// v1.9.0 计算垂直位置:资源视图中支持换行布局
let topOffset = (props.rowHeight - taskBarHeight) / 2 // 默认:居中对齐
if (viewMode.value === 'resource' && props.taskSubRow !== undefined && props.rowHeights && props.rowHeights.length > 0) {
// 换行布局:根据子行索引和每行高度计算垂直位置
const subRow = props.taskSubRow
const rowHeights = props.rowHeights
const currentRowHeight = rowHeights[subRow] || 51
// 计算当前子行距离顶部的偏移量(累加前面所有行的高度)
let cumulativeOffset = 0
for (let i = 0; i < subRow; i++) {
cumulativeOffset += rowHeights[i] || 51
}
// 在当前子行内底边对齐
// 第一行padding-top(5px) + 可用空间
// 后续行可用空间无padding-top
if (subRow === 0) {
// 第一行顶部5px padding底部对齐
topOffset = cumulativeOffset + (currentRowHeight - 5 - taskBarHeight)
} else {
// 后续行底部对齐底部5px padding
topOffset = cumulativeOffset + (currentRowHeight - 5 - taskBarHeight)
}
}
return {
left: `${left}px`,
@@ -1068,15 +1137,23 @@ const handleMouseMove = (e: MouseEvent) => {
if (viewMode.value === 'resource') {
;(window as any).lastDragMouseY = e.clientY
// v1.9.0 检测是否跨行拖拽
// v1.9.0 检测是否跨行拖拽(基于资源行的实际高度)
const timelineBody = document.querySelector('.timeline-body')
let isCrossRowDrag = false
if (timelineBody && isDragging.value && isDragThresholdMet.value) {
if (timelineBody && isDragging.value && isDragThresholdMet.value && props.currentResourceId) {
const bodyRect = timelineBody.getBoundingClientRect()
const relativeY = e.clientY - bodyRect.top + (timelineBody as HTMLElement).scrollTop
const currentRowIndex = Math.floor(relativeY / 51)
isCrossRowDrag = currentRowIndex !== props.rowIndex
// 获取当前资源行的位置和高度
const currentResourceId = String(props.currentResourceId)
const currentRowTop = resourceRowPositions.value.get(currentResourceId) || 0
const currentRowLayout = resourceTaskLayouts.value.get(currentResourceId)
const currentRowHeight = currentRowLayout?.totalHeight || 51
const currentRowBottom = currentRowTop + currentRowHeight
// 判断鼠标是否超出当前资源行的边界
isCrossRowDrag = relativeY < currentRowTop || relativeY >= currentRowBottom
// 只有在跨行拖拽时才显示预览
if (isCrossRowDrag) {
@@ -1624,21 +1701,30 @@ const handleMouseUp = () => {
)
// v1.9.0 资源视图垂直拖拽:检测是否移动到不同资源
// @ts-expect-error - targetResourceRowIndex预留变量未来可能使用
let targetResourceRowIndex: number | undefined
let isCrossRowDrag = false
if (viewMode.value === 'resource' && isDragging.value && isDragThresholdMet.value) {
if (viewMode.value === 'resource' && isDragging.value && isDragThresholdMet.value && props.currentResourceId) {
// 记录松开鼠标时的X位置用于计算新日期
dragEndX.value = (window as any).event?.clientX || 0
// 检测是否跨行
// 检测是否跨行(基于资源行的实际高度)
const timelineBody = document.querySelector('.timeline-body')
if (timelineBody) {
const bodyRect = timelineBody.getBoundingClientRect()
const mouseY = (window as any).lastDragMouseY || 0
const relativeY = mouseY - bodyRect.top + (timelineBody as HTMLElement).scrollTop
const targetRowIndex = Math.floor(relativeY / 51)
isCrossRowDrag = targetRowIndex !== props.rowIndex
// 获取当前资源行的位置和高度
const currentResourceId = String(props.currentResourceId)
const currentRowTop = resourceRowPositions.value.get(currentResourceId) || 0
const currentRowLayout = resourceTaskLayouts.value.get(currentResourceId)
const currentRowHeight = currentRowLayout?.totalHeight || 51
const currentRowBottom = currentRowTop + currentRowHeight
// 判断鼠标是否超出当前资源行的边界
isCrossRowDrag = relativeY < currentRowTop || relativeY >= currentRowBottom
}
// 只有跨行拖拽才发送drop事件
@@ -2366,7 +2452,9 @@ const handleTaskBarMouseEnter = (event: MouseEvent) => {
// 保存event.currentTarget的引用因为在setTimeout回调中它会变成null
const targetElement = event.currentTarget as HTMLElement
// 保存鼠标位置
// @ts-expect-error - 预留变量,未来可能使用
const mouseX = event.clientX
// @ts-expect-error - 预留变量,未来可能使用
const mouseY = event.clientY
// 延迟显示tooltip避免快速滑过时显示
@@ -2381,6 +2469,7 @@ const handleTaskBarMouseEnter = (event: MouseEvent) => {
// 视口尺寸
const viewportWidth = window.innerWidth
// @ts-expect-error - 预留变量,未来可能使用
const viewportHeight = window.innerHeight
// v1.9.0 改为基于TaskBar边界定位
@@ -3121,12 +3210,16 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
<div
v-if="barConfig.showTitle && !(showActualTaskbar && hasActualProgress)"
ref="taskBarNameRef"
:style="viewMode.value === 'resource' ? {} : getNameStyles()"
:class="{ 'task-name-wrapper': viewMode.value === 'resource' }"
:style="viewMode === 'resource' ? {} : getNameStyles()"
:class="{ 'task-name-wrapper': viewMode === 'resource' }"
>
<slot v-if="hasContentSlot" name="custom-task-content" v-bind="slotPayload" />
<div v-else class="task-name">
{{ task.name }}
<!-- v1.9.0 资源视图:显示占比文字 -->
<span v-if="shouldShowPercentText" class="resource-percent-text">
{{ resourcePercent }}%
</span>
</div>
</div>
@@ -3134,7 +3227,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
<div
v-if="barConfig.showProgress && shouldShowProgress && !(showActualTaskbar && hasActualProgress)"
class="task-progress"
:style="viewMode.value === 'resource' ? {} : getProgressStyles()"
:style="viewMode === 'resource' ? {} : getProgressStyles()"
>
{{ task.progress || 0 }}%
</div>
@@ -3255,6 +3348,11 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
<div class="tooltip-arrow"></div>
<div class="tooltip-title">{{ task.name }}</div>
<div class="tooltip-content">
<!-- v1.9.0 资源视图:显示投入占比 -->
<div v-if="viewMode === 'resource' && resourcePercent < 100" class="tooltip-row">
<span class="tooltip-label">{{ t('investment') || '投入' }}:</span>
<span class="tooltip-value">{{ resourcePercent }}%</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label"> {{ t('startDate') }}:</span>
<span class="tooltip-value">{{ formatDisplayDate(task.startDate) }}</span>
@@ -3275,6 +3373,10 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
<span class="tooltip-label"> {{ t('progress') }}:</span>
<span class="tooltip-value">{{ task.progress || 0 }}%</span>
</div>
<!-- v1.9.0 资源冲突警告 -->
<div v-if="props.hasResourceConflict" class="tooltip-row tooltip-warning">
<span class="tooltip-label">⚠️ {{ t('resourceOverloaded') || '资源超负荷' }}</span>
</div>
</div>
</div>
</Teleport>
@@ -3876,6 +3978,31 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
/* 移除背景样式,保持原始状态 */
}
/* v1.9.0 资源占比文字样式 */
.resource-percent-text {
display: inline-block;
margin-left: 6px;
font-size: 12px;
font-weight: 500;
color: var(--gantt-text-on-primary, inherit);
opacity: 0.9;
}
/* TaskBar宽度<40px时隐藏占比文字 */
.task-bar[style*="width: 4px"],
.task-bar[style*="width: 8px"],
.task-bar[style*="width: 12px"],
.task-bar[style*="width: 16px"],
.task-bar[style*="width: 20px"],
.task-bar[style*="width: 24px"],
.task-bar[style*="width: 28px"],
.task-bar[style*="width: 32px"],
.task-bar[style*="width: 36px"] {
.resource-percent-text {
display: none;
}
}
.task-progress {
opacity: 0.9;
font-size: 11px;
@@ -4190,6 +4317,19 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
color: #ffffff;
}
/* v1.9.0 Tooltip警告行样式 */
.tooltip-warning {
border-top: 1px solid rgba(255, 107, 107, 0.3);
padding-top: 6px;
margin-top: 4px;
}
.tooltip-warning .tooltip-label {
color: #ff6b6b;
font-weight: 600;
opacity: 1;
}
/* === 拖拽实时反馈提示框样式 === */
.drag-tooltip {
position: fixed;

View File

@@ -167,6 +167,7 @@ const formData = reactive<Task>({
timerEndTime: undefined, // 计时器结束时间
timerElapsedTime: 0, // 计时器已用时间
children: undefined, // 子任务列表
resources: [], // v1.9.0 资源分配列表(包含占比)
})
// 任务列表数据
@@ -425,6 +426,7 @@ const resetForm = () => {
bgColor: undefined,
avatar: undefined,
barColor: undefined,
resources: [], // v1.9.0 资源分配
})
// 清除错误信息
@@ -677,6 +679,42 @@ const handleAssigneeChanged = (event: Event) => {
}
}
// v1.9.0 资源占比管理
const addResource = () => {
if (!formData.resources) {
formData.resources = []
}
formData.resources.push({
id: '',
name: '',
percent: 100,
})
}
const removeResource = (index: number) => {
if (formData.resources && formData.resources.length > index) {
formData.resources.splice(index, 1)
}
}
const handleResourceChange = (index: number, field: 'id' | 'percent', value: string | number) => {
if (!formData.resources || !formData.resources[index]) return
if (field === 'id') {
const selected = props.assigneeOptions?.find(option => option.value === value)
if (selected) {
formData.resources[index].id = selected.value
formData.resources[index].name = selected.label
}
} else if (field === 'percent') {
let percent = typeof value === 'string' ? parseInt(value) : value
// 校验范围 20-100
if (percent < 20) percent = 20
if (percent > 100) percent = 100
formData.resources[index].percent = percent
}
}
// 计算任务状态
const taskStatus = computed(() => {
if (!props.task) return { text: '', color: '', bgColor: '', borderColor: '' }
@@ -925,6 +963,71 @@ const taskStatus = computed(() => {
</select>
</div>
<!-- v1.9.0 资源分配含占比配置 -->
<div class="form-group">
<label class="form-label">{{ t.resourceAllocation || '资源分配' }}</label>
<div class="resource-list">
<div
v-for="(resource, index) in formData.resources"
:key="index"
class="resource-item"
>
<select
v-model="resource.id"
class="form-select resource-select"
@change="handleResourceChange(index, 'id', ($event.target as HTMLSelectElement).value)"
>
<option value="">{{ t.selectResource || '选择资源' }}</option>
<option
v-for="assignee in props.assigneeOptions"
:key="assignee.key ?? assignee.value"
:value="assignee.value"
>
{{ assignee.label }}
</option>
</select>
<div class="percent-input-wrapper">
<select
v-model.number="resource.percent"
class="form-select percent-select"
@change="handleResourceChange(index, 'percent', ($event.target as HTMLSelectElement).value)"
>
<option :value="25">25%</option>
<option :value="50">50%</option>
<option :value="75">75%</option>
<option :value="100">100%</option>
</select>
<input
type="number"
v-model.number="resource.percent"
class="form-input percent-input"
min="20"
max="100"
@input="handleResourceChange(index, 'percent', ($event.target as HTMLInputElement).value)"
/>
<span class="percent-unit">%</span>
</div>
<button
type="button"
class="btn-remove-resource"
@click="removeResource(index)"
title="删除资源"
>
×
</button>
</div>
<button
type="button"
class="btn-add-resource"
@click="addResource"
>
+ {{ t.addResource || '添加资源' }}
</button>
</div>
</div>
<!-- 上级任务选择 -->
<div class="form-group">
<label class="form-label" for="task-parent">{{ t.parentTask }}</label>
@@ -1570,4 +1673,96 @@ const taskStatus = computed(() => {
:global(html[data-theme='dark']) .form-textarea::placeholder {
color: var(--gantt-text-muted, #9e9e9e) !important;
}
/* v1.9.0 资源分配样式 */
.resource-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.resource-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: var(--gantt-bg-secondary, #f5f7fa);
border-radius: 4px;
}
.resource-select {
flex: 1;
min-width: 140px;
}
.percent-input-wrapper {
display: flex;
align-items: center;
gap: 4px;
}
.percent-select {
width: 80px;
}
.percent-input {
width: 60px;
padding: 6px 8px;
}
.percent-unit {
font-size: 14px;
color: var(--gantt-text-secondary, #606266);
}
.btn-remove-resource {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--gantt-border-base, #dcdfe6);
border-radius: 4px;
color: var(--gantt-text-secondary, #606266);
cursor: pointer;
font-size: 20px;
line-height: 1;
transition: all 0.2s;
}
.btn-remove-resource:hover {
background: var(--gantt-danger-light, #fef0f0);
border-color: var(--gantt-danger, #f56c6c);
color: var(--gantt-danger, #f56c6c);
}
.btn-add-resource {
width: 100%;
padding: 8px;
background: transparent;
border: 1px dashed var(--gantt-border-base, #dcdfe6);
border-radius: 4px;
color: var(--gantt-primary, #409eff);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-add-resource:hover {
border-color: var(--gantt-primary, #409eff);
background: var(--gantt-primary-light, #ecf5ff);
}
:global(html[data-theme='dark']) .resource-item {
background: var(--gantt-bg-secondary, rgba(255, 255, 255, 0.05)) !important;
}
:global(html[data-theme='dark']) .btn-remove-resource:hover {
background: var(--gantt-danger-dark, rgba(245, 108, 108, 0.2)) !important;
}
:global(html[data-theme='dark']) .btn-add-resource:hover {
background: var(--gantt-primary-dark, rgba(64, 158, 255, 0.2)) !important;
}
</style>

View File

@@ -6,6 +6,7 @@ import { useI18n } from '../../composables/useI18n'
import type { Task } from '../../models/classes/Task'
import type { Resource } from '../../models/classes/Resource'
import type { TaskListConfig, TaskListColumnConfig } from '../../models/configs/TaskListConfig'
// @ts-expect-error - ResourceListColumnConfig is used in type unions
import type { ResourceListConfig, ResourceListColumnConfig } from '../../models/configs/ResourceListConfig'
import { DEFAULT_TASK_LIST_COLUMNS } from '../../models/configs/TaskListConfig'
import { DEFAULT_RESOURCE_LIST_COLUMNS } from '../../models/configs/ResourceListConfig'
@@ -86,7 +87,7 @@ const { declarativeColumns, getColumnWidthStyle: getDeclarativeColumnWidth } =
useTaskListColumns(
computed(() => props.taskListColumnRenderMode || 'default'),
slots,
finalColumnsConfig.value,
finalColumnsConfig.value as TaskListColumnConfig[],
)
// 计算实际使用的列配置
@@ -239,6 +240,7 @@ const handleTaskRowMoved = (payload: {
}
// v1.9.0 处理资源行点击事件
// @ts-expect-error - Reserved for future resource click handling
const handleResourceClick = (resource: Resource) => {
emit('resource-click', resource)
}
@@ -308,7 +310,7 @@ onUnmounted(() => {
<component :is="columnSlots['header-name']" />
</template>
<template v-else>
{{ (t as any).taskName || '任务名称' }}
{{ viewMode === 'resource' ? ((t as any).resourceName || '资源名称') : ((t as any).taskName || '任务名称') }}
</template>
</div>
<div
@@ -345,7 +347,7 @@ onUnmounted(() => {
:is-hovered="hoveredTaskId === task.id"
:hovered-task-id="hoveredTaskId"
:on-hover="handleTaskRowHover"
:columns="taskListColumnRenderMode === 'declarative' ? [] : visibleColumns"
:columns="taskListColumnRenderMode === 'declarative' ? [] : (visibleColumns as TaskListColumnConfig[])"
:declarative-columns="taskListColumnRenderMode === 'declarative' ? columnsToUse : undefined"
:render-mode="taskListColumnRenderMode"
:get-column-width-style="getColumnWidthStyle"

View File

@@ -1,5 +1,6 @@
import { ref, computed, type Ref } from 'vue'
import { ref, computed, inject, type Ref, type ComputedRef } from 'vue'
import type { Task } from '../../../../models/classes/Task'
import type { Resource } from '../../../../models/classes/Resource'
/**
* TaskList 布局计算逻辑
@@ -29,6 +30,15 @@ export function useTaskListLayout(tasks: Ref<Task[]>) {
const taskListScrollTop = ref(0)
const taskListBodyHeight = ref(0)
// v1.9.0 注入视图模式和资源布局信息
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
const resourceTaskLayouts = inject<ComputedRef<Map<string | number, {
taskRowMap: Map<string | number, number>,
rowHeights: number[],
totalHeight: number
}>>>('resourceTaskLayouts', computed(() => new Map()))
/**
* 获取当前折叠状态下的可见任务列表(扁平化)
*/
@@ -55,22 +65,81 @@ export function useTaskListLayout(tasks: Ref<Task[]>) {
const flattenedTasks = computed(() => getFlattenedVisibleTasks(tasks.value))
/**
* 计算可视区域任务范围
* v1.9.0 计算每个任务/资源的累计高度位置(支持动态行高)
*/
const cumulativeHeights = computed<number[]>(() => {
if (viewMode.value === 'resource') {
// 资源视图:使用每个资源的实际高度
const resources = dataSource.value as Resource[]
const heights: number[] = [0] // 第一个位置是0
let cumulative = 0
resources.forEach(resource => {
const layout = resourceTaskLayouts.value.get(resource.id)
const height = layout?.totalHeight || ROW_HEIGHT
cumulative += height
heights.push(cumulative)
})
return heights
} else {
// 任务视图:使用固定行高
const heights: number[] = [0]
for (let i = 1; i <= flattenedTasks.value.length; i++) {
heights.push(i * ROW_HEIGHT)
}
return heights
}
})
/**
* 根据滚动位置查找对应的任务/资源索引(二分查找)
*/
const findIndexByScrollTop = (scrollTop: number): number => {
const heights = cumulativeHeights.value
let left = 0
let right = heights.length - 1
while (left < right) {
const mid = Math.floor((left + right) / 2)
if (heights[mid] <= scrollTop) {
left = mid + 1
} else {
right = mid
}
}
return Math.max(0, left - 1)
}
/**
* 计算可视区域任务范围(支持动态行高)
*/
const visibleTaskRange = computed<VisibleTaskRange>(() => {
const scrollTop = taskListScrollTop.value
const containerHeight = taskListBodyHeight.value || 600
const heights = cumulativeHeights.value
const startIndex = Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER
const endIndex = Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER
if (heights.length <= 1) {
return { startIndex: 0, endIndex: 0 }
}
const total = flattenedTasks.value.length
const clampedStart = Math.min(Math.max(0, startIndex), total)
const clampedEnd = Math.min(total, Math.max(clampedStart + 1, endIndex))
// 找到起始索引(考虑缓冲区)
let startIndex = findIndexByScrollTop(scrollTop)
startIndex = Math.max(0, startIndex - VERTICAL_BUFFER)
// 找到结束索引(考虑缓冲区)
const scrollBottom = scrollTop + containerHeight
let endIndex = findIndexByScrollTop(scrollBottom)
endIndex = Math.min(heights.length - 1, endIndex + VERTICAL_BUFFER + 1)
const total = viewMode.value === 'resource'
? (dataSource.value as Resource[]).length
: flattenedTasks.value.length
return {
startIndex: clampedStart,
endIndex: clampedEnd,
startIndex: Math.min(startIndex, total),
endIndex: Math.min(endIndex, total),
}
})
@@ -88,13 +157,22 @@ export function useTaskListLayout(tasks: Ref<Task[]>) {
})
/**
* Spacer 高度用于撑起滚动区域
* Spacer 高度用于撑起滚动区域(支持动态行高)
*/
const totalContentHeight = computed(() => flattenedTasks.value.length * ROW_HEIGHT)
const startSpacerHeight = computed(() => visibleTaskRange.value.startIndex * ROW_HEIGHT)
const totalContentHeight = computed(() => {
const heights = cumulativeHeights.value
return heights.length > 0 ? heights[heights.length - 1] : 0
})
const startSpacerHeight = computed(() => {
const startIdx = visibleTaskRange.value.startIndex
return cumulativeHeights.value[startIdx] || 0
})
const endSpacerHeight = computed(() => {
const visibleHeight = visibleTasks.value.length * ROW_HEIGHT
return Math.max(0, totalContentHeight.value - startSpacerHeight.value - visibleHeight)
const endIdx = visibleTaskRange.value.endIndex
const endHeight = cumulativeHeights.value[endIdx] || 0
return Math.max(0, totalContentHeight.value - endHeight)
})
return {

View File

@@ -4,6 +4,7 @@ import type { StyleValue } from 'vue'
import { useI18n } from '../../../composables/useI18n'
import { formatPredecessorDisplay } from '../../../utils/predecessorUtils'
import type { Task } from '../../../models/classes/Task'
// @ts-expect-error - Resource is used in type definitions
import type { Resource } from '../../../models/classes/Resource'
import type { TaskListColumnConfig } from '../../../models/configs/TaskListConfig'
import type { DeclarativeColumnConfig } from '../composables/taskList/useTaskListColumns'
@@ -86,11 +87,37 @@ const hasContentSlot = computed(() => Boolean(slots['custom-task-content']))
// v1.9.0 Inject view mode to detect if we're rendering a resource
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
// 从 GanttChart 注入资源布局信息
const resourceTaskLayouts = inject<ComputedRef<Map<string, { taskRowMap: Map<string | number, number>, rowHeights: number[], totalHeight: number }>>>('resourceTaskLayouts', computed(() => new Map()))
// v1.9.0 Detect if current row is a resource
const isResourceRow = computed(() => {
return viewMode.value === 'resource' && 'tasks' in props.task
})
// v1.9.0 检测资源是否超载(任务重叠)
const isResourceOverloaded = computed(() => {
if (!isResourceRow.value) return false
// 类型断言为Resource
const resource = props.task as any
if (typeof resource.isOverloaded === 'function') {
return resource.isOverloaded()
}
return false
})
// 计算行高度 - resource视图下使用动态高度
const rowHeight = computed(() => {
if (isResourceRow.value) {
const resourceId = String(props.task.id) // 转换为string
const layout = resourceTaskLayouts.value.get(resourceId)
return layout?.totalHeight || 51
}
return 51 // task视图下使用固定高度
})
// 注入右键菜单配置
const enableTaskListContextMenu = inject<ComputedRef<boolean>>('enable-task-list-context-menu', computed(() => true))
const hasTaskListContextMenuSlot = inject<ComputedRef<boolean>>('task-list-context-menu-slot', computed(() => false))
@@ -236,9 +263,16 @@ const slotPayload = computed(() => ({
progressClass: progressClass.value,
}))
// 计算左侧边框颜色 - 支持barColor自定义
// 计算左侧边框颜色 - 支持barColor/color自定义
const leftBorderColor = computed(() => {
// 如果设置了barColor优先使用
// 资源视图优先使用resource.color
if (isResourceRow.value) {
const resource = props.task as any
if (resource.color) {
return resource.color
}
}
// 任务视图使用barColor
if (props.task.barColor) {
return props.task.barColor
}
@@ -247,10 +281,12 @@ const leftBorderColor = computed(() => {
})
// 计算自定义边框样式
const customBorderStyle = computed(() => {
const customBorderStyle = computed((): StyleValue => {
if (leftBorderColor.value) {
return {
borderLeftColor: leftBorderColor.value
borderLeftColor: `${leftBorderColor.value} !important` as any,
borderLeftWidth: '3px',
borderLeftStyle: 'solid' as const,
}
}
return {}
@@ -298,7 +334,7 @@ const assigneeDisplayData = computed(() => {
:data-task-id="props.task.id"
:class="{
'task-row-hovered': isHovered,
'task-type-story': viewMode === 'resource', /* v1.9.0 资源视图始终显示左边框 */
'task-type-resource': isResourceRow, /* v1.9.0 资源视图始终显示左边框 */
'parent-task': isParentTask,
'milestone-group-row': isMilestoneGroup,
'task-type-story': isStoryTask,
@@ -306,7 +342,7 @@ const assigneeDisplayData = computed(() => {
'task-type-milestone': isMilestoneTask,
[customRowClass]: customRowClass,
}"
:style="[customRowStyle, customBorderStyle]"
:style="[customRowStyle, customBorderStyle, { height: `${rowHeight}px` }]"
@click="handleRowClick"
@dblclick="handleTaskRowDoubleClick"
@mousedown="handleMouseDown"
@@ -374,6 +410,25 @@ const assigneeDisplayData = computed(() => {
<!-- v1.9.0 资源视图直接显示资源名称 -->
<div v-if="isResourceRow" class="resource-row-name">
<!-- v1.9.0 资源超载警示图标 -->
<svg
v-if="isResourceOverloaded"
class="resource-warning-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2L2 20h20L12 2z"
fill="var(--gantt-danger, #f56c6c)"
/>
<path
d="M12 8v6M12 16h.01"
stroke="#fff"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<div v-if="(props.task as any).avatar" class="resource-avatar">
<img :src="(props.task as any).avatar" :alt="(props.task as any).name" />
</div>
@@ -626,6 +681,11 @@ const assigneeDisplayData = computed(() => {
border-left: 3px solid var(--gantt-danger, #f56c6c);
}
/* v1.9.0 资源类型左边框颜色 */
.task-type-resource {
border-left: 3px solid var(--gantt-success, #67c23a);
}
/* 任务类型悬停时保持左边框,无需加粗 */
.task-type-story:hover {
border-left: 3px solid var(--gantt-primary, #409eff);
@@ -639,6 +699,10 @@ const assigneeDisplayData = computed(() => {
border-left: 3px solid var(--gantt-danger, #f56c6c);
}
.task-type-resource:hover {
border-left: 3px solid var(--gantt-success, #67c23a);
}
/* 悬停状态下的左边框保持 */
.task-row-hovered.task-type-story {
border-left: 3px solid var(--gantt-primary, #409eff) !important;
@@ -652,6 +716,10 @@ const assigneeDisplayData = computed(() => {
border-left: 3px solid var(--gantt-danger, #f56c6c) !important;
}
.task-row-hovered.task-type-resource {
border-left: 3px solid var(--gantt-success, #67c23a) !important;
}
:global(html[data-theme='dark']) .milestone-group-row {
border-left-color: var(--gantt-danger, #f67c7c);
}
@@ -669,6 +737,10 @@ const assigneeDisplayData = computed(() => {
border-left-color: var(--gantt-danger, #f67c7c);
}
:global(html[data-theme='dark']) .task-type-resource {
border-left-color: var(--gantt-success, #85ce61);
}
.collapse-btn:hover {
background-color: var(--gantt-primary-light);
}
@@ -850,6 +922,23 @@ const assigneeDisplayData = computed(() => {
color: var(--gantt-text-primary);
}
/* v1.9.0 资源超载警示图标 */
.resource-warning-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.resource-avatar {
width: 28px;
height: 28px;

View File

@@ -15,7 +15,7 @@ import type { Resource } from '../models/classes/Resource'
import type { Milestone } from '../models/classes/Milestone'
import type { TimelineConfig } from '../models/configs/TimelineConfig'
import { TimelineScale } from '../models/types/TimelineScale'
import { detectResourceConflicts } from '../utils/resourceUtils'
import { assignTaskRows } from '../utils/taskLayoutUtils' // v1.9.0 换行布局算法
// 定义Props接口
interface Props {
@@ -89,7 +89,7 @@ const emit = defineEmits<{
'successor-added': [{ targetTask: Task; newTask: Task }] // 后置任务已添加
delete: [task: Task, deleteChildren?: boolean]
'link-deleted': [{ sourceTaskId: number; targetTaskId: number; updatedTask: Task }] // 链接已删除
'resource-drag-end': [{ task: Task; sourceResourceIndex: number; targetResourceIndex: number; targetResource: Resource }] // v1.9.0 资源视图垂直拖拽结束
'resource-drag-end': [{ task: Task; sourceResourceIndex: number; targetResourceIndex: number; targetResource: Resource; newStartDate?: string; newEndDate?: string }] // v1.9.0 资源视图垂直拖拽结束
}>()
// 多语言
@@ -104,21 +104,60 @@ const t = (key: string): string => {
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
// v1.9.0 计算资源冲突
const resourceConflicts = computed(() => {
if (viewMode.value !== 'resource') return new Map()
// v1.9.0 从 GanttChart 注入资源冲突信息(由 GanttChart 计算并响应 updateTaskTrigger
const resourceConflicts = inject<ComputedRef<Map<string, Set<number>>>>('resourceConflicts', computed(() => new Map()))
// v1.9.0 计算资源视图的任务行布局(换行)
const resourceTaskLayouts = computed(() => {
const layoutMap = new Map<string | number, {
taskRowMap: Map<string | number, number>,
rowHeights: number[],
totalHeight: number
}>()
if (viewMode.value !== 'resource') {
return layoutMap
}
const resources = dataSource.value as Resource[]
const conflictsMap = new Map<string | number, Set<number>>()
resources.forEach(resource => {
const conflicts = detectResourceConflicts(resource)
if (conflicts.size > 0) {
conflictsMap.set(resource.id, conflicts)
const resourceTasks = (resource as any).tasks || []
if (resourceTasks.length > 0) {
const result = assignTaskRows(resourceTasks, resource.id, 51)
layoutMap.set(resource.id, result)
} else {
// 没有任务的资源,使用默认高度
layoutMap.set(resource.id, {
taskRowMap: new Map(),
rowHeights: [51],
totalHeight: 51
})
}
})
return conflictsMap
return layoutMap
})
// v1.9.0 计算资源行的累积位置(用于资源视图)
const resourceRowPositions = computed(() => {
const positions = new Map<string | number, number>()
if (viewMode.value !== 'resource') {
return positions
}
const resources = dataSource.value as Resource[]
let cumulativeTop = 0
resources.forEach(resource => {
positions.set(resource.id, cumulativeTop)
const layout = resourceTaskLayouts.value.get(resource.id)
const resourceHeight = layout?.totalHeight || 51
cumulativeTop += resourceHeight
})
return positions
})
// 获取以今天为中心的时间线范围(缓存结果,避免每次计算创建新对象)
@@ -664,7 +703,7 @@ const getYearTimelineRange = () => {
}
// 悬停状态管理
const hoveredTaskId = ref<number | null>(null)
const hoveredTaskId = ref<number | string | null>(null)
// 高亮状态管理(用于长按高亮功能)
const highlightedTaskId = ref<number | null>(null)
@@ -1342,30 +1381,86 @@ const visibleHourRange = computed(() => {
}
})
// 计算纵向可视区域的任务范围
// 计算纵向可视区域的任务范围(支持动态行高)
const visibleTaskRange = computed(() => {
const scrollTop = timelineBodyScrollTop.value
const containerHeight = timelineBodyHeight.value || 600
// 计算可视区域的开始和结束任务索引
const startIndex = Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER
const endIndex = Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER
if (viewMode.value === 'resource') {
// 资源视图:基于资源行的实际高度计算可见范围
const resources = dataSource.value as Resource[]
return {
startIndex: Math.max(0, startIndex),
endIndex: Math.min(tasks.value.length, Math.max(startIndex + 1, endIndex)),
let startIndex = 0
let endIndex = resources.length
// 找到第一个可见的资源行
for (let i = 0; i < resources.length; i++) {
const resourceId = resources[i].id
const rowTop = resourceRowPositions.value.get(resourceId) || 0
const layout = resourceTaskLayouts.value.get(resourceId)
const rowHeight = layout?.totalHeight || ROW_HEIGHT
const rowBottom = rowTop + rowHeight
if (rowBottom >= scrollTop - ROW_HEIGHT * VERTICAL_BUFFER) {
startIndex = i
break
}
}
// 找到最后一个可见的资源行
const scrollBottom = scrollTop + containerHeight
for (let i = startIndex; i < resources.length; i++) {
const resourceId = resources[i].id
const rowTop = resourceRowPositions.value.get(resourceId) || 0
if (rowTop > scrollBottom + ROW_HEIGHT * VERTICAL_BUFFER) {
endIndex = i
break
}
}
return {
startIndex: Math.max(0, startIndex),
endIndex: Math.min(resources.length, endIndex),
}
} else {
// 任务视图:使用固定行高计算
const startIndex = Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER
const endIndex = Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER
return {
startIndex: Math.max(0, startIndex),
endIndex: Math.min(tasks.value.length, Math.max(startIndex + 1, endIndex)),
}
}
})
// 获取虚拟滚动优化后的可见任务列表
const visibleTasks = computed(() => {
const { startIndex, endIndex } = visibleTaskRange.value
// 任务视图:返回可见的任务列表
return tasks.value.slice(startIndex, endIndex).map((task, index) => ({
task,
originalIndex: startIndex + index,
}))
})
// v1.9.0 资源视图的可见资源列表
const visibleResources = computed(() => {
const { startIndex, endIndex } = visibleTaskRange.value
if (viewMode.value !== 'resource') {
return []
}
const resources = dataSource.value as Resource[]
return resources.slice(startIndex, endIndex).map((resource, index) => ({
resource,
originalIndex: startIndex + index,
}))
})
// 防抖处理滚动事件(优化:增加防抖时间)
const debounce = <T extends (...args: unknown[]) => void>(func: T, wait: number): T => {
let timeout: number | null = null
@@ -1688,7 +1783,7 @@ const handleTimelineContainerResized = () => {
}
// 处理任务行悬停事件
const handleTaskRowHover = (taskId: number | null) => {
const handleTaskRowHover = (taskId: number | string | null) => {
// 如果正在拖拽Splitter或拖动滚动则不响应悬停事件
if (isSplitterDragging.value || isDragging.value) {
return
@@ -1705,11 +1800,25 @@ const handleTaskRowHover = (taskId: number | null) => {
// 计算Timeline内容的总高度
const contentHeight = computed(() => {
// 每个任务行高度51px (50px + 1px border)
const minHeight = 400 // 最小高度确保有足够的空间
// v1.9.0 资源视图:使用实际的累计高度
if (viewMode.value === 'resource') {
const resources = dataSource.value as Resource[]
let totalHeight = 0
resources.forEach(resource => {
const layout = resourceTaskLayouts.value.get(resource.id)
totalHeight += layout?.totalHeight || 51
})
return Math.max(totalHeight, minHeight, timelineBodyHeight.value)
}
// 任务视图每个任务行高度51px (50px + 1px border)
const rowHeight = 51
const taskCount = tasks.value.length
const minHeightFromTasks = taskCount * rowHeight
const minHeight = 400 // 最小高度确保有足够的空间
// 返回任务高度、最小高度和容器高度中的最大值
return Math.max(minHeightFromTasks, minHeight, timelineBodyHeight.value)
@@ -3147,6 +3256,7 @@ const handleTaskBarHighlighted = () => {
// v1.9.0 资源视图垂直拖拽处理TaskBar拖放到不同资源行
const handleResourceTaskBarDrop = (event: Event) => {
const customEvent = event as CustomEvent
// @ts-expect-error - taskId和mouseX预留但当前未使用
const { taskId, task, sourceRowIndex, mouseY, mouseX } = customEvent.detail
// 计算目标资源行索引
@@ -3155,15 +3265,41 @@ const handleResourceTaskBarDrop = (event: Event) => {
const bodyRect = timelineBody.getBoundingClientRect()
const relativeY = mouseY - bodyRect.top + timelineBody.scrollTop
const targetRowIndex = Math.floor(relativeY / 51) // 51px是每行的高度
// v1.9.0 使用动态的资源行位置来计算目标行索引
const resources = dataSource.value as Resource[]
let targetRowIndex = -1
let minDistance = Infinity
// 遍历资源,找到距离鼠标位置最近的资源行中心
for (let i = 0; i < resources.length; i++) {
const resource = resources[i]
const resourceId = String(resource.id)
const rowTop = resourceRowPositions.value.get(resourceId) || 0
const layout = resourceTaskLayouts.value.get(resourceId)
const rowHeight = layout?.totalHeight || 51
const rowCenter = rowTop + rowHeight / 2
const distance = Math.abs(relativeY - rowCenter)
if (distance < minDistance) {
minDistance = distance
targetRowIndex = i
}
}
// 如果没有找到目标行(不太可能),使用最近的边界
if (targetRowIndex === -1 && resources.length > 0) {
if (relativeY < 0) {
targetRowIndex = 0
} else {
targetRowIndex = resources.length - 1
}
}
// v1.9.0 直接使用TaskBar传递过来的精确日期避免重复计算导致误差
const newStartDate = customEvent.detail.calculatedStartDate
const newEndDate = customEvent.detail.calculatedEndDate
// 获取资源列表
const resources = dataSource.value as Resource[]
// 如果目标行与源行不同发送事件给父组件demo处理
if (targetRowIndex !== sourceRowIndex && targetRowIndex >= 0 && targetRowIndex < resources.length) {
const targetResource = resources[targetRowIndex]
@@ -3444,19 +3580,40 @@ const handleDragBoundaryCheck = (event: CustomEvent) => {
// v1.9.0 资源视图垂直拖拽检测
if (isResourceView && mouseY && timelineBodyElement.value) {
const bodyRect = timelineBodyElement.value.getBoundingClientRect()
const relativeY = mouseY - bodyRect.top
const targetRowIndex = Math.floor(relativeY / 51) // 每行高度51px
const relativeY = mouseY - bodyRect.top + timelineBodyElement.value.scrollTop
// 使用动态的资源行位置来计算目标行索引(基于最近的行中心)
const resources = dataSource.value as Resource[]
let targetRowIndex = -1
let minDistance = Infinity
for (let i = 0; i < resources.length; i++) {
const resource = resources[i]
const resourceId = String(resource.id)
const rowTop = resourceRowPositions.value.get(resourceId) || 0
const layout = resourceTaskLayouts.value.get(resourceId)
const rowHeight = layout?.totalHeight || 51
const rowCenter = rowTop + rowHeight / 2
const distance = Math.abs(relativeY - rowCenter)
if (distance < minDistance) {
minDistance = distance
targetRowIndex = i
}
}
// 如果移动到不同行,发送事件通知(可以用于显示拖拽指示器等)
if (targetRowIndex !== rowIndex && targetRowIndex >= 0) {
window.dispatchEvent(new CustomEvent('resource-drag-over', {
detail: {
taskId,
sourceRowIndex: rowIndex,
targetRowIndex,
mouseY: relativeY
}
}))
if (targetRowIndex !== -1 && targetRowIndex !== rowIndex) {
window.dispatchEvent(
new CustomEvent('resource-drag-over', {
detail: {
taskId,
sourceRowIndex: rowIndex,
targetRowIndex,
mouseY: relativeY,
},
}),
)
}
}
@@ -4680,11 +4837,14 @@ const handleAddSuccessor = (task: Task) => {
<!-- 资源视图:一行渲染多个 TaskBar -->
<div
v-else-if="viewMode === 'resource'"
v-for="{ task: resource, originalIndex } in visibleTasks"
v-for="{ resource, originalIndex } in visibleResources"
:key="resource.id"
class="task-row resource-row"
:class="{ 'task-row-hovered': hoveredTaskId === resource.id }"
:style="{ top: `${originalIndex * 51}px` }"
:style="{
top: `${resourceRowPositions.get(resource.id) || 0}px`,
height: `${resourceTaskLayouts.get(resource.id)?.totalHeight || 51}px`
}"
@mouseenter="handleTaskRowHover(resource.id)"
@mouseleave="handleTaskRowHover(null)"
>
@@ -4696,6 +4856,8 @@ const handleAddSuccessor = (task: Task) => {
:task="task"
:row-index="originalIndex"
:row-height="51"
:task-sub-row="resourceTaskLayouts.get(resource.id)?.taskRowMap.get(task.id) || 0"
:row-heights="resourceTaskLayouts.get(resource.id)?.rowHeights || [51]"
:day-width="dayWidth"
:start-date="
currentTimeScale === TimelineScale.YEAR
@@ -4731,7 +4893,8 @@ const handleAddSuccessor = (task: Task) => {
linkDragTargetTask?.id === task.id && isValidLinkTarget === false
"
:all-tasks="tasks"
:has-resource-conflict="resourceConflicts.get(resource.id)?.has(task.id) || false"
:has-resource-conflict="resourceConflicts.get(String(resource.id))?.has(task.id) || false"
:current-resource-id="resource.id"
:style="{
zIndex: taskIndex,
}"
@@ -5122,7 +5285,7 @@ const handleAddSuccessor = (task: Task) => {
position: absolute !important;
left: 0;
width: 100%;
height: 51px !important;
/* height由内联样式动态设置,不使用固定值 */
pointer-events: auto;
z-index: 11;
transition: background-color 0.2s ease;

View File

@@ -9,6 +9,7 @@ const messages = {
dateNotSet: '未设置',
// TaskList Header
taskName: '任务名称',
resourceName: '资源名称',
predecessor: '前置任务',
assignee: '分配人',
startDate: '开始日期',
@@ -205,6 +206,12 @@ const messages = {
taskDeletedSuccess: '已删除任务',
// 选择器占位符
selectAssignee: '请选择负责人',
// v1.9.0 资源分配
resourceAllocation: '资源分配',
selectResource: '选择资源',
addResource: '添加资源',
percentMinError: '最小占比为20%',
percentMaxWarning: '占比超过100%将标记为超负荷',
// 通用消息
customCsvExportCalled: '自定义CSV导出被调用',
languageSwitchedTo: '语言切换到:{language}',
@@ -295,6 +302,7 @@ const messages = {
dateNotSet: 'Not set',
// TaskList Header
taskName: 'Task Name',
resourceName: 'Resource Name',
predecessor: 'Predecessor',
assignee: 'Assignee',
startDate: 'Start Date',
@@ -492,6 +500,12 @@ const messages = {
taskDeletedSuccess: 'Task deleted',
// Selector placeholders
selectAssignee: 'Select assignee',
// v1.9.0 Resource allocation
resourceAllocation: 'Resource Allocation',
selectResource: 'Select resource',
addResource: 'Add Resource',
percentMinError: 'Minimum percentage is 20%',
percentMaxWarning: 'Percentage over 100% will be marked as overloaded',
// Common messages
customCsvExportCalled: 'Custom CSV export called',
languageSwitchedTo: 'Language switched to: {language}',

View File

@@ -16,6 +16,7 @@ export class Resource {
department?: string
skills?: string[]
utilization?: number
color?: string // 自定义资源行左边框颜色,如 '#ff5733',若不设置则使用默认颜色方案
tasks: Task[]
[key: string]: unknown
@@ -28,6 +29,7 @@ export class Resource {
department?: string
skills?: string[]
utilization?: number
color?: string
tasks?: Task[]
[key: string]: unknown
}) {
@@ -39,6 +41,7 @@ export class Resource {
this.department = data.department
this.skills = data.skills
this.utilization = data.utilization
this.color = data.color
this.tasks = data.tasks || []
// 复制其他自定义属性
@@ -88,4 +91,98 @@ export class Resource {
updateUtilization(): void {
this.utilization = this.calculateUtilization()
}
/**
* 检测任务是否存在时间重叠
* @returns 是否存在任务重叠
*/
hasTaskOverlap(): boolean {
if (this.tasks.length < 2) return false
// 过滤掉没有开始日期和结束日期的任务
const validTasks = this.tasks.filter(task => task.startDate && task.endDate)
if (validTasks.length < 2) return false
// 按开始日期排序
const sortedTasks = [...validTasks].sort((a, b) => {
const dateA = new Date(a.startDate!).getTime()
const dateB = new Date(b.startDate!).getTime()
return dateA - dateB
})
// 检测相邻任务是否重叠
for (let i = 0; i < sortedTasks.length - 1; i++) {
const currentTask = sortedTasks[i]
const nextTask = sortedTasks[i + 1]
const currentEnd = new Date(currentTask.endDate!).getTime()
const nextStart = new Date(nextTask.startDate!).getTime()
// 如果当前任务的结束时间晚于下一个任务的开始时间,则存在重叠
if (currentEnd > nextStart) {
return true
}
}
return false
}
/**
* v1.9.0 检测资源是否超载(基于占用比例)
* 超载定义:同一时间段内,资源总占用比例 > 100%
* @returns 是否资源超载
*/
isOverloaded(): boolean {
if (this.tasks.length < 2) return false
// 过滤掉没有开始日期和结束日期的任务
const validTasks = this.tasks.filter(task => task.startDate && task.endDate)
if (validTasks.length < 2) return false
// 检测任意时间点的总占比是否超过100%
for (let i = 0; i < validTasks.length; i++) {
for (let j = i + 1; j < validTasks.length; j++) {
const task1 = validTasks[i]
const task2 = validTasks[j]
// 检查两个任务是否有时间交集
const start1 = new Date(task1.startDate!).getTime()
const end1 = new Date(task1.endDate!).getTime()
const start2 = new Date(task2.startDate!).getTime()
const end2 = new Date(task2.endDate!).getTime()
// 判断时间是否重叠
if (start1 < end2 && start2 < end1) {
// 有重叠,计算总占比
const percent1 = this.getTaskAllocationPercent(task1)
const percent2 = this.getTaskAllocationPercent(task2)
if (percent1 + percent2 > 100) {
return true // 超负荷
}
}
}
}
return false
}
/**
* v1.9.0 获取任务中当前资源的投入占比
* @param task 任务对象
* @returns 占比百分比 (20-100)默认100
*/
private getTaskAllocationPercent(task: any): number {
if (!task.resources || !Array.isArray(task.resources)) {
return 100 // 未配置resources时默认100%
}
const allocation = task.resources.find((r: any) => r.id === this.id)
if (!allocation) {
return 100 // 未找到当前资源的分配信息默认100%
}
const percent = allocation.percent ?? 100
return Math.max(20, Math.min(100, percent)) // 限制范围 20-100
}
}

View File

@@ -1,3 +1,13 @@
/**
* 资源分配信息 (Resource Allocation)
* @version 1.9.0
*/
export interface ResourceAllocation {
id: string | number // 资源ID
name: string // 资源名称
percent?: number // 投入精力占比 (20-100)默认100
}
// Task 类型定义
export interface Task {
id: number
@@ -31,6 +41,8 @@ export interface Task {
isEditable?: boolean // 是否可编辑可拖拽、拉伸默认为true
// 自定义样式
barColor?: string // 自定义TaskBar颜色如 '#ff5733',若不设置则使用默认颜色方案
// v1.9.0 资源占用比例
resources?: ResourceAllocation[] // 资源分配列表,包含占比信息
// 支持自定义属性 - 使用 unknown 允许任意类型
[key: string]: unknown
}

View File

@@ -0,0 +1,176 @@
import type { Task } from '../models/classes/Task'
/**
* v1.9.0 任务布局工具函数
* 用于处理资源视图下的任务换行布局
*/
/**
* 检测两个任务是否存在时间交集
*/
export function hasTimeOverlap(task1: Task, task2: Task): boolean {
// 获取任务的开始和结束日期
const start1 = task1.startDate ? new Date(task1.startDate).getTime() : null
const end1 = task1.endDate ? new Date(task1.endDate).getTime() : null
const start2 = task2.startDate ? new Date(task2.startDate).getTime() : null
const end2 = task2.endDate ? new Date(task2.endDate).getTime() : null
// 如果任一任务缺少日期信息,视为无交集
if (!start1 || !end1 || !start2 || !end2) {
return false
}
// 检测时间交集task1的结束时间 > task2的开始时间 && task1的开始时间 < task2的结束时间
return end1 > start2 && start1 < end2
}
/**
* 获取任务的资源占比(用于高度计算)
*/
function getTaskResourcePercent(task: Task, resourceId?: string | number): number {
if (!resourceId || !task.resources || task.resources.length === 0) {
return 100
}
const allocation = task.resources.find((r: any) => r.id === resourceId)
if (allocation && allocation.percent !== undefined) {
return Math.max(20, Math.min(100, allocation.percent))
}
return 100
}
/**
* 为任务列表分配行索引,并计算每行高度
* 使用贪心算法:尽可能将任务放在第一个不冲突的行
* @param tasks 任务列表
* @param resourceId 资源ID用于获取占比
* @param baseRowHeight 基础行高默认51
* @returns { taskRowMap, rowHeights, totalHeight }
*/
export function assignTaskRows(
tasks: Task[],
resourceId?: string | number,
baseRowHeight: number = 51
): {
taskRowMap: Map<string | number, number>
rowHeights: number[]
totalHeight: number
} {
const taskRowMap = new Map<string | number, number>()
const rowHeights: number[] = []
if (!tasks || tasks.length === 0) {
return { taskRowMap, rowHeights, totalHeight: baseRowHeight }
}
// 按开始时间排序任务
const sortedTasks = [...tasks].sort((a, b) => {
const timeA = a.startDate ? new Date(a.startDate).getTime() : 0
const timeB = b.startDate ? new Date(b.startDate).getTime() : 0
return timeA - timeB
})
// 记录每一行的所有任务
const rowTasks: Task[][] = []
for (const task of sortedTasks) {
// 查找第一个不冲突的行
let assignedRow = -1
for (let rowIndex = 0; rowIndex < rowTasks.length; rowIndex++) {
const tasksInRow = rowTasks[rowIndex]
// 检查当前任务是否与该行的所有任务都不冲突
let hasConflict = false
for (const taskInRow of tasksInRow) {
if (hasTimeOverlap(task, taskInRow)) {
hasConflict = true
break
}
}
// 如果与该行所有任务都不冲突,可以放在这一行
if (!hasConflict) {
assignedRow = rowIndex
rowTasks[rowIndex].push(task)
break
}
}
// 如果没有找到合适的行,创建新行
if (assignedRow === -1) {
assignedRow = rowTasks.length
rowTasks.push([task])
}
taskRowMap.set(task.id, assignedRow)
}
// 计算每行的高度
const baseTaskBarHeight = baseRowHeight - 10 // 基础TaskBar高度
for (let i = 0; i < rowTasks.length; i++) {
const tasksInRow = rowTasks[i]
// 找到该行中最大的TaskBar高度
let maxTaskBarHeight = 0
for (const task of tasksInRow) {
const percent = getTaskResourcePercent(task, resourceId)
const taskBarHeight = baseTaskBarHeight * (percent / 100)
maxTaskBarHeight = Math.max(maxTaskBarHeight, taskBarHeight)
}
// 计算该行的容器高度
// 第一行padding-top(5px) + maxTaskBarHeight + padding-bottom(5px)
// 后续行maxTaskBarHeight + padding-bottom(5px)
const rowHeight = i === 0
? 5 + maxTaskBarHeight + 5
: maxTaskBarHeight + 5
rowHeights.push(rowHeight)
}
// 计算总高度
const totalHeight = rowHeights.reduce((sum, h) => sum + h, 0)
return { taskRowMap, rowHeights, totalHeight }
}
/**
* 计算任务列表需要的最大行数
*/
export function calculateMaxRows(tasks: Task[], resourceId?: string | number): number {
const result = assignTaskRows(tasks, resourceId)
if (result.taskRowMap.size === 0) {
return 1
}
return Math.max(...Array.from(result.taskRowMap.values())) + 1
}
/**
* 为资源视图的每个资源计算任务行布局
* @returns 资源ID到其任务行映射的映射 { resourceId: { taskRowMap, rowHeights, totalHeight } }
*/
export function calculateResourceTaskLayout(
tasks: Task[],
currentResourceId?: string | number,
baseRowHeight: number = 51
): Map<string | number, { taskRowMap: Map<string | number, number>, rowHeights: number[], totalHeight: number }> {
const resourceLayoutMap = new Map<string | number, { taskRowMap: Map<string | number, number>, rowHeights: number[], totalHeight: number }>()
if (!currentResourceId) {
return resourceLayoutMap
}
// 筛选属于当前资源的任务
const resourceTasks = tasks.filter(task => {
if (!task.resources || task.resources.length === 0) {
return false
}
return task.resources.some(r => r.id === currentResourceId)
})
// 为当前资源的任务分配行
const result = assignTaskRows(resourceTasks, currentResourceId, baseRowHeight)
resourceLayoutMap.set(currentResourceId, result)
return resourceLayoutMap
}