v1.9.0 - 事件交叉Taskbar展示优化
This commit is contained in:
47
demo/App.vue
47
demo/App.vue
@@ -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;">
|
||||
|
||||
173
demo/data-resources-with-percent.json
Normal file
173
demo/data-resources-with-percent.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 删除事件
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
176
src/utils/taskLayoutUtils.ts
Normal file
176
src/utils/taskLayoutUtils.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user