v1.9.0 - Add resource view
This commit is contained in:
426
demo/App.vue
426
demo/App.vue
@@ -5,6 +5,7 @@ import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import MilestoneDialog from '../src/components/MilestoneDialog.vue'
|
||||
import normalData from './data.json'
|
||||
import largeData from './data-large-1m.json'
|
||||
import resourcesData from './data-resources.json'
|
||||
import packageInfo from '../package.json'
|
||||
// 导入主题变量
|
||||
import '../src/styles/theme-variables.css'
|
||||
@@ -15,7 +16,9 @@ import { useI18n } from '../src/composables/useI18n'
|
||||
import { useDemoLocale } from './useDemoLocale'
|
||||
import { getPredecessorIds, predecessorIdsToString } from '../src/utils/predecessorUtils'
|
||||
import type { Task } from '../src/models/Task'
|
||||
import { Resource } from '../src/models/classes/Resource'
|
||||
import type { TaskListConfig, TaskListColumnConfig } from '../src/models/configs/TaskListConfig'
|
||||
import type { ResourceListConfig } from '../src/models/configs/ResourceListConfig'
|
||||
import type { TaskBarConfig } from '../src/models/configs/TaskBarConfig'
|
||||
|
||||
const { showMessage } = useMessage()
|
||||
@@ -33,6 +36,8 @@ const gantt = ref<InstanceType<typeof import('../src/components/GanttChart.vue')
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const milestones = ref<Task[]>([])
|
||||
const resources = ref<Resource[]>([])
|
||||
const viewMode = ref<'task' | 'resource'>('task')
|
||||
|
||||
const rawDataSources = [
|
||||
{
|
||||
@@ -80,6 +85,56 @@ const applyDataSource = (source: RawDataSource) => {
|
||||
const payload = source.payload as { tasks?: Task[]; milestones?: Task[] }
|
||||
tasks.value = cloneData(payload.tasks ?? [])
|
||||
milestones.value = cloneData(payload.milestones ?? [])
|
||||
|
||||
// 从独立的资源数据文件加载资源数据
|
||||
const resourcePayload = resourcesData as { resources?: any[] }
|
||||
if (resourcePayload.resources) {
|
||||
resources.value = resourcePayload.resources.map(resData => {
|
||||
// 使用Resource类创建资源实例
|
||||
return new Resource({
|
||||
id: resData.id,
|
||||
name: resData.name,
|
||||
type: resData.type,
|
||||
avatar: resData.avatar,
|
||||
description: resData.description,
|
||||
department: resData.department,
|
||||
skills: resData.skills,
|
||||
utilization: resData.utilization,
|
||||
tasks: resData.tasks || []
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 向后兼容:如果没有独立的资源数据,从任务中生成
|
||||
const resourceMap = new Map<string, Resource>()
|
||||
tasks.value.forEach(task => {
|
||||
if (task.assignee && !resourceMap.has(task.assignee)) {
|
||||
resourceMap.set(task.assignee, new Resource({
|
||||
id: task.assignee,
|
||||
name: task.assignee,
|
||||
type: 'user',
|
||||
avatar: task.assigneeAvatar || undefined,
|
||||
tasks: []
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// 将任务分配到对应资源
|
||||
tasks.value.forEach(task => {
|
||||
if (task.assignee) {
|
||||
const resource = resourceMap.get(task.assignee)
|
||||
if (resource) {
|
||||
resource.addTask(task)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 计算每个资源的利用率
|
||||
resourceMap.forEach(resource => {
|
||||
resource.updateUtilization()
|
||||
})
|
||||
|
||||
resources.value = Array.from(resourceMap.values())
|
||||
}
|
||||
}
|
||||
|
||||
const getSourceTexts = (key: DataSourceKey) => {
|
||||
@@ -216,12 +271,81 @@ const taskListConfig = computed<TaskListConfig>(() => ({
|
||||
widthUnit.value === '%' ? `${widthPercentage.value.maxWidth}%` : taskListWidth.value.maxWidth,
|
||||
}))
|
||||
|
||||
// 资源列表配置
|
||||
const resourceListConfig = computed<ResourceListConfig>(() => ({
|
||||
columns: [
|
||||
{
|
||||
key: 'name',
|
||||
label: '资源名称',
|
||||
visible: true,
|
||||
width: 150,
|
||||
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: '利用率',
|
||||
visible: true,
|
||||
width: 100,
|
||||
formatter: (resource: Resource) => {
|
||||
if (resource.utilization != null) {
|
||||
return `${resource.utilization}%`
|
||||
}
|
||||
// 简单计算:基于任务数量的利用率
|
||||
const taskCount = resource.tasks?.length || 0
|
||||
const utilizationPercent = Math.min(taskCount * 20, 100) // 假设每个任务占20%
|
||||
return `${utilizationPercent}%`
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultWidth:
|
||||
widthUnit.value === '%'
|
||||
? `${widthPercentage.value.defaultWidth}%`
|
||||
: taskListWidth.value.defaultWidth,
|
||||
minWidth:
|
||||
widthUnit.value === '%' ? `${widthPercentage.value.minWidth}%` : taskListWidth.value.minWidth,
|
||||
maxWidth:
|
||||
widthUnit.value === '%' ? `${widthPercentage.value.maxWidth}%` : taskListWidth.value.maxWidth,
|
||||
}))
|
||||
|
||||
|
||||
// 控制是否允许拖拽和拉伸
|
||||
const allowDragAndResize = ref(true)
|
||||
|
||||
// 控制是否启用TaskRow拖拽移动
|
||||
const enableTaskRowMove = ref(true)
|
||||
|
||||
// v1.9.0 资源视图垂直拖拽确认对话框
|
||||
const resourceDragConfirmVisible = ref(false)
|
||||
const resourceDragData = ref<{
|
||||
task: Task | null
|
||||
sourceResourceIndex: number
|
||||
targetResourceIndex: number
|
||||
targetResource: Resource | null
|
||||
newStartDate?: string
|
||||
newEndDate?: string
|
||||
}>({
|
||||
task: null,
|
||||
sourceResourceIndex: -1,
|
||||
targetResourceIndex: -1,
|
||||
targetResource: null,
|
||||
newStartDate: undefined,
|
||||
newEndDate: undefined,
|
||||
})
|
||||
|
||||
// 指派人员选项列表
|
||||
const assigneeOptions = ref([
|
||||
{ value: 'zhangsan', label: '张三' },
|
||||
@@ -922,6 +1046,96 @@ const handleTaskRowMoved = async (payload: {
|
||||
// }
|
||||
}
|
||||
|
||||
// v1.9.0 处理资源视图垂直拖拽结束事件
|
||||
const handleResourceDragEnd = (event: {
|
||||
task: Task
|
||||
sourceResourceIndex: number
|
||||
targetResourceIndex: number
|
||||
targetResource: Resource
|
||||
newStartDate?: string
|
||||
newEndDate?: string
|
||||
}) => {
|
||||
// 保存拖拽数据并显示确认对话框
|
||||
resourceDragData.value = {
|
||||
task: event.task,
|
||||
sourceResourceIndex: event.sourceResourceIndex,
|
||||
targetResourceIndex: event.targetResourceIndex,
|
||||
targetResource: event.targetResource,
|
||||
newStartDate: event.newStartDate,
|
||||
newEndDate: event.newEndDate,
|
||||
}
|
||||
resourceDragConfirmVisible.value = true
|
||||
}
|
||||
|
||||
// 确认资源分配
|
||||
const confirmResourceDrag = () => {
|
||||
const { task, sourceResourceIndex, targetResourceIndex, targetResource, newStartDate, newEndDate } = resourceDragData.value
|
||||
if (!task || !targetResource) return
|
||||
|
||||
// v1.9.0 跨行拖拽时需要更新任务的日期,使任务显示在正确的时间位置
|
||||
if (newStartDate && newEndDate) {
|
||||
task.startDate = newStartDate
|
||||
task.endDate = newEndDate
|
||||
}
|
||||
|
||||
// 从源资源中移除任务
|
||||
const sourceResource = resources.value[sourceResourceIndex]
|
||||
if (sourceResource && sourceResource.tasks) {
|
||||
const taskIndex = sourceResource.tasks.findIndex(t => t.id === task.id)
|
||||
if (taskIndex !== -1) {
|
||||
sourceResource.tasks.splice(taskIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加任务到目标资源
|
||||
const destResource = resources.value[targetResourceIndex]
|
||||
if (destResource) {
|
||||
if (!destResource.tasks) {
|
||||
destResource.tasks = []
|
||||
}
|
||||
destResource.tasks.push(task)
|
||||
}
|
||||
|
||||
// 显示资源分配和日期更新信息
|
||||
const dateInfo = newStartDate && newEndDate ? `,日期已调整为 ${newStartDate} ~ ${newEndDate}` : ''
|
||||
showMessage(`任务 "${task.name}" 已分配给资源 "${targetResource.name}"${dateInfo}`, 'success')
|
||||
resourceDragConfirmVisible.value = false
|
||||
|
||||
// 重置拖拽数据
|
||||
resourceDragData.value = {
|
||||
task: null,
|
||||
sourceResourceIndex: -1,
|
||||
targetResourceIndex: -1,
|
||||
targetResource: null,
|
||||
newStartDate: undefined,
|
||||
newEndDate: undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 取消资源分配
|
||||
const cancelResourceDrag = () => {
|
||||
resourceDragConfirmVisible.value = false
|
||||
|
||||
// v1.9.0 通知TaskBar清除临时数据,恢复到原始位置
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('resource-drag-cancel', {
|
||||
detail: {
|
||||
taskId: resourceDragData.value.task?.id
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 重置拖拽数据
|
||||
resourceDragData.value = {
|
||||
task: null,
|
||||
sourceResourceIndex: -1,
|
||||
targetResourceIndex: -1,
|
||||
targetResource: null,
|
||||
newStartDate: undefined,
|
||||
newEndDate: undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义右键菜单操作处理
|
||||
const handleCustomMenuAction = (action: string, task: Task) => {
|
||||
showMessage(`自定义操作: ${action} - 任务: ${task.name}`, 'info', { closable: true })
|
||||
@@ -1863,6 +2077,9 @@ const handleCustomMenuAction = (action: string, task: Task) => {
|
||||
ref="gantt"
|
||||
:tasks="tasks"
|
||||
:milestones="milestones"
|
||||
:resources="resources"
|
||||
:view-mode="viewMode"
|
||||
:resource-list-config="resourceListConfig"
|
||||
:locale="controlMode === 'props' ? propsLocale : undefined"
|
||||
:theme="controlMode === 'props' ? propsTheme : undefined"
|
||||
:time-scale="controlMode === 'props' ? propsTimeScale : undefined"
|
||||
@@ -1910,6 +2127,9 @@ const handleCustomMenuAction = (action: string, task: Task) => {
|
||||
@task-added="handleTaskAddEvent"
|
||||
@task-updated="handleTaskUpdateEvent"
|
||||
@task-row-moved="handleTaskRowMoved"
|
||||
@view-mode-change="mode => viewMode = mode"
|
||||
@resource-click="resource => showMessage(`资源点击: ${resource.name}`, 'info')"
|
||||
@resource-drag-end="handleResourceDragEnd"
|
||||
>
|
||||
<!-- 自定义任务名称内容 (TaskRow 和 TaskBar) -->
|
||||
<template #custom-task-content="item">
|
||||
@@ -2107,10 +2327,139 @@ const handleCustomMenuAction = (action: string, task: Task) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- v1.9.0 资源拖拽确认对话框 -->
|
||||
<div v-if="resourceDragConfirmVisible" class="modal-overlay" @click.self="cancelResourceDrag">
|
||||
<div class="resource-drag-dialog">
|
||||
<div class="resource-drag-dialog-header">
|
||||
<h3>确认资源分配</h3>
|
||||
</div>
|
||||
<div class="resource-drag-dialog-body">
|
||||
<p v-if="resourceDragData.task && resourceDragData.targetResource">
|
||||
将任务 <strong>"{{ resourceDragData.task.name }}"</strong> 分配给资源
|
||||
<strong>"{{ resourceDragData.targetResource.name }}"</strong>?
|
||||
</p>
|
||||
<div v-if="resourceDragData.task" class="task-info">
|
||||
<p>原开始日期:{{ resourceDragData.task.startDate }}</p>
|
||||
<p>原结束日期:{{ resourceDragData.task.endDate }}</p>
|
||||
<template v-if="resourceDragData.newStartDate && resourceDragData.newEndDate">
|
||||
<p style="color: #409eff; font-weight: 600; margin-top: 8px;">新开始日期:{{ resourceDragData.newStartDate }}</p>
|
||||
<p style="color: #409eff; font-weight: 600;">新结束日期:{{ resourceDragData.newEndDate }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-drag-dialog-footer">
|
||||
<button class="cancel-button" @click="cancelResourceDrag">取消</button>
|
||||
<button class="confirm-button" @click="confirmResourceDrag">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 蒙版层 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
/* 资源拖拽确认对话框 */
|
||||
.resource-drag-dialog {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 400px;
|
||||
max-width: 500px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resource-drag-dialog-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.resource-drag-dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.resource-drag-dialog-body {
|
||||
padding: 20px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.resource-drag-dialog-body p {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.resource-drag-dialog-body strong {
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.resource-drag-dialog-body .task-info {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.resource-drag-dialog-body .task-info p {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.resource-drag-dialog-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resource-drag-dialog-footer button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background: #fff;
|
||||
border: 1px solid #dcdfe6;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background: #f5f7fa;
|
||||
border-color: #c0c4cc;
|
||||
}
|
||||
|
||||
.confirm-button {
|
||||
background: #409eff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.confirm-button:hover {
|
||||
background: #66b1ff;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
@@ -3443,6 +3792,83 @@ const handleCustomMenuAction = (action: string, task: Task) => {
|
||||
background: var(--gantt-primary-color-active, #3a8ee6);
|
||||
}
|
||||
|
||||
/* v1.9.0 资源拖拽确认对话框样式 */
|
||||
.resource-drag-dialog {
|
||||
position: relative;
|
||||
width: 500px;
|
||||
max-width: 90vw;
|
||||
background: var(--gantt-bg-primary, #ffffff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.resource-drag-dialog-header {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--gantt-border-color, #e4e7ed);
|
||||
}
|
||||
|
||||
.resource-drag-dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--gantt-text-primary, #303133);
|
||||
}
|
||||
|
||||
.resource-drag-dialog-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.resource-drag-dialog-body p {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--gantt-text-primary, #303133);
|
||||
}
|
||||
|
||||
.resource-drag-dialog-body .task-info {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: var(--gantt-bg-secondary, #f5f7fa);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.resource-drag-dialog-body .task-info p {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
color: var(--gantt-text-secondary, #606266);
|
||||
}
|
||||
|
||||
.resource-drag-dialog-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--gantt-border-color, #e4e7ed);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
padding: 8px 20px;
|
||||
background: transparent;
|
||||
color: var(--gantt-text-primary, #303133);
|
||||
border: 1px solid var(--gantt-border-color, #dcdfe6);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
color: var(--gantt-primary-color, #409eff);
|
||||
border-color: var(--gantt-primary-color, #409eff);
|
||||
background: var(--gantt-primary-light-9, #ecf5ff);
|
||||
}
|
||||
|
||||
/* 暗黑模式适配 */
|
||||
:global(html[data-theme='dark']) .task-click-dialog {
|
||||
background: var(--gantt-bg-primary, #1a1a1a);
|
||||
|
||||
244
demo/data-resources.json
Normal file
244
demo/data-resources.json
Normal file
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"resources": [
|
||||
{
|
||||
"id": "resource-chen",
|
||||
"name": "首席研究员 Dr. Chen",
|
||||
"type": "研究员",
|
||||
"avatar": "https://i.pravatar.cc/150?img=1",
|
||||
"department": "临床研究部",
|
||||
"skills": ["临床试验管理", "项目规划", "团队领导"],
|
||||
"utilization": 85,
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1000,
|
||||
"name": "新药ADX-2024临床试验项目",
|
||||
"startDate": "2025-01-01",
|
||||
"endDate": "2027-12-31",
|
||||
"progress": 15,
|
||||
"type": "story"
|
||||
},
|
||||
{
|
||||
"id": 5001,
|
||||
"name": "研究方案评审",
|
||||
"startDate": "2025-03-01",
|
||||
"endDate": "2025-03-15",
|
||||
"progress": 100,
|
||||
"type": "task"
|
||||
},
|
||||
{
|
||||
"id": 5002,
|
||||
"name": "数据安全监察会议",
|
||||
"startDate": "2025-06-10",
|
||||
"endDate": "2025-06-12",
|
||||
"progress": 80,
|
||||
"type": "task"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "resource-wang",
|
||||
"name": "I期试验主任 Dr. Wang",
|
||||
"type": "试验主任",
|
||||
"avatar": "https://i.pravatar.cc/150?img=2",
|
||||
"department": "I期临床研究组",
|
||||
"skills": ["I期试验设计", "药代动力学", "安全性评估"],
|
||||
"utilization": 92,
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1100,
|
||||
"name": "I期临床试验",
|
||||
"startDate": "2025-01-01",
|
||||
"endDate": "2025-08-31",
|
||||
"progress": 65,
|
||||
"type": "story"
|
||||
},
|
||||
{
|
||||
"id": 5003,
|
||||
"name": "受试者招募",
|
||||
"startDate": "2025-03-01",
|
||||
"endDate": "2025-04-30",
|
||||
"progress": 100,
|
||||
"type": "task"
|
||||
},
|
||||
{
|
||||
"id": 5004,
|
||||
"name": "给药与观察",
|
||||
"startDate": "2025-05-01",
|
||||
"endDate": "2025-07-31",
|
||||
"progress": 45,
|
||||
"type": "task"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "resource-li",
|
||||
"name": "方案设计师 李明",
|
||||
"type": "方案设计师",
|
||||
"avatar": "https://i.pravatar.cc/150?img=3",
|
||||
"department": "临床研究部",
|
||||
"skills": ["方案设计", "伦理审查", "监管申报"],
|
||||
"utilization": 75,
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1101,
|
||||
"name": "试验方案设计与伦理审查",
|
||||
"startDate": "2025-01-01",
|
||||
"endDate": "2025-02-28",
|
||||
"progress": 100,
|
||||
"type": "task"
|
||||
},
|
||||
{
|
||||
"id": 5005,
|
||||
"name": "II期方案准备",
|
||||
"startDate": "2025-07-01",
|
||||
"endDate": "2025-08-15",
|
||||
"progress": 30,
|
||||
"type": "task"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "resource-zhang",
|
||||
"name": "数据分析师 张伟",
|
||||
"type": "数据分析师",
|
||||
"avatar": "https://i.pravatar.cc/150?img=4",
|
||||
"department": "数据管理与统计部",
|
||||
"skills": ["统计分析", "数据可视化", "报告撰写"],
|
||||
"utilization": 68,
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1103,
|
||||
"name": "数据收集与分析",
|
||||
"startDate": "2025-05-01",
|
||||
"endDate": "2025-08-15",
|
||||
"progress": 50,
|
||||
"type": "task"
|
||||
},
|
||||
{
|
||||
"id": 5006,
|
||||
"name": "中期数据报告",
|
||||
"startDate": "2025-06-15",
|
||||
"endDate": "2025-07-01",
|
||||
"progress": 70,
|
||||
"type": "task"
|
||||
},
|
||||
{
|
||||
"id": 5007,
|
||||
"name": "统计模型优化",
|
||||
"startDate": "2025-07-15",
|
||||
"endDate": "2025-08-10",
|
||||
"progress": 25,
|
||||
"type": "task"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "resource-liu",
|
||||
"name": "质量保证专员 刘芳",
|
||||
"type": "质量保证",
|
||||
"avatar": "https://i.pravatar.cc/150?img=5",
|
||||
"department": "质量保证部",
|
||||
"skills": ["GCP审计", "质量控制", "SOP管理"],
|
||||
"utilization": 88,
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1104,
|
||||
"name": "I期总结报告",
|
||||
"startDate": "2025-08-15",
|
||||
"endDate": "2025-08-31",
|
||||
"progress": 20,
|
||||
"type": "task"
|
||||
},
|
||||
{
|
||||
"id": 5008,
|
||||
"name": "GCP合规性审计",
|
||||
"startDate": "2025-04-01",
|
||||
"endDate": "2025-04-15",
|
||||
"progress": 100,
|
||||
"type": "task"
|
||||
},
|
||||
{
|
||||
"id": 5009,
|
||||
"name": "文档质量检查",
|
||||
"startDate": "2025-07-01",
|
||||
"endDate": "2025-07-31",
|
||||
"progress": 60,
|
||||
"type": "task"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "resource-zhao",
|
||||
"name": "II期试验协调员 赵敏",
|
||||
"type": "试验协调员",
|
||||
"avatar": "https://i.pravatar.cc/150?img=6",
|
||||
"department": "II期临床研究组",
|
||||
"skills": ["试验协调", "中心管理", "受试者跟踪"],
|
||||
"utilization": 55,
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1200,
|
||||
"name": "II期临床试验",
|
||||
"startDate": "2025-09-01",
|
||||
"endDate": "2026-12-31",
|
||||
"progress": 8,
|
||||
"type": "story"
|
||||
},
|
||||
{
|
||||
"id": 5010,
|
||||
"name": "研究中心筛选",
|
||||
"startDate": "2025-08-01",
|
||||
"endDate": "2025-08-31",
|
||||
"progress": 90,
|
||||
"type": "task"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "resource-wu",
|
||||
"name": "设备管理员 吴强",
|
||||
"type": "设备管理",
|
||||
"avatar": "https://i.pravatar.cc/150?img=7",
|
||||
"department": "设备与后勤部",
|
||||
"skills": ["设备维护", "库存管理", "供应链协调"],
|
||||
"utilization": 42,
|
||||
"tasks": [
|
||||
{
|
||||
"id": 5011,
|
||||
"name": "试验设备采购",
|
||||
"startDate": "2025-02-01",
|
||||
"endDate": "2025-02-28",
|
||||
"progress": 100,
|
||||
"type": "task"
|
||||
},
|
||||
{
|
||||
"id": 5012,
|
||||
"name": "设备校准与维护",
|
||||
"startDate": "2025-05-15",
|
||||
"endDate": "2025-05-20",
|
||||
"progress": 100,
|
||||
"type": "task"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "resource-huang",
|
||||
"name": "III期项目经理 黄建国",
|
||||
"type": "项目经理",
|
||||
"avatar": "https://i.pravatar.cc/150?img=8",
|
||||
"department": "III期临床研究组",
|
||||
"skills": ["大型试验管理", "多中心协调", "里程碑管理"],
|
||||
"utilization": 35,
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1300,
|
||||
"name": "III期临床试验",
|
||||
"startDate": "2026-01-01",
|
||||
"endDate": "2027-12-31",
|
||||
"progress": 2,
|
||||
"type": "story"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jordium-gantt-vue3",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"type": "module",
|
||||
"main": "npm-package/dist/jordium-gantt-vue3.cjs.js",
|
||||
"module": "npm-package/dist/jordium-gantt-vue3.es.js",
|
||||
|
||||
@@ -13,10 +13,12 @@ import jsPDF from 'jspdf'
|
||||
import html2canvas from 'html2canvas'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
import type { Milestone } from '../models/classes/Milestone'
|
||||
import type { Resource } from '../models/classes/Resource'
|
||||
import { useTaskListContextMenu } from './TaskList/composables/taskList/useTaskListContextMenu'
|
||||
import { useTaskBarContextMenu } from './Timeline/composables/useTaskBarContextMenu'
|
||||
import type { ToolbarConfig } from '../models/configs/ToolbarConfig'
|
||||
import type { TaskListConfig } from '../models/configs/TaskListConfig'
|
||||
import type { ResourceListConfig } from '../models/configs/ResourceListConfig'
|
||||
import {
|
||||
DEFAULT_TASK_LIST_WIDTH,
|
||||
DEFAULT_TASK_LIST_MIN_WIDTH,
|
||||
@@ -30,6 +32,8 @@ import { useMessage } from '../composables/useMessage'
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
tasks: () => [],
|
||||
milestones: () => [],
|
||||
resources: () => [],
|
||||
viewMode: 'task',
|
||||
useDefaultDrawer: true,
|
||||
useDefaultMilestoneDialog: true,
|
||||
toolbarConfig: () => ({}),
|
||||
@@ -48,6 +52,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
afternoon: { start: 13, end: 17 },
|
||||
}),
|
||||
taskListConfig: undefined,
|
||||
resourceListConfig: undefined,
|
||||
taskListColumnRenderMode: 'default',
|
||||
taskBarConfig: undefined,
|
||||
autoSortByStartDate: false,
|
||||
@@ -96,11 +101,34 @@ const emit = defineEmits([
|
||||
'add-successor',
|
||||
// TaskRow拖拽事件
|
||||
'task-row-moved',
|
||||
// v1.9.0 资源视图事件
|
||||
'view-mode-change', // 视图模式切换事件
|
||||
'resource-click', // 资源行点击事件
|
||||
'taskbar-resource-change', // 任务跨资源移动事件
|
||||
'resource-drag-end', // v1.9.0 资源视图垂直拖拽结束事件
|
||||
])
|
||||
|
||||
const { showMessage } = useMessage()
|
||||
const slots = useSlots()
|
||||
|
||||
// v1.9.0 视图模式状态管理
|
||||
const currentViewMode = ref<'task' | 'resource'>(props.viewMode || 'task')
|
||||
|
||||
// 根据视图模式计算当前使用的数据源
|
||||
const currentDataSource = computed(() => {
|
||||
return currentViewMode.value === 'resource' ? props.resources : props.tasks
|
||||
})
|
||||
|
||||
// 根据视图模式计算当前使用的列表配置
|
||||
const currentListConfig = computed(() => {
|
||||
return currentViewMode.value === 'resource' ? props.resourceListConfig : props.taskListConfig
|
||||
})
|
||||
|
||||
// 提供视图模式和数据源给子组件
|
||||
provide('gantt-view-mode', currentViewMode)
|
||||
provide('gantt-data-source', currentDataSource)
|
||||
provide('gantt-list-config', currentListConfig)
|
||||
|
||||
// 提供 slots 给子组件(TaskList 和 TaskRow)
|
||||
provide('gantt-column-slots', slots)
|
||||
|
||||
@@ -132,6 +160,10 @@ interface Props {
|
||||
tasks?: Task[]
|
||||
// 里程碑数据
|
||||
milestones?: Task[]
|
||||
// v1.9.0 资源数据(资源计划视图使用)
|
||||
resources?: Resource[]
|
||||
// v1.9.0 视图模式:'task' 任务计划视图 | 'resource' 资源计划视图
|
||||
viewMode?: 'task' | 'resource'
|
||||
// 是否使用默认的TaskDrawer
|
||||
useDefaultDrawer?: boolean
|
||||
// 是否使用默认的MilestoneDialog
|
||||
@@ -170,6 +202,8 @@ interface Props {
|
||||
}
|
||||
// 任务列表配置
|
||||
taskListConfig?: TaskListConfig
|
||||
// v1.9.0 资源列表配置(资源计划视图使用)
|
||||
resourceListConfig?: ResourceListConfig
|
||||
// 任务列表列渲染模式:'default' 使用 taskListConfig.columns 配置,'declarative' 使用声明式 <task-list-column> 标签
|
||||
taskListColumnRenderMode?: 'default' | 'declarative'
|
||||
// TaskBar 配置
|
||||
@@ -398,6 +432,24 @@ watch(
|
||||
// 时间刻度状态
|
||||
const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
|
||||
|
||||
// v1.9.0 视图模式切换处理函数
|
||||
const handleViewModeChange = (newMode: 'task' | 'resource') => {
|
||||
if (currentViewMode.value !== newMode) {
|
||||
currentViewMode.value = newMode
|
||||
emit('view-mode-change', newMode)
|
||||
}
|
||||
}
|
||||
|
||||
// v1.9.0 响应式监听viewMode prop变化
|
||||
watch(
|
||||
() => props.viewMode,
|
||||
newMode => {
|
||||
if (newMode && currentViewMode.value !== newMode) {
|
||||
currentViewMode.value = newMode
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 计算是否显示关闭按钮
|
||||
const showCloseButton = computed(() => {
|
||||
const taskId = timelineRef.value?.highlightedTaskId
|
||||
@@ -2658,6 +2710,11 @@ function handleLinkDeleted(event: {
|
||||
emit('link-deleted', event)
|
||||
}
|
||||
|
||||
// v1.9.0 资源视图垂直拖拽结束事件
|
||||
function handleResourceDragEnd(event: { task: Task; sourceResourceIndex: number; targetResourceIndex: number; targetResource: Resource }) {
|
||||
emit('resource-drag-end', event)
|
||||
}
|
||||
|
||||
// 处理里程碑双击事件
|
||||
function handleMilestoneDoubleClick(milestone: Milestone) {
|
||||
// 先触发外部事件,让外部可以自定义处理
|
||||
@@ -2742,6 +2799,7 @@ defineExpose({
|
||||
:theme="currentThemeMode"
|
||||
:fullscreen="isFullscreen"
|
||||
:expand-all="getIsExpandAll()"
|
||||
:view-mode="currentViewMode"
|
||||
:on-today-locate="todayLocateHandler"
|
||||
:on-export-csv="csvExportHandler"
|
||||
:on-export-pdf="pdfExportHandler"
|
||||
@@ -2751,10 +2809,12 @@ defineExpose({
|
||||
:on-time-scale-change="handleTimeScaleChange"
|
||||
:on-expand-all="handleExpandAll"
|
||||
:on-collapse-all="handleCollapseAll"
|
||||
:on-view-mode-change="handleViewModeChange"
|
||||
@add-task="handleToolbarAddTask"
|
||||
@add-milestone="milestoneAddHandler"
|
||||
@expand-all="handleExpandAll"
|
||||
@collapse-all="handleCollapseAll"
|
||||
@view-mode-change="handleViewModeChange"
|
||||
/>
|
||||
|
||||
<!-- 甘特图主体 -->
|
||||
@@ -2848,6 +2908,7 @@ defineExpose({
|
||||
@successor-added="handleSuccessorAdded"
|
||||
@delete="handleTaskDelete"
|
||||
@link-deleted="handleLinkDeleted"
|
||||
@resource-drag-end="handleResourceDragEnd"
|
||||
>
|
||||
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
|
||||
<slot name="custom-task-content" v-bind="barScope" />
|
||||
|
||||
@@ -28,7 +28,7 @@ interface Props {
|
||||
offsetTop?: number // Canvas 在垂直方向的偏移量(用于虚拟渲染)
|
||||
highlightedTaskId: number | null
|
||||
highlightedTaskIds: Set<number>
|
||||
hoveredTaskId: number | null
|
||||
hoveredTaskId: number | string | null // v1.9.0 支持资源视图中的字符串ID
|
||||
// 月份分隔线配置
|
||||
verticalLines?: VerticalLine[]
|
||||
showVerticalLines?: boolean
|
||||
|
||||
@@ -14,6 +14,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
theme: undefined,
|
||||
fullscreen: undefined,
|
||||
expandAll: undefined,
|
||||
viewMode: 'task', // v1.9.0 默认任务视图
|
||||
onAddTask: undefined,
|
||||
onAddMilestone: undefined,
|
||||
onTodayLocate: undefined,
|
||||
@@ -26,6 +27,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
onTimeScaleChange: undefined,
|
||||
onExpandAll: undefined,
|
||||
onCollapseAll: undefined,
|
||||
onViewModeChange: undefined, // v1.9.0
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -40,6 +42,7 @@ const emit = defineEmits<{
|
||||
'time-scale-change': [scale: TimelineScale]
|
||||
'expand-all': []
|
||||
'collapse-all': []
|
||||
'view-mode-change': [mode: 'task' | 'resource'] // v1.9.0 视图切换事件
|
||||
}>()
|
||||
|
||||
// LocalStorage keys
|
||||
@@ -58,6 +61,7 @@ interface Props {
|
||||
theme?: 'light' | 'dark'
|
||||
fullscreen?: boolean
|
||||
expandAll?: boolean
|
||||
viewMode?: 'task' | 'resource' // v1.9.0 视图模式
|
||||
// 自定义事件处理器
|
||||
onAddTask?: () => void
|
||||
onAddMilestone?: () => void
|
||||
@@ -70,6 +74,7 @@ interface Props {
|
||||
onTimeScaleChange?: (scale: TimelineScale) => void
|
||||
onExpandAll?: () => void
|
||||
onCollapseAll?: () => void
|
||||
onViewModeChange?: (mode: 'task' | 'resource') => void // v1.9.0 视图切换回调
|
||||
// 外部确认接口
|
||||
onSettingsConfirm?: (
|
||||
type: 'theme' | 'language',
|
||||
@@ -97,6 +102,18 @@ const isDarkMode = ref(getInitialTheme())
|
||||
const isFullscreen = ref(false)
|
||||
const showLanguageDropdown = ref(false)
|
||||
const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
|
||||
const currentViewMode = ref<'task' | 'resource'>('task') // v1.9.0 视图模式状态
|
||||
|
||||
// v1.9.0 监听 viewMode prop
|
||||
watch(
|
||||
() => props.viewMode,
|
||||
(newMode) => {
|
||||
if (newMode && newMode !== currentViewMode.value) {
|
||||
currentViewMode.value = newMode
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 如果外部通过 Prop 传入 timeScale,则同步到本地状态
|
||||
watch(
|
||||
@@ -210,6 +227,18 @@ const handleCollapseAll = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.9.0 视图模式切换处理
|
||||
const handleViewModeChange = (mode: 'task' | 'resource') => {
|
||||
if (currentViewMode.value !== mode) {
|
||||
currentViewMode.value = mode
|
||||
if (props.onViewModeChange && typeof props.onViewModeChange === 'function') {
|
||||
props.onViewModeChange(mode)
|
||||
} else {
|
||||
emit('view-mode-change', mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 语言下拉菜单控制
|
||||
const toggleLanguageDropdown = () => {
|
||||
showLanguageDropdown.value = !showLanguageDropdown.value
|
||||
@@ -531,6 +560,58 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- v1.9.0 视图模式切换按钮组 - 使用 Segmented Control 样式 -->
|
||||
<div class="gantt-view-mode-control">
|
||||
<div class="view-mode-track">
|
||||
<div
|
||||
class="view-mode-thumb"
|
||||
:style="{
|
||||
transform: `translateX(${currentViewMode === 'task' ? '0%' : '100%'})`
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<button
|
||||
class="view-mode-item"
|
||||
:class="{ active: currentViewMode === 'task' }"
|
||||
:title="t('taskView') || '任务视图'"
|
||||
@click="handleViewModeChange('task')"
|
||||
>
|
||||
<svg
|
||||
class="gantt-btn-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"></rect>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"></rect>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"></rect>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"></rect>
|
||||
</svg>
|
||||
{{ t('taskView') || '任务视图' }}
|
||||
</button>
|
||||
<button
|
||||
class="view-mode-item"
|
||||
:class="{ active: currentViewMode === 'resource' }"
|
||||
:title="t('resourceView') || '资源视图'"
|
||||
@click="handleViewModeChange('resource')"
|
||||
>
|
||||
<svg
|
||||
class="gantt-btn-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
</svg>
|
||||
{{ t('resourceView') || '资源视图' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 展开/折叠按钮组 -->
|
||||
<div
|
||||
v-if="config.showExpandCollapse !== false"
|
||||
@@ -1269,6 +1350,124 @@ onUnmounted(() => {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
/* v1.9.0 视图模式切换 Segmented Control 样式 */
|
||||
.gantt-view-mode-control {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
background: var(--gantt-bg-primary, #ffffff);
|
||||
border: 1px solid var(--gantt-border-color, #dcdfe6);
|
||||
border-radius: 6px;
|
||||
padding: 1px;
|
||||
margin-right: 12px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.gantt-view-mode-control:hover {
|
||||
border-color: var(--gantt-primary-light, #79bbff);
|
||||
}
|
||||
|
||||
.view-mode-track {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
bottom: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.view-mode-thumb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: var(--gantt-primary, #409eff);
|
||||
border-radius: 5px;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.1),
|
||||
0 1px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.view-mode-item {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
min-width: 100px;
|
||||
z-index: 1;
|
||||
border-radius: 5px;
|
||||
user-select: none;
|
||||
color: var(--gantt-text-primary, #303133);
|
||||
}
|
||||
|
||||
.view-mode-item:hover:not(.active) {
|
||||
color: var(--gantt-primary, #409eff);
|
||||
background: var(--gantt-bg-hover, rgba(64, 158, 255, 0.06));
|
||||
}
|
||||
|
||||
.view-mode-item:active:not(.active) {
|
||||
background: var(--gantt-bg-active, rgba(64, 158, 255, 0.12));
|
||||
}
|
||||
|
||||
.view-mode-item.active {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.view-mode-item .gantt-btn-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
/* 暗黑模式下的视图模式切换样式 */
|
||||
:global(html[data-theme='dark']) .gantt-view-mode-control {
|
||||
background: var(--gantt-bg-secondary, #4b4b4b);
|
||||
border-color: var(--gantt-border-color, #808080);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .gantt-view-mode-control:hover {
|
||||
border-color: var(--gantt-primary, #3399ff);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .view-mode-thumb {
|
||||
background: var(--gantt-primary, #3399ff);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.3),
|
||||
0 1px 6px -1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .view-mode-item {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .view-mode-item:hover:not(.active) {
|
||||
color: var(--gantt-primary, #3399ff);
|
||||
background: rgba(51, 153, 255, 0.12);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .view-mode-item:active:not(.active) {
|
||||
background: rgba(51, 153, 255, 0.2);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .view-mode-item.active {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 暗黑模式下的按钮组样式 */
|
||||
:global(html[data-theme='dark']) .gantt-btn-group {
|
||||
box-shadow:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onUnmounted } from 'vue'
|
||||
import { computed, ref, onUnmounted, inject } from 'vue'
|
||||
|
||||
interface Props {
|
||||
// 触点类型
|
||||
@@ -26,6 +26,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
globalDragging: false,
|
||||
})
|
||||
|
||||
// 注入视图模式,在资源视图下禁用LinkAnchor
|
||||
const viewMode = inject<any>('gantt-view-mode', { value: 'task' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'drag-start': [{ taskId: number; type: 'predecessor' | 'successor'; x: number; y: number }]
|
||||
'drag-move': [{ x: number; y: number }]
|
||||
@@ -37,6 +40,11 @@ const isHoveredAnchor = ref(false)
|
||||
|
||||
// 优化的显示逻辑
|
||||
const shouldShow = computed(() => {
|
||||
// 在资源视图下禁用LinkAnchor
|
||||
if (viewMode.value === 'resource') {
|
||||
return false
|
||||
}
|
||||
|
||||
// 在以下情况显示触点:
|
||||
// 1. TaskBar 被悬停
|
||||
// 2. 全局有拖拽操作进行中(其他任务正在拖拽连接线)
|
||||
|
||||
@@ -10,9 +10,17 @@ import { useI18n } from '../composables/useI18n'
|
||||
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
|
||||
import { DEFAULT_TASK_BAR_CONFIG } from '../models/configs/TaskBarConfig'
|
||||
|
||||
// 禁用自动继承attributes,手动应用到wrapper
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
// 从 GanttChart 注入 enableLinkAnchor 配置
|
||||
const enableLinkAnchor = inject<ComputedRef<boolean>>('enable-link-anchor', computed(() => true))
|
||||
|
||||
// v1.9.0 注入视图模式,用于资源视图的垂直拖拽
|
||||
const viewMode = inject<any>('gantt-view-mode', { value: 'task' })
|
||||
|
||||
interface Props {
|
||||
task: Task
|
||||
rowHeight: number
|
||||
@@ -59,6 +67,8 @@ interface Props {
|
||||
isInvalidLinkTarget?: boolean
|
||||
// 所有任务列表(用于右键菜单删除链接功能)
|
||||
allTasks?: Task[]
|
||||
// v1.9.0 资源视图:是否存在资源冲突(时间重叠)
|
||||
hasResourceConflict?: boolean
|
||||
}
|
||||
|
||||
interface TaskStatus {
|
||||
@@ -258,12 +268,20 @@ const isResizingLeft = ref(false)
|
||||
const isResizingRight = ref(false)
|
||||
const justFinishedDragOrResize = ref(false) // 标记刚刚完成拖拽或调整大小
|
||||
const dragStartX = ref(0)
|
||||
const dragStartY = ref(0) // v1.9.0 用于资源视图垂直拖拽
|
||||
const dragStartLeft = ref(0)
|
||||
const dragStartWidth = ref(0)
|
||||
const resizeStartX = ref(0)
|
||||
const resizeStartWidth = ref(0)
|
||||
const resizeStartLeft = ref(0)
|
||||
|
||||
// v1.9.0 拖拽预览效果(资源视图垂直拖拽)
|
||||
const dragPreviewVisible = ref(false)
|
||||
const dragPreviewPosition = ref({ x: 0, y: 0 })
|
||||
const dragPreviewOffsetX = ref(0) // 鼠标在TaskBar内的X偏移量,用于保持预览对齐
|
||||
const dragEndX = ref(0) // 记录松开鼠标时的X位置,用于计算新日期
|
||||
const tempTaskPixelLeft = ref<number | null>(null) // 资源视图拖拽时的精确像素位置
|
||||
|
||||
// 长按检测状态
|
||||
const longPressTimer = ref<number | null>(null)
|
||||
const longPressTriggered = ref(false)
|
||||
@@ -303,11 +321,13 @@ const nameTextWidth = ref(0)
|
||||
const taskBarStyle = computed(() => {
|
||||
// 季度视图拖拽时使用位置覆盖
|
||||
if (quarterDragOverride.value && props.currentTimeScale === TimelineScale.QUARTER) {
|
||||
const taskBarHeight = props.rowHeight - 10
|
||||
const topOffset = (props.rowHeight - taskBarHeight) / 2
|
||||
return {
|
||||
left: `${quarterDragOverride.value.left ?? 0}px`,
|
||||
width: `${quarterDragOverride.value.width ?? 100}px`,
|
||||
height: `${props.rowHeight - 10}px`,
|
||||
top: '4px',
|
||||
height: `${taskBarHeight}px`,
|
||||
top: `${topOffset}px`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,11 +340,13 @@ const taskBarStyle = computed(() => {
|
||||
|
||||
// 如果startDate和endDate都不存在,返回0宽度(实际不会渲染,由shouldRenderTaskBar控制)
|
||||
if (!startDate && !endDate) {
|
||||
const taskBarHeight = props.rowHeight - 10
|
||||
const topOffset = (props.rowHeight - taskBarHeight) / 2
|
||||
return {
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
height: `${props.rowHeight - 10}px`,
|
||||
top: '4px',
|
||||
height: `${taskBarHeight}px`,
|
||||
top: `${topOffset}px`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,19 +359,37 @@ const taskBarStyle = computed(() => {
|
||||
// 安全检查:renderStartDate和renderEndDate必定存在(因为上面已经检查过)
|
||||
// 但baseStart可能不存在,如果不存在则无法计算位置
|
||||
if (!renderStartDate || !renderEndDate || !renderBaseStart) {
|
||||
const taskBarHeight = props.rowHeight - 10
|
||||
const topOffset = (props.rowHeight - taskBarHeight) / 2
|
||||
return {
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
height: `${props.rowHeight - 10}px`,
|
||||
top: '4px',
|
||||
height: `${taskBarHeight}px`,
|
||||
top: `${topOffset}px`,
|
||||
}
|
||||
}
|
||||
|
||||
let left = 0
|
||||
let width = 0
|
||||
|
||||
// 小时视图:按分钟精确计算位置(需要考虑时间部分)
|
||||
if (props.currentTimeScale === TimelineScale.HOUR) {
|
||||
// v1.9.0 资源视图拖拽时,优先使用精确的像素位置
|
||||
if (viewMode.value === 'resource' && tempTaskPixelLeft.value !== null) {
|
||||
left = tempTaskPixelLeft.value
|
||||
// 计算宽度(基于日期)
|
||||
const startDate = createLocalDate(currentStartDate)
|
||||
const endDate = createLocalDate(currentEndDate)
|
||||
if (startDate && endDate) {
|
||||
const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
|
||||
const endDateOnly = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
|
||||
const timeDiffMs = endDateOnly.getTime() - startDateOnly.getTime()
|
||||
const daysDiff = Math.round(timeDiffMs / (1000 * 60 * 60 * 24))
|
||||
const duration = daysDiff === 0 ? 1 : daysDiff + 1
|
||||
width = duration * props.dayWidth
|
||||
} else {
|
||||
width = props.dayWidth // 默认宽度
|
||||
}
|
||||
} else if (props.currentTimeScale === TimelineScale.HOUR) {
|
||||
// 小时视图:按分钟精确计算位置(需要考虑时间部分)
|
||||
// 确保 baseStart 是当天的 00:00:00
|
||||
const baseStartOfDay = new Date(renderBaseStart)
|
||||
baseStartOfDay.setHours(0, 0, 0, 0)
|
||||
@@ -500,11 +540,15 @@ const taskBarStyle = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
const taskBarHeight = props.rowHeight - 10
|
||||
// v1.9.0 资源视图中使用固定top值,因为resource-row已经绝对定位
|
||||
const topOffset = viewMode.value === 'resource' ? 5 : (props.rowHeight - taskBarHeight) / 2
|
||||
|
||||
return {
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
height: `${props.rowHeight - 10}px`,
|
||||
top: '4px',
|
||||
height: `${taskBarHeight}px`,
|
||||
top: `${topOffset}px`,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -923,9 +967,12 @@ const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-r
|
||||
|
||||
// 计算鼠标相对于TaskBar的位置
|
||||
mouseOffsetX.value = e.clientX - barRect.left
|
||||
// v1.9.0 记录鼠标在TaskBar内的偏移量,用于拖拽预览对齐
|
||||
dragPreviewOffsetX.value = e.clientX - barRect.left
|
||||
|
||||
// 记录初始状态,但不立即激活拖拽
|
||||
dragStartX.value = e.clientX
|
||||
dragStartY.value = e.clientY // v1.9.0 记录Y坐标用于资源视图垂直拖拽
|
||||
dragStartLeft.value = parseInt(taskBarStyle.value.left)
|
||||
dragStartWidth.value = parseInt(taskBarStyle.value.width)
|
||||
|
||||
@@ -1017,6 +1064,34 @@ const dragTooltipPosition = ref({ x: 0, y: 0 })
|
||||
const dragTooltipContent = ref({ startDate: '', endDate: '' })
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
// 记录最新的鼠标Y位置(用于资源视图垂直拖拽)
|
||||
if (viewMode.value === 'resource') {
|
||||
;(window as any).lastDragMouseY = e.clientY
|
||||
|
||||
// v1.9.0 检测是否跨行拖拽
|
||||
const timelineBody = document.querySelector('.timeline-body')
|
||||
let isCrossRowDrag = false
|
||||
|
||||
if (timelineBody && isDragging.value && isDragThresholdMet.value) {
|
||||
const bodyRect = timelineBody.getBoundingClientRect()
|
||||
const relativeY = e.clientY - bodyRect.top + (timelineBody as HTMLElement).scrollTop
|
||||
const currentRowIndex = Math.floor(relativeY / 51)
|
||||
isCrossRowDrag = currentRowIndex !== props.rowIndex
|
||||
|
||||
// 只有在跨行拖拽时才显示预览
|
||||
if (isCrossRowDrag) {
|
||||
dragPreviewVisible.value = true
|
||||
// 虚拟预览应该显示在鼠标位置
|
||||
dragPreviewPosition.value = {
|
||||
x: e.clientX - dragPreviewOffsetX.value,
|
||||
y: e.clientY - (props.rowHeight / 2)
|
||||
}
|
||||
} else {
|
||||
dragPreviewVisible.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果处于高亮状态,立即返回,不执行任何拖拽/拉伸操作
|
||||
if (props.isHighlighted || props.isPrimaryHighlight) {
|
||||
return
|
||||
@@ -1030,9 +1105,15 @@ const handleMouseMove = (e: MouseEvent) => {
|
||||
// 检查是否达到拖拽阈值
|
||||
if (!isDragThresholdMet.value) {
|
||||
const deltaX = Math.abs(e.clientX - dragStartX.value)
|
||||
const deltaY = Math.abs(e.clientY - dragStartY.value)
|
||||
|
||||
// v1.9.0 资源视图中,同时考虑Y轴移动(垂直拖拽)
|
||||
const threshold = viewMode.value === 'resource' && dragType.value === 'drag'
|
||||
? Math.max(deltaX, deltaY) // 资源视图拖拽:X或Y有一个达到阈值即可
|
||||
: deltaX // 任务视图或拉伸:只考虑X轴
|
||||
|
||||
// 如果移动距离小于阈值,不执行任何操作
|
||||
if (deltaX < dragThreshold.value) {
|
||||
if (threshold < dragThreshold.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1057,7 +1138,11 @@ const handleMouseMove = (e: MouseEvent) => {
|
||||
new CustomEvent('drag-boundary-check', {
|
||||
detail: {
|
||||
mouseX: e.clientX,
|
||||
mouseY: e.clientY, // v1.9.0 添加Y坐标用于资源视图垂直拖拽检测
|
||||
taskId: props.task.id, // v1.9.0 添加taskId
|
||||
rowIndex: props.rowIndex, // v1.9.0 添加当前行索引
|
||||
isDragging: isDragging.value || isResizingLeft.value || isResizingRight.value,
|
||||
isResourceView: viewMode.value === 'resource', // v1.9.0 标识是否资源视图
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -1074,6 +1159,69 @@ const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging.value) {
|
||||
const deltaX = e.clientX - dragStartX.value
|
||||
|
||||
// v1.9.0 资源视图垂直拖拽:使用与任务视图相同的日期计算算法
|
||||
if (viewMode.value === 'resource') {
|
||||
// 计算TaskBar的新左边缘位置(鼠标位置 - 偏移量)
|
||||
const taskBarNewLeft = e.clientX - dragPreviewOffsetX.value
|
||||
|
||||
// 需要获取timeline body的位置来计算相对位置
|
||||
const timelineBody = document.querySelector('.timeline-body')
|
||||
if (timelineBody) {
|
||||
const bodyRect = timelineBody.getBoundingClientRect()
|
||||
const scrollLeft = (timelineBody as HTMLElement).scrollLeft
|
||||
// 计算相对于timeline body的位置
|
||||
const relativeX = taskBarNewLeft - bodyRect.left + scrollLeft
|
||||
|
||||
// 保存精确的像素位置,避免通过日期往返计算导致的精度损失
|
||||
tempTaskPixelLeft.value = relativeX
|
||||
|
||||
// 使用与任务视图相同的日期计算算法
|
||||
let newStartDate: Date | null = null
|
||||
|
||||
if (props.timelineData &&
|
||||
(props.currentTimeScale === TimelineScale.DAY ||
|
||||
props.currentTimeScale === TimelineScale.MONTH ||
|
||||
props.currentTimeScale === TimelineScale.QUARTER ||
|
||||
props.currentTimeScale === TimelineScale.YEAR)) {
|
||||
// 有timelineData时,使用精确的日期计算
|
||||
newStartDate = calculateDateFromPosition(
|
||||
relativeX,
|
||||
props.timelineData,
|
||||
props.currentTimeScale,
|
||||
)
|
||||
}
|
||||
|
||||
if (!newStartDate) {
|
||||
// 没有timelineData或计算失败,使用简单算法
|
||||
newStartDate = addDaysToLocalDate(props.startDate, relativeX / props.dayWidth)
|
||||
}
|
||||
|
||||
// 计算任务持续时间(天数)
|
||||
const originalStartDate = createLocalDate(props.task.startDate) || props.startDate
|
||||
const originalEndDate = createLocalDate(props.task.endDate) || props.startDate
|
||||
const durationMs = originalEndDate.getTime() - originalStartDate.getTime()
|
||||
const duration = Math.ceil(durationMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
// 计算新的结束日期
|
||||
const newEndDate = new Date(newStartDate)
|
||||
newEndDate.setDate(newEndDate.getDate() + Math.max(0, duration))
|
||||
|
||||
// 更新拖拽提示框内容
|
||||
dragTooltipContent.value = {
|
||||
startDate: formatDateToLocalString(newStartDate),
|
||||
endDate: formatDateToLocalString(newEndDate),
|
||||
}
|
||||
|
||||
// v1.9.0 更新tempTaskData,让TaskBar有视觉移动效果(同行和跨行都需要)
|
||||
tempTaskData.value = {
|
||||
startDate: formatDateToLocalString(newStartDate),
|
||||
endDate: formatDateToLocalString(newEndDate),
|
||||
}
|
||||
}
|
||||
// 资源视图直接返回,不执行后续的任务视图逻辑
|
||||
return
|
||||
}
|
||||
|
||||
if (props.currentTimeScale === TimelineScale.HOUR) {
|
||||
// 小时视图:15分钟刻度对齐
|
||||
const pixelPerMinute = 40 / 60 // 每分钟的像素数
|
||||
@@ -1475,8 +1623,49 @@ const handleMouseUp = () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// v1.9.0 资源视图垂直拖拽:检测是否移动到不同资源
|
||||
let targetResourceRowIndex: number | undefined
|
||||
let isCrossRowDrag = false
|
||||
|
||||
if (viewMode.value === 'resource' && isDragging.value && isDragThresholdMet.value) {
|
||||
// 记录松开鼠标时的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
|
||||
}
|
||||
|
||||
// 只有跨行拖拽才发送drop事件
|
||||
if (isCrossRowDrag) {
|
||||
// 发送最后的鼠标位置给Timeline,让它确定目标资源
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('resource-taskbar-drop', {
|
||||
detail: {
|
||||
taskId: props.task.id,
|
||||
task: props.task,
|
||||
sourceRowIndex: props.rowIndex,
|
||||
mouseY: (window as any).lastDragMouseY || 0,
|
||||
// v1.9.0 直接传递TaskBar已经计算好的精确日期,避免Timeline重复计算导致误差
|
||||
calculatedStartDate: tempTaskData.value?.startDate,
|
||||
calculatedEndDate: tempTaskData.value?.endDate,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 隐藏拖拽预览
|
||||
dragPreviewVisible.value = false
|
||||
}
|
||||
|
||||
// 只有达到拖拽阈值且有临时数据时才提交更新
|
||||
if (isDragThresholdMet.value && tempTaskData.value) {
|
||||
// v1.9.0 资源视图跨行拖拽时不提交,由确认对话框处理
|
||||
if (isDragThresholdMet.value && tempTaskData.value && !(viewMode.value === 'resource' && isCrossRowDrag)) {
|
||||
const updatedTask = {
|
||||
...props.task,
|
||||
...tempTaskData.value,
|
||||
@@ -1518,6 +1707,7 @@ const handleMouseUp = () => {
|
||||
isDragThresholdMet.value = false
|
||||
isDelayPassed.value = false
|
||||
dragType.value = null
|
||||
tempTaskPixelLeft.value = null // v1.9.0 清除资源视图的像素位置缓存
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
@@ -1541,6 +1731,17 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// v1.9.0 监听资源拖拽取消事件
|
||||
const handleResourceDragCancel = (event: Event) => {
|
||||
const customEvent = event as CustomEvent
|
||||
if (customEvent.detail.taskId === props.task.id) {
|
||||
// 清除临时数据,让TaskBar恢复到原始位置
|
||||
tempTaskData.value = null
|
||||
tempTaskPixelLeft.value = null
|
||||
}
|
||||
}
|
||||
window.addEventListener('resource-drag-cancel', handleResourceDragCancel as EventListener)
|
||||
|
||||
// 监听时间刻度变化事件,重新计算位置
|
||||
const handleTimelineScaleUpdate = () => {
|
||||
nextTick(() => {
|
||||
@@ -1576,6 +1777,7 @@ onMounted(() => {
|
||||
window.removeEventListener('timeline-scale-updated', handleTimelineScaleUpdate)
|
||||
window.removeEventListener('timeline-force-recalculate', handleForceRecalculate)
|
||||
window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
|
||||
window.removeEventListener('resource-drag-cancel', handleResourceDragCancel as EventListener)
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
|
||||
// 清除长按定时器
|
||||
@@ -1959,6 +2161,7 @@ const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
// TaskBar 悬停 tooltip 状态
|
||||
const showHoverTooltip = ref(false)
|
||||
const hoverTooltipPosition = ref({ x: 0, y: 0 })
|
||||
const isTooltipBelow = ref(false) // v1.9.0 标记tooltip是否显示在TaskBar下方
|
||||
let hoverTooltipTimer: number | null = null
|
||||
|
||||
// 跟踪滚动状态,避免非滚动时的动画
|
||||
@@ -2158,18 +2361,53 @@ const handleTaskBarMouseEnter = (event: MouseEvent) => {
|
||||
isTaskBarHovered.value = true
|
||||
|
||||
// 如果启用了TaskBar Tooltip(父级任务也显示tooltip)
|
||||
if (props.enableTaskBarTooltip !== false) {
|
||||
// 但在拖拽或拉伸时不显示tooltip
|
||||
if (props.enableTaskBarTooltip !== false && !isDragging.value && !isResizingLeft.value && !isResizingRight.value) {
|
||||
// 保存event.currentTarget的引用,因为在setTimeout回调中它会变成null
|
||||
const targetElement = event.currentTarget as HTMLElement
|
||||
// 保存鼠标位置
|
||||
const mouseX = event.clientX
|
||||
const mouseY = event.clientY
|
||||
|
||||
// 延迟显示tooltip,避免快速滑过时显示
|
||||
hoverTooltipTimer = window.setTimeout(() => {
|
||||
showHoverTooltip.value = true
|
||||
const rect = targetElement.getBoundingClientRect()
|
||||
hoverTooltipPosition.value = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top - 10,
|
||||
|
||||
// 计算tooltip的预估宽高(根据实际CSS设置)
|
||||
const tooltipWidth = 250 // 预估宽度
|
||||
const tooltipHeight = 120 // 预估高度(考虑实际内容高度)
|
||||
const margin = 10 // 边距
|
||||
|
||||
// 视口尺寸
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
// v1.9.0 改为基于TaskBar边界定位
|
||||
// 水平位置:TaskBar中心对齐
|
||||
let x = rect.left + rect.width / 2
|
||||
// 默认显示在TaskBar上方边缘外(CSS transform: translateY(-100%)会向上偏移tooltip高度)
|
||||
let y = rect.top - 10
|
||||
|
||||
// 水平边界检测:左侧超出
|
||||
if (x - tooltipWidth / 2 < margin) {
|
||||
x = margin + tooltipWidth / 2
|
||||
}
|
||||
// 水平边界检测:右侧超出
|
||||
if (x + tooltipWidth / 2 > viewportWidth - margin) {
|
||||
x = viewportWidth - margin - tooltipWidth / 2
|
||||
}
|
||||
|
||||
// 垂直边界检测:如果上方空间不足,显示在下方
|
||||
if (rect.top - 10 - tooltipHeight < margin) {
|
||||
// 显示在TaskBar下方
|
||||
y = rect.bottom + 10
|
||||
isTooltipBelow.value = true
|
||||
} else {
|
||||
isTooltipBelow.value = false
|
||||
}
|
||||
|
||||
hoverTooltipPosition.value = { x, y }
|
||||
}, 300) // 300ms延迟
|
||||
}
|
||||
}
|
||||
@@ -2185,6 +2423,17 @@ const handleTaskBarMouseLeave = () => {
|
||||
showHoverTooltip.value = false
|
||||
}
|
||||
|
||||
// 监听拖拽/拉伸状态,如果开始拖拽/拉伸,立即隐藏tooltip
|
||||
watch([isDragging, isResizingLeft, isResizingRight], ([dragging, resizingL, resizingR]) => {
|
||||
if (dragging || resizingL || resizingR) {
|
||||
showHoverTooltip.value = false
|
||||
if (hoverTooltipTimer) {
|
||||
clearTimeout(hoverTooltipTimer)
|
||||
hoverTooltipTimer = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化日期显示
|
||||
const formatDisplayDate = (dateStr: string | undefined): string => {
|
||||
if (!dateStr) return t('dateNotSet')
|
||||
@@ -2674,23 +2923,25 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 实际进度条(独立渲染在下层) -->
|
||||
<div
|
||||
v-if="actualBarStyle && shouldRenderTaskBar && !isParent"
|
||||
class="actual-bar"
|
||||
:data-task-id="`actual-${task.id}`"
|
||||
:class="{
|
||||
'highlighted': isHighlighted,
|
||||
'primary-highlight': isPrimaryHighlight,
|
||||
'dimmed': isDimmed,
|
||||
}"
|
||||
:style="{
|
||||
...actualBarStyle,
|
||||
backgroundColor: taskStatus.color,
|
||||
filter: 'brightness(1.15) saturate(0.9)', /* 加白并降低饱和度,与计划TaskBar色系一致 */
|
||||
boxShadow: `0 6px 20px ${taskStatus.color}60, 0 3px 10px ${taskStatus.color}40`, /* 使用TaskBar颜色的阴影,移除白边 */
|
||||
}"
|
||||
>
|
||||
<!-- 根容器:包裹所有非Teleport的元素,接收传递的style属性 -->
|
||||
<div class="task-bar-wrapper" v-bind="$attrs">
|
||||
<!-- 实际进度条(独立渲染在下层) -->
|
||||
<div
|
||||
v-if="actualBarStyle && shouldRenderTaskBar && !isParent"
|
||||
class="actual-bar"
|
||||
:data-task-id="`actual-${task.id}`"
|
||||
:class="{
|
||||
'highlighted': isHighlighted,
|
||||
'primary-highlight': isPrimaryHighlight,
|
||||
'dimmed': isDimmed,
|
||||
}"
|
||||
:style="{
|
||||
...actualBarStyle,
|
||||
backgroundColor: taskStatus.color,
|
||||
filter: 'brightness(1.15) saturate(0.9)', /* 加白并降低饱和度,与计划TaskBar色系一致 */
|
||||
boxShadow: `0 6px 20px ${taskStatus.color}60, 0 3px 10px ${taskStatus.color}40`, /* 使用TaskBar颜色的阴影,移除白边 */
|
||||
}"
|
||||
>
|
||||
<div class="actual-bar-content">
|
||||
<span class="actual-progress">{{ task.progress || 0 }}%</span>
|
||||
</div>
|
||||
@@ -2769,6 +3020,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
'primary-highlight': isPrimaryHighlight,
|
||||
dimmed: isDimmed,
|
||||
'has-actual': showActualTaskbar && hasActualProgress, /* 只有在showActualTaskbar=true时才标记有实际进度 */
|
||||
'resource-conflict': props.hasResourceConflict, /* v1.9.0 资源冲突样式 */
|
||||
}"
|
||||
@click="handleTaskBarClick"
|
||||
@contextmenu="handleContextMenu"
|
||||
@@ -2866,7 +3118,12 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
</div>
|
||||
|
||||
<!-- 任务名称 - 有实际TaskBar时隐藏 -->
|
||||
<div v-if="barConfig.showTitle && !(showActualTaskbar && hasActualProgress)" ref="taskBarNameRef" :style="getNameStyles()">
|
||||
<div
|
||||
v-if="barConfig.showTitle && !(showActualTaskbar && hasActualProgress)"
|
||||
ref="taskBarNameRef"
|
||||
:style="viewMode.value === 'resource' ? {} : getNameStyles()"
|
||||
:class="{ 'task-name-wrapper': viewMode.value === 'resource' }"
|
||||
>
|
||||
<slot v-if="hasContentSlot" name="custom-task-content" v-bind="slotPayload" />
|
||||
<div v-else class="task-name">
|
||||
{{ task.name }}
|
||||
@@ -2877,7 +3134,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
<div
|
||||
v-if="barConfig.showProgress && shouldShowProgress && !(showActualTaskbar && hasActualProgress)"
|
||||
class="task-progress"
|
||||
:style="getProgressStyles()"
|
||||
:style="viewMode.value === 'resource' ? {} : getProgressStyles()"
|
||||
>
|
||||
{{ task.progress || 0 }}%
|
||||
</div>
|
||||
@@ -2983,6 +3240,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</div><!-- 关闭task-bar-wrapper -->
|
||||
|
||||
<!-- Tooltip 弹窗 -->
|
||||
<Teleport to="body">
|
||||
@@ -3044,18 +3302,40 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- v1.9.0 拖拽预览效果(资源视图垂直拖拽) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="dragPreviewVisible"
|
||||
class="drag-preview"
|
||||
:style="{
|
||||
left: `${dragPreviewPosition.x}px`,
|
||||
top: `${dragPreviewPosition.y}px`,
|
||||
width: taskBarStyle.width,
|
||||
height: taskBarStyle.height,
|
||||
backgroundColor: taskStatus.color,
|
||||
borderColor: taskStatus.borderColor,
|
||||
}"
|
||||
>
|
||||
<div class="drag-preview-content">{{ task.name }}</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- TaskBar悬停提示框 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showHoverTooltip"
|
||||
class="task-hover-tooltip"
|
||||
:class="{ 'tooltip-below': isTooltipBelow }"
|
||||
:style="{
|
||||
left: `${hoverTooltipPosition.x}px`,
|
||||
top: `${hoverTooltipPosition.y}px`,
|
||||
backgroundColor: taskStatus.color,
|
||||
}"
|
||||
>
|
||||
<div class="hover-tooltip-arrow" :style="{ borderTopColor: taskStatus.color }"></div>
|
||||
<div class="hover-tooltip-arrow" :style="{
|
||||
borderTopColor: isTooltipBelow ? 'transparent' : taskStatus.color,
|
||||
borderBottomColor: isTooltipBelow ? taskStatus.color : 'transparent'
|
||||
}"></div>
|
||||
<div class="hover-tooltip-content">
|
||||
<div class="hover-tooltip-title">{{ task.name }}</div>
|
||||
<div class="hover-tooltip-row">
|
||||
@@ -3080,6 +3360,18 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 根容器:透明容器,仅用于接收传递的style属性 */
|
||||
.task-bar-wrapper {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none; /* 让所有事件穿透到子元素 */
|
||||
}
|
||||
|
||||
.task-bar-wrapper > * {
|
||||
pointer-events: auto; /* 恢复子元素的事件响应 */
|
||||
}
|
||||
|
||||
.task-bar {
|
||||
position: absolute;
|
||||
border-radius: 4px;
|
||||
@@ -3088,7 +3380,8 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
transform 0.3s,
|
||||
filter 0.3s;
|
||||
filter 0.3s,
|
||||
z-index 0s; /* v1.9.0 z-index不使用动画 */
|
||||
z-index: 100;
|
||||
border: 2px solid;
|
||||
/* 添加半透明黑色边框增强对比度 */
|
||||
@@ -3096,6 +3389,11 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
overflow: visible; /* 允许内容超出 TaskBar */
|
||||
}
|
||||
|
||||
/* v1.9.0 悬停时提升z-index,确保资源视图中重叠的TaskBar可以正常交互 */
|
||||
.task-bar:hover {
|
||||
z-index: 160 !important;
|
||||
}
|
||||
|
||||
/* 有实际进度时,计划条使用虚线边框样式 */
|
||||
.task-bar.has-actual {
|
||||
/* 不再强制设置半透明背景,由内联样式的isTaskBarHovered控制 */
|
||||
@@ -3138,6 +3436,30 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* v1.9.0 资源冲突样式(根据UI规范)*/
|
||||
.task-bar.resource-conflict {
|
||||
/* 左侧3px红色色带 */
|
||||
border-left: 3px solid var(--gantt-error-color, #f56c6c) !important;
|
||||
/* 浅红底色 */
|
||||
background-color: rgba(245, 108, 108, 0.08) !important;
|
||||
/* 斑马纹背景(叠加在底色上)*/
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 10px,
|
||||
rgba(245, 108, 108, 0.12) 10px,
|
||||
rgba(245, 108, 108, 0.12) 20px
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* 冲突状态的文字保持清晰可读 */
|
||||
.task-bar.resource-conflict .task-name,
|
||||
.task-bar.resource-conflict .task-progress {
|
||||
color: var(--gantt-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-bar.dragging {
|
||||
opacity: 0.8;
|
||||
z-index: 1000;
|
||||
@@ -3537,6 +3859,13 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* 资源视图中的标题包裹器,禁用绝对定位 */
|
||||
.task-name-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
@@ -3552,6 +3881,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
font-size: 11px;
|
||||
font-weight: 700; /* 加粗显示 */
|
||||
z-index: 10;
|
||||
line-height: 1.2;
|
||||
/* 移除背景样式,保持原始状态 */
|
||||
}
|
||||
|
||||
@@ -3876,6 +4206,31 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
/* v1.9.0 拖拽预览效果 */
|
||||
.drag-preview {
|
||||
position: fixed;
|
||||
opacity: 0.5;
|
||||
border-radius: 6px;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.8);
|
||||
z-index: 999999998;
|
||||
pointer-events: none;
|
||||
/* v1.9.0 不使用transform居中,直接定位保持时间对齐 */
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.drag-preview-content {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.drag-tooltip .tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -3911,20 +4266,35 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
|
||||
z-index: 999999999;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -100%);
|
||||
transform: translate(-50%, -100%); /* 默认显示在上方 */
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
/* 显示在下方时的样式 */
|
||||
.task-hover-tooltip.tooltip-below {
|
||||
transform: translate(-50%, 0); /* 显示在下方 */
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.hover-tooltip-arrow {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -5px;
|
||||
bottom: -5px; /* 默认箭头在底部,指向下方 */
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid rgba(0, 0, 0, 0.85);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* 显示在下方时,箭头在顶部,指向上方 */
|
||||
.tooltip-below .hover-tooltip-arrow {
|
||||
bottom: auto;
|
||||
top: -5px;
|
||||
border-top: 0;
|
||||
border-bottom: 6px solid rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.hover-tooltip-content {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, useSlots, computed, inject } from 'vue'
|
||||
import type { StyleValue, Slots } from 'vue'
|
||||
import type { StyleValue, Slots, Ref, ComputedRef } from 'vue'
|
||||
import TaskRow from './taskRow/TaskRow.vue'
|
||||
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'
|
||||
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'
|
||||
import { useTaskRowDrag } from '../../composables/useTaskRowDrag'
|
||||
import { useTaskListColumns } from './composables/taskList/useTaskListColumns'
|
||||
import { useTaskListLayout } from './composables/taskList/useTaskListLayout'
|
||||
@@ -43,23 +46,47 @@ const emit = defineEmits<{
|
||||
newParent: Task | null
|
||||
},
|
||||
]
|
||||
'resource-click': [resource: Resource] // v1.9.0 资源行点击事件
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
const hasRowSlot = computed(() => Boolean(slots['custom-task-content']))
|
||||
|
||||
// v1.9.0 从 GanttChart 注入视图模式和数据源
|
||||
const viewMode = inject<Ref<'task' | 'resource'>>('gantt-view-mode', ref('task'))
|
||||
const dataSource = inject<ComputedRef<Task[] | Resource[]>>('gantt-data-source', computed(() => []))
|
||||
const listConfig = inject<ComputedRef<TaskListConfig | ResourceListConfig | undefined>>(
|
||||
'gantt-list-config',
|
||||
computed(() => undefined),
|
||||
)
|
||||
|
||||
// 从 GanttChart 注入列级 slots
|
||||
const columnSlots = inject<Slots>('gantt-column-slots', {})
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// v1.9.0 根据视图模式获取默认列配置
|
||||
const getDefaultColumns = computed(() => {
|
||||
if (viewMode.value === 'resource') {
|
||||
return DEFAULT_RESOURCE_LIST_COLUMNS as unknown as TaskListColumnConfig[]
|
||||
}
|
||||
return DEFAULT_TASK_LIST_COLUMNS
|
||||
})
|
||||
|
||||
// v1.9.0 根据视图模式和inject的配置计算最终列配置
|
||||
const finalColumnsConfig = computed(() => {
|
||||
// 优先使用inject的配置,否则使用props
|
||||
const config = listConfig.value || props.taskListConfig
|
||||
return config?.columns || getDefaultColumns.value
|
||||
})
|
||||
|
||||
// 使用声明式列管理 composable
|
||||
const { declarativeColumns, getColumnWidthStyle: getDeclarativeColumnWidth } =
|
||||
useTaskListColumns(
|
||||
computed(() => props.taskListColumnRenderMode || 'default'),
|
||||
slots,
|
||||
props.taskListConfig?.columns || DEFAULT_TASK_LIST_COLUMNS,
|
||||
finalColumnsConfig.value,
|
||||
)
|
||||
|
||||
// 计算实际使用的列配置
|
||||
@@ -76,12 +103,18 @@ const visibleColumns = computed(() => {
|
||||
if (props.taskListColumnRenderMode === 'declarative') {
|
||||
return [] as TaskListColumnConfig[]
|
||||
}
|
||||
const columns = props.taskListConfig?.columns || DEFAULT_TASK_LIST_COLUMNS
|
||||
return columns.filter(col => col.visible !== false)
|
||||
return finalColumnsConfig.value.filter(col => col.visible !== false)
|
||||
})
|
||||
|
||||
// 使用 props.tasks 的引用,不创建副本
|
||||
const localTasks = computed(() => props.tasks || [])
|
||||
// v1.9.0 使用注入的数据源或props.tasks
|
||||
const localTasks = computed(() => {
|
||||
if (viewMode.value === 'resource') {
|
||||
// 资源视图:使用dataSource作为资源列表
|
||||
return (dataSource.value || []) as Task[]
|
||||
}
|
||||
// 任务视图:使用props.tasks
|
||||
return (props.tasks || []) as Task[]
|
||||
})
|
||||
|
||||
// 使用布局计算 composable
|
||||
const {
|
||||
@@ -92,8 +125,8 @@ const {
|
||||
endSpacerHeight,
|
||||
} = useTaskListLayout(localTasks)
|
||||
|
||||
// 悬停状态管理
|
||||
const hoveredTaskId = ref<number | null>(null)
|
||||
// 悬停状态管理 - v1.9.0 支持资源视图中的字符串ID
|
||||
const hoveredTaskId = ref<number | string | null>(null)
|
||||
|
||||
// 拖拽状态管理
|
||||
const isSplitterDragging = ref(false)
|
||||
@@ -205,6 +238,11 @@ const handleTaskRowMoved = (payload: {
|
||||
})
|
||||
}
|
||||
|
||||
// v1.9.0 处理资源行点击事件
|
||||
const handleResourceClick = (resource: Resource) => {
|
||||
emit('resource-click', resource)
|
||||
}
|
||||
|
||||
// 全局拖拽管理器
|
||||
const { startDrag, handleDragOver } = useTaskRowDrag({
|
||||
enabled: props.enableTaskRowMove ?? false,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { updateParentTasksData, getAllTasks } from './useTaskParentCalculation'
|
||||
|
||||
export interface TaskListEventHandlersOptions {
|
||||
tasks: Ref<Task[]>
|
||||
hoveredTaskId: Ref<number | null>
|
||||
hoveredTaskId: Ref<number | string | null>
|
||||
isSplitterDragging: Ref<boolean>
|
||||
taskListScrollTop: Ref<number>
|
||||
taskListBodyRef: Ref<HTMLElement | null>
|
||||
@@ -30,7 +30,7 @@ export function useTaskListEventHandlers(options: TaskListEventHandlersOptions)
|
||||
|
||||
// ==================== 悬停事件处理 ====================
|
||||
|
||||
const handleTaskRowHover = (taskId: number | null) => {
|
||||
const handleTaskRowHover = (taskId: number | string | null) => {
|
||||
if (isSplitterDragging.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, useSlots, toRef, inject, type ComputedRef } from 'vue'
|
||||
import { ref, computed, useSlots, toRef, inject, type ComputedRef, type Ref } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
import { useI18n } from '../../../composables/useI18n'
|
||||
import { formatPredecessorDisplay } from '../../../utils/predecessorUtils'
|
||||
import type { Task } from '../../../models/classes/Task'
|
||||
import type { Resource } from '../../../models/classes/Resource'
|
||||
import type { TaskListColumnConfig } from '../../../models/configs/TaskListConfig'
|
||||
import type { DeclarativeColumnConfig } from '../composables/taskList/useTaskListColumns'
|
||||
import TaskContextMenu from '../../TaskContextMenu.vue'
|
||||
@@ -21,7 +22,7 @@ interface TaskRowSlotProps {
|
||||
level: number
|
||||
indent: string
|
||||
isHovered: boolean
|
||||
hoveredTaskId: number | null
|
||||
hoveredTaskId: number | string | null
|
||||
isParent: boolean
|
||||
hasChildren: boolean
|
||||
collapsed: boolean
|
||||
@@ -42,8 +43,8 @@ interface Props {
|
||||
level: number
|
||||
rowIndex?: number
|
||||
isHovered?: boolean
|
||||
hoveredTaskId?: number | null
|
||||
onHover?: (taskId: number | null) => void
|
||||
hoveredTaskId?: number | string | null
|
||||
onHover?: (taskId: number | string | null) => void
|
||||
columns: TaskListColumnConfig[]
|
||||
declarativeColumns?: DeclarativeColumnConfig[]
|
||||
renderMode?: 'default' | 'declarative'
|
||||
@@ -82,6 +83,14 @@ const daysText = computed(() => t.value?.days ?? '')
|
||||
const slots = useSlots()
|
||||
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'))
|
||||
|
||||
// v1.9.0 Detect if current row is a resource
|
||||
const isResourceRow = computed(() => {
|
||||
return viewMode.value === 'resource' && 'tasks' in props.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))
|
||||
@@ -289,6 +298,7 @@ const assigneeDisplayData = computed(() => {
|
||||
:data-task-id="props.task.id"
|
||||
:class="{
|
||||
'task-row-hovered': isHovered,
|
||||
'task-type-story': viewMode === 'resource', /* v1.9.0 资源视图始终显示左边框 */
|
||||
'parent-task': isParentTask,
|
||||
'milestone-group-row': isMilestoneGroup,
|
||||
'task-type-story': isStoryTask,
|
||||
@@ -358,11 +368,21 @@ const assigneeDisplayData = computed(() => {
|
||||
|
||||
<!-- 叶子节点的占位空间(无折叠按钮且无图标显示时) -->
|
||||
<span
|
||||
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false"
|
||||
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false && !isResourceRow"
|
||||
class="leaf-spacer"
|
||||
></span>
|
||||
|
||||
<!-- v1.9.0 资源视图:直接显示资源名称 -->
|
||||
<div v-if="isResourceRow" class="resource-row-name">
|
||||
<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>
|
||||
<span class="resource-name-text">{{ (props.task as any).name || '-' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 任务视图:正常渲染 -->
|
||||
<TaskRowNameContent
|
||||
v-else
|
||||
:task="props.task"
|
||||
:is-parent-task="isParentTask"
|
||||
:has-children="hasChildren"
|
||||
@@ -417,7 +437,12 @@ const assigneeDisplayData = computed(() => {
|
||||
{{ column.formatter(props.task, column) }}
|
||||
</template>
|
||||
|
||||
<!-- 优先级3: 内置列类型渲染 -->
|
||||
<!-- v1.9.0 资源视图:使用formatter或直接显示资源属性 -->
|
||||
<template v-else-if="isResourceRow">
|
||||
{{ (props.task as any)[column.key] || '-' }}
|
||||
</template>
|
||||
|
||||
<!-- 优先级3: 内置列类型渲染(任务视图) -->
|
||||
<!-- 前置任务列 -->
|
||||
<template v-else-if="column.key === 'predecessor'">
|
||||
{{ formatPredecessorDisplay(props.task.predecessor) }}
|
||||
@@ -540,24 +565,20 @@ const assigneeDisplayData = computed(() => {
|
||||
align-items: center;
|
||||
color: var(--gantt-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
transform: scale(1);
|
||||
transform-origin: 5px center; /* 从左侧偏右5px的位置作为放大中心 */
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-row:hover {
|
||||
background-color: var(--gantt-bg-hover);
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.task-row-hovered {
|
||||
background-color: var(--gantt-bg-hover) !important;
|
||||
transform: scale(1.02) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||||
box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15) !important;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
@@ -568,15 +589,13 @@ const assigneeDisplayData = computed(() => {
|
||||
|
||||
.task-row.parent-task:hover {
|
||||
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover));
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.task-row.parent-task.task-row-hovered {
|
||||
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover)) !important;
|
||||
transform: scale(1.02) !important;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2) !important;
|
||||
box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15) !important;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
@@ -586,12 +605,9 @@ const assigneeDisplayData = computed(() => {
|
||||
background: linear-gradient(90deg, var(--gantt-bg-tertiary) 0%, var(--gantt-bg-primary) 100%);
|
||||
}
|
||||
|
||||
.milestone-group-row:hover {
|
||||
.mbox-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15);
|
||||
ilestone-group-row:hover {
|
||||
background: linear-gradient(90deg, var(--gantt-bg-hover-parent) 0%, var(--gantt-bg-hover) 100%);
|
||||
transform: scale(1.02);
|
||||
box-shadow:
|
||||
0 6px 16px rgba(245, 108, 108, 0.3),
|
||||
0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
border-left-color: var(--gantt-danger, #f56c6c);
|
||||
border-left-width: 4px; /* 悬停时边框稍微加粗 */
|
||||
@@ -610,30 +626,30 @@ const assigneeDisplayData = computed(() => {
|
||||
border-left: 3px solid var(--gantt-danger, #f56c6c);
|
||||
}
|
||||
|
||||
/* 任务类型悬停时保持并增强左边框 */
|
||||
/* 任务类型悬停时保持左边框,无需加粗 */
|
||||
.task-type-story:hover {
|
||||
border-left: 5px solid var(--gantt-primary, #409eff);
|
||||
border-left: 3px solid var(--gantt-primary, #409eff);
|
||||
}
|
||||
|
||||
.task-type-task:hover {
|
||||
border-left: 5px solid var(--gantt-warning, #e6a23c);
|
||||
border-left: 3px solid var(--gantt-warning, #e6a23c);
|
||||
}
|
||||
|
||||
.task-type-milestone:hover {
|
||||
border-left: 5px solid var(--gantt-danger, #f56c6c);
|
||||
border-left: 3px solid var(--gantt-danger, #f56c6c);
|
||||
}
|
||||
|
||||
/* 悬停状态下的左边框保持 */
|
||||
.task-row-hovered.task-type-story {
|
||||
border-left: 5px solid var(--gantt-primary, #409eff) !important;
|
||||
border-left: 3px solid var(--gantt-primary, #409eff) !important;
|
||||
}
|
||||
|
||||
.task-row-hovered.task-type-task {
|
||||
border-left: 5px solid var(--gantt-warning, #e6a23c) !important;
|
||||
border-left: 3px solid var(--gantt-warning, #e6a23c) !important;
|
||||
}
|
||||
|
||||
.task-row-hovered.task-type-milestone {
|
||||
border-left: 5px solid var(--gantt-danger, #f56c6c) !important;
|
||||
border-left: 3px solid var(--gantt-danger, #f56c6c) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .milestone-group-row {
|
||||
@@ -787,35 +803,25 @@ const assigneeDisplayData = computed(() => {
|
||||
|
||||
/* 暗黑模式的悬停效果 */
|
||||
:global(html[data-theme='dark']) .task-row:hover {
|
||||
box-shadow:
|
||||
0 4px 12px rgba(255, 255, 255, 0.1),
|
||||
0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .task-row.task-row-hovered {
|
||||
background-color: var(--gantt-bg-hover) !important;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(255, 255, 255, 0.1),
|
||||
0 2px 8px rgba(0, 0, 0, 0.3) !important;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .task-row.parent-task:hover {
|
||||
box-shadow:
|
||||
0 6px 16px rgba(255, 255, 255, 0.15),
|
||||
0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .task-row.parent-task.task-row-hovered {
|
||||
background: var(--gantt-bg-hover-parent) !important;
|
||||
box-shadow:
|
||||
0 6px 16px rgba(255, 255, 255, 0.15),
|
||||
0 2px 8px rgba(0, 0, 0, 0.4) !important;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .milestone-group-row:hover {
|
||||
box-shadow:
|
||||
0 6px 16px rgba(246, 124, 124, 0.4),
|
||||
0 2px 8px rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* TaskRow拖拽样式 */
|
||||
@@ -835,6 +841,35 @@ const assigneeDisplayData = computed(() => {
|
||||
background-color: rgba(64, 158, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
/* v1.9.0 资源视图行样式 */
|
||||
.resource-row-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--gantt-text-primary);
|
||||
}
|
||||
|
||||
.resource-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resource-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.resource-name-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .task-row-drop-target.drop-after {
|
||||
background-color: rgba(125, 180, 240, 0.1) !important;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch, nextTick, shallowRef } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed, watch, nextTick, shallowRef, inject } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import TaskBar from './TaskBar.vue'
|
||||
import MilestonePoint from './MilestonePoint.vue'
|
||||
import GanttLinks from './GanttLinks.vue'
|
||||
@@ -10,9 +11,11 @@ import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
|
||||
import { getPredecessorIds } from '../utils/predecessorUtils'
|
||||
import { perfMonitor } from '../utils/perfMonitor'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
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'
|
||||
|
||||
// 定义Props接口
|
||||
interface Props {
|
||||
@@ -86,6 +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 资源视图垂直拖拽结束
|
||||
}>()
|
||||
|
||||
// 多语言
|
||||
@@ -96,6 +100,27 @@ const t = (key: string): string => {
|
||||
return getTranslation(key)
|
||||
}
|
||||
|
||||
// v1.9.0 从 GanttChart 注入视图模式和数据源
|
||||
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()
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
return conflictsMap
|
||||
})
|
||||
|
||||
// 获取以今天为中心的时间线范围(缓存结果,避免每次计算创建新对象)
|
||||
const cachedTodayCenteredRange = (() => {
|
||||
const today = new Date()
|
||||
@@ -146,8 +171,15 @@ watch([timelineStartDate, timelineEndDate], ([newStart, newEnd]) => {
|
||||
// 优化:使用常量避免每次创建新空数组
|
||||
const EMPTY_TASKS_ARRAY: Task[] = []
|
||||
|
||||
// 使用props传入的任务和里程碑数据
|
||||
const tasks = computed(() => props.tasks ?? EMPTY_TASKS_ARRAY)
|
||||
// v1.9.0 使用视图模式决定数据源:资源视图使用dataSource,任务视图使用props.tasks
|
||||
const tasks = computed(() => {
|
||||
if (viewMode.value === 'resource') {
|
||||
// 资源视图:使用注入的 dataSource (Resource[])
|
||||
return (dataSource.value || []) as unknown as Task[]
|
||||
}
|
||||
// 任务视图:使用 props.tasks
|
||||
return props.tasks ?? EMPTY_TASKS_ARRAY
|
||||
})
|
||||
|
||||
// DOM 元素缓存,避免重复查询
|
||||
const timelineContainerElement = ref<HTMLElement | null>(null)
|
||||
@@ -206,8 +238,20 @@ const computeTasksDateRange = (): TaskDateRange => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of tasks.value) {
|
||||
collectDatesFromTask(task)
|
||||
// v1.9.0 资源视图:从Resource.tasks中提取任务日期
|
||||
if (viewMode.value === 'resource') {
|
||||
for (const resource of tasks.value as any) {
|
||||
if (resource.tasks && Array.isArray(resource.tasks)) {
|
||||
for (const task of resource.tasks) {
|
||||
collectDatesFromTask(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 任务视图:直接遍历任务
|
||||
for (const task of tasks.value) {
|
||||
collectDatesFromTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
if (dates.length === 0) {
|
||||
@@ -2796,10 +2840,36 @@ function handleBarMounted(payload: {
|
||||
|
||||
// 向上传递 TaskBar 拖拽/拉伸事件
|
||||
const handleTaskBarDragEnd = (updatedTask: Task) => {
|
||||
// 如果是资源视图,需要更新dataSource中的资源数据
|
||||
if (viewMode.value === 'resource' && dataSource.value) {
|
||||
for (const resource of dataSource.value as any[]) {
|
||||
if (resource.tasks) {
|
||||
const taskIndex = resource.tasks.findIndex((t: Task) => t.id === updatedTask.id)
|
||||
if (taskIndex !== -1) {
|
||||
// 更新资源中的任务数据
|
||||
resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 通过全局事件或 emit/props 回调传递给 GanttChart
|
||||
window.dispatchEvent(new CustomEvent('taskbar-drag-end', { detail: updatedTask }))
|
||||
}
|
||||
const handleTaskBarResizeEnd = (updatedTask: Task) => {
|
||||
// 如果是资源视图,需要更新dataSource中的资源数据
|
||||
if (viewMode.value === 'resource' && dataSource.value) {
|
||||
for (const resource of dataSource.value as any[]) {
|
||||
if (resource.tasks) {
|
||||
const taskIndex = resource.tasks.findIndex((t: Task) => t.id === updatedTask.id)
|
||||
if (taskIndex !== -1) {
|
||||
// 更新资源中的任务数据
|
||||
resource.tasks[taskIndex] = { ...resource.tasks[taskIndex], ...updatedTask }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('taskbar-resize-end', { detail: updatedTask }))
|
||||
}
|
||||
|
||||
@@ -2876,6 +2946,9 @@ onMounted(() => {
|
||||
// 监听TaskBar高亮事件
|
||||
window.addEventListener('taskbar-highlighted', handleTaskBarHighlighted as EventListener)
|
||||
|
||||
// 监听资源视图垂直拖拽事件
|
||||
window.addEventListener('resource-taskbar-drop', handleResourceTaskBarDrop as EventListener)
|
||||
|
||||
// 设置ResizeObserver监听timeline-body的尺寸变化
|
||||
nextTick(() => {
|
||||
// 初始化并缓存 DOM 元素引用
|
||||
@@ -3071,6 +3144,42 @@ const handleTaskBarHighlighted = () => {
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// v1.9.0 资源视图垂直拖拽:处理TaskBar拖放到不同资源行
|
||||
const handleResourceTaskBarDrop = (event: Event) => {
|
||||
const customEvent = event as CustomEvent
|
||||
const { taskId, task, sourceRowIndex, mouseY, mouseX } = customEvent.detail
|
||||
|
||||
// 计算目标资源行索引
|
||||
const timelineBody = timelineBodyElement.value
|
||||
if (!timelineBody) return
|
||||
|
||||
const bodyRect = timelineBody.getBoundingClientRect()
|
||||
const relativeY = mouseY - bodyRect.top + timelineBody.scrollTop
|
||||
const targetRowIndex = Math.floor(relativeY / 51) // 51px是每行的高度
|
||||
|
||||
// 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]
|
||||
|
||||
// 发送事件给父组件,让demo显示确认对话框
|
||||
emit('resource-drag-end', {
|
||||
task,
|
||||
sourceResourceIndex: sourceRowIndex,
|
||||
targetResourceIndex: targetRowIndex,
|
||||
targetResource,
|
||||
newStartDate, // v1.9.0 新的开始日期
|
||||
newEndDate, // v1.9.0 新的结束日期
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 鼠标按下开始拖拽(在时间轴表头和body区域)
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
@@ -3322,7 +3431,7 @@ const stopAutoScroll = () => {
|
||||
|
||||
// 处理拖拽边界检测事件
|
||||
const handleDragBoundaryCheck = (event: CustomEvent) => {
|
||||
const { mouseX, isDragging: dragState } = event.detail
|
||||
const { mouseX, mouseY, isDragging: dragState, isResourceView, taskId, rowIndex } = event.detail
|
||||
|
||||
if (!dragState || !timelineContainer.value) {
|
||||
stopAutoScroll()
|
||||
@@ -3332,6 +3441,25 @@ const handleDragBoundaryCheck = (event: CustomEvent) => {
|
||||
const containerRect = timelineContainer.value.getBoundingClientRect()
|
||||
const relativeX = mouseX - containerRect.left
|
||||
|
||||
// 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
|
||||
|
||||
// 如果移动到不同行,发送事件通知(可以用于显示拖拽指示器等)
|
||||
if (targetRowIndex !== rowIndex && targetRowIndex >= 0) {
|
||||
window.dispatchEvent(new CustomEvent('resource-drag-over', {
|
||||
detail: {
|
||||
taskId,
|
||||
sourceRowIndex: rowIndex,
|
||||
targetRowIndex,
|
||||
mouseY: relativeY
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否在左边界滚动区域
|
||||
if (relativeX <= EDGE_SCROLL_ZONE && timelineContainer.value.scrollLeft > 0) {
|
||||
startAutoScroll('left')
|
||||
@@ -3371,6 +3499,7 @@ onUnmounted(() => {
|
||||
)
|
||||
window.removeEventListener('milestone-click-locate', handleMilestoneClickLocate as EventListener)
|
||||
window.removeEventListener('drag-boundary-check', handleDragBoundaryCheck as EventListener)
|
||||
window.removeEventListener('resource-taskbar-drop', handleResourceTaskBarDrop as EventListener)
|
||||
|
||||
// 清理ResizeObserver
|
||||
if (resizeObserver) {
|
||||
@@ -3657,6 +3786,16 @@ watch([timelineData, timelineContainerWidth], () => {
|
||||
}, 100)
|
||||
})
|
||||
|
||||
// 监听viewMode和dataSource变化,刷新缓存和时间线
|
||||
watch(
|
||||
[viewMode, dataSource],
|
||||
() => {
|
||||
invalidateTaskDateRangeCache()
|
||||
debouncedUpdateTimelineRange()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 监听tasks变化,清理不再存在的任务的位置信息
|
||||
watch(
|
||||
() => tasks.value,
|
||||
@@ -4384,8 +4523,9 @@ const handleAddSuccessor = (task: Task) => {
|
||||
<!-- 同时需要考虑左侧TaskList包含1px的bottom border -->
|
||||
<div class="task-bar-container" :style="{ height: `${contentHeight}px` }">
|
||||
<div class="task-rows" :style="{ height: `${contentHeight}px` }">
|
||||
<!-- 使用虚拟滚动渲染可见任务 -->
|
||||
<!-- 任务视图:使用虚拟滚动渲染可见任务 -->
|
||||
<div
|
||||
v-if="viewMode === 'task'"
|
||||
v-for="{ task, originalIndex } in visibleTasks"
|
||||
:key="task.id"
|
||||
class="task-row"
|
||||
@@ -4536,6 +4676,96 @@ const handleAddSuccessor = (task: Task) => {
|
||||
</template>
|
||||
</TaskBar>
|
||||
</div>
|
||||
|
||||
<!-- 资源视图:一行渲染多个 TaskBar -->
|
||||
<div
|
||||
v-else-if="viewMode === 'resource'"
|
||||
v-for="{ task: resource, originalIndex } in visibleTasks"
|
||||
:key="resource.id"
|
||||
class="task-row resource-row"
|
||||
:class="{ 'task-row-hovered': hoveredTaskId === resource.id }"
|
||||
:style="{ top: `${originalIndex * 51}px` }"
|
||||
@mouseenter="handleTaskRowHover(resource.id)"
|
||||
@mouseleave="handleTaskRowHover(null)"
|
||||
>
|
||||
<!-- 为资源下的每个任务渲染 TaskBar -->
|
||||
<template v-if="(resource as any).tasks && (resource as any).tasks.length > 0">
|
||||
<TaskBar
|
||||
v-for="(task, taskIndex) in (resource as any).tasks"
|
||||
:key="`taskbar-${task.id}-${taskBarRenderKey}`"
|
||||
:task="task"
|
||||
:row-index="originalIndex"
|
||||
:row-height="51"
|
||||
:day-width="dayWidth"
|
||||
:start-date="
|
||||
currentTimeScale === TimelineScale.YEAR
|
||||
? getYearTimelineRange().startDate
|
||||
: currentTimeScale === TimelineScale.MONTH
|
||||
? getMonthTimelineRange().startDate
|
||||
: timelineConfig.startDate
|
||||
"
|
||||
:scroll-left="timelineScrollLeft"
|
||||
:container-width="timelineContainerWidth"
|
||||
:hide-bubbles="hideBubbles"
|
||||
:timeline-data="
|
||||
currentTimeScale === TimelineScale.HOUR ? optimizedTimelineData : timelineData
|
||||
"
|
||||
:current-time-scale="currentTimeScale"
|
||||
:task-bar-config="props.taskBarConfig"
|
||||
:allow-drag-and-resize="props.allowDragAndResize && !isInHighlightMode"
|
||||
:show-actual-taskbar="props.showActualTaskbar"
|
||||
:enable-task-bar-tooltip="props.enableTaskBarTooltip"
|
||||
:pending-task-background-color="props.pendingTaskBackgroundColor"
|
||||
:delay-task-background-color="props.delayTaskBackgroundColor"
|
||||
:complete-task-background-color="props.completeTaskBackgroundColor"
|
||||
:ongoing-task-background-color="props.ongoingTaskBackgroundColor"
|
||||
:is-highlighted="highlightedTaskIds.has(task.id)"
|
||||
:is-primary-highlight="highlightedTaskId === task.id"
|
||||
:is-in-highlight-mode="isInHighlightMode"
|
||||
:drag-link-mode="dragLinkMode"
|
||||
:is-link-drag-source="linkDragSourceTask?.id === task.id"
|
||||
:is-valid-link-target="
|
||||
linkDragTargetTask?.id === task.id && isValidLinkTarget === true
|
||||
"
|
||||
:is-invalid-link-target="
|
||||
linkDragTargetTask?.id === task.id && isValidLinkTarget === false
|
||||
"
|
||||
:all-tasks="tasks"
|
||||
:has-resource-conflict="resourceConflicts.get(resource.id)?.has(task.id) || false"
|
||||
:style="{
|
||||
zIndex: taskIndex,
|
||||
}"
|
||||
@update:task="updateTask"
|
||||
@bar-mounted="handleBarMounted"
|
||||
@click="handleTaskBarClick(task, $event)"
|
||||
@dblclick="handleTaskBarDoubleClick(task)"
|
||||
@drag-end="handleTaskBarDragEnd"
|
||||
@resize-end="handleTaskBarResizeEnd"
|
||||
@scroll-to-position="handleScrollToPosition"
|
||||
@context-menu="handleTaskBarContextMenu"
|
||||
@start-timer="handleStartTimer"
|
||||
@stop-timer="handleStopTimer"
|
||||
@add-predecessor="handleAddPredecessor"
|
||||
@add-successor="handleAddSuccessor"
|
||||
@delete="handleTaskDelete"
|
||||
@delete-link="handleDeleteLink"
|
||||
@long-press="setHighlightTask"
|
||||
@link-drag-start="handleLinkDragStart"
|
||||
@link-drag-move="handleLinkDragMove"
|
||||
@link-drag-end="handleLinkDragEnd"
|
||||
>
|
||||
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
|
||||
<slot name="custom-task-content" v-bind="barScope" />
|
||||
</template>
|
||||
<template
|
||||
v-if="$slots['task-bar-context-menu']"
|
||||
#task-bar-context-menu="contextMenuScope"
|
||||
>
|
||||
<slot name="task-bar-context-menu" v-bind="contextMenuScope" />
|
||||
</template>
|
||||
</TaskBar>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4886,6 +5116,18 @@ const handleAddSuccessor = (task: Task) => {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* 资源视图行样式 */
|
||||
.resource-row {
|
||||
/* 不使用flex,保持TaskBar的绝对定位 */
|
||||
position: absolute !important;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 51px !important;
|
||||
pointer-events: auto;
|
||||
z-index: 11;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.timeline-body .task-row-hovered {
|
||||
background-color: var(--gantt-bg-hover); /* 与TaskList保持一致的悬停背景色 */
|
||||
/* 降低层级,避免覆盖任务条等元素 */
|
||||
|
||||
@@ -94,6 +94,9 @@ const messages = {
|
||||
exportPdf: '导出 PDF',
|
||||
expandAll: '全部展开',
|
||||
collapseAll: '全部折叠',
|
||||
// v1.9.0 视图模式
|
||||
taskView: '任务视图',
|
||||
resourceView: '资源视图',
|
||||
language: '中文',
|
||||
languageTooltip: '选择语言',
|
||||
lightMode: '明亮模式',
|
||||
@@ -376,6 +379,9 @@ const messages = {
|
||||
exportPdf: 'Export PDF',
|
||||
expandAll: 'Expand All',
|
||||
collapseAll: 'Collapse All',
|
||||
// v1.9.0 视图模式
|
||||
taskView: 'Task View',
|
||||
resourceView: 'Resource View',
|
||||
language: 'English',
|
||||
languageTooltip: 'Select language',
|
||||
lightMode: 'Light Mode',
|
||||
|
||||
@@ -11,6 +11,7 @@ export { default as MilestonePoint } from './components/MilestonePoint.vue'
|
||||
export { default as MilestoneDialog } from './components/MilestoneDialog.vue'
|
||||
export { default as TaskRow } from './components/TaskList/taskRow/TaskRow.vue'
|
||||
export type { Task } from './models/classes/Task.ts' // 导出Task类型
|
||||
export { Resource } from './models/classes/Resource.ts' // v1.9.0 导出Resource类
|
||||
export { useMessage } from './composables/useMessage.ts' // 导出useMessage组合式函数
|
||||
|
||||
// 导出 composables 供外部使用
|
||||
@@ -23,6 +24,12 @@ export type {
|
||||
TaskListColumnType,
|
||||
ColumnFormatter,
|
||||
} from './models/configs/TaskListConfig'
|
||||
export type {
|
||||
ResourceListConfig,
|
||||
ResourceListColumnConfig,
|
||||
ResourceListColumnType,
|
||||
ResourceColumnFormatter,
|
||||
} from './models/configs/ResourceListConfig' // v1.9.0 导出资源配置类型
|
||||
export type { TaskBarConfig } from './models/configs/TaskBarConfig'
|
||||
export type { ToolbarConfig } from './models/configs/ToolbarConfig'
|
||||
|
||||
|
||||
91
src/models/classes/Resource.ts
Normal file
91
src/models/classes/Resource.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Task } from './Task'
|
||||
|
||||
/**
|
||||
* 资源类 (Resource Class)
|
||||
* 用于资源计划视图,代表可分配的人力或设备资源
|
||||
*
|
||||
* @version 1.9.0
|
||||
* @description 支持资源视图的核心数据模型,每个资源可以关联多个任务
|
||||
*/
|
||||
export class Resource {
|
||||
id: string | number
|
||||
name: string
|
||||
type?: string
|
||||
avatar?: string
|
||||
description?: string
|
||||
department?: string
|
||||
skills?: string[]
|
||||
utilization?: number
|
||||
tasks: Task[]
|
||||
[key: string]: unknown
|
||||
|
||||
constructor(data: {
|
||||
id: string | number
|
||||
name: string
|
||||
type?: string
|
||||
avatar?: string
|
||||
description?: string
|
||||
department?: string
|
||||
skills?: string[]
|
||||
utilization?: number
|
||||
tasks?: Task[]
|
||||
[key: string]: unknown
|
||||
}) {
|
||||
this.id = data.id
|
||||
this.name = data.name
|
||||
this.type = data.type
|
||||
this.avatar = data.avatar
|
||||
this.description = data.description
|
||||
this.department = data.department
|
||||
this.skills = data.skills
|
||||
this.utilization = data.utilization
|
||||
this.tasks = data.tasks || []
|
||||
|
||||
// 复制其他自定义属性
|
||||
Object.keys(data).forEach(key => {
|
||||
if (!(key in this)) {
|
||||
this[key] = data[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加任务到资源
|
||||
*/
|
||||
addTask(task: Task): void {
|
||||
if (!this.tasks.find(t => t.id === task.id)) {
|
||||
this.tasks.push(task)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从资源中移除任务
|
||||
*/
|
||||
removeTask(taskId: number | string): void {
|
||||
this.tasks = this.tasks.filter(t => t.id !== taskId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务数量
|
||||
*/
|
||||
getTaskCount(): number {
|
||||
return this.tasks.length
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算资源利用率(基于任务时间重叠)
|
||||
* @returns 利用率百分比 (0-100)
|
||||
*/
|
||||
calculateUtilization(): number {
|
||||
if (this.tasks.length === 0) return 0
|
||||
const baseUtilization = Math.min(this.tasks.length * 20, 100)
|
||||
return Math.round(baseUtilization)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新利用率
|
||||
*/
|
||||
updateUtilization(): void {
|
||||
this.utilization = this.calculateUtilization()
|
||||
}
|
||||
}
|
||||
80
src/models/configs/ResourceListConfig.ts
Normal file
80
src/models/configs/ResourceListConfig.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { Resource } from '../classes/Resource'
|
||||
|
||||
/**
|
||||
* 资源列表列类型枚举
|
||||
*/
|
||||
export type ResourceListColumnType =
|
||||
| 'name'
|
||||
| 'type'
|
||||
| 'department'
|
||||
| 'utilization'
|
||||
| 'taskCount'
|
||||
|
||||
/**
|
||||
* 资源列格式化函数类型
|
||||
* @param resource 当前资源对象
|
||||
* @param column 当前列配置
|
||||
* @returns 格式化后的字符串
|
||||
*/
|
||||
export type ResourceColumnFormatter = (
|
||||
resource: Resource,
|
||||
column: ResourceListColumnConfig,
|
||||
) => string
|
||||
|
||||
/**
|
||||
* 资源列表列配置接口
|
||||
*/
|
||||
export interface ResourceListColumnConfig {
|
||||
type?: ResourceListColumnType
|
||||
key: string // 用于国际化的key,也可以作为识别符
|
||||
label?: string // 显示标签
|
||||
cssClass?: string // CSS类名
|
||||
width?: number | string // 列宽度,支持像素(120)或百分比('15%')
|
||||
visible?: boolean // 是否显示,默认true
|
||||
formatter?: ResourceColumnFormatter // 自定义格式化函数(优先级低于 slot)
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源列表配置接口
|
||||
*/
|
||||
export interface ResourceListConfig {
|
||||
columns?: ResourceListColumnConfig[]
|
||||
showAllColumns?: boolean // 是否显示所有列,默认true
|
||||
defaultWidth?: number | string // 默认展开宽度,支持像素数字(如 320)或百分比字符串(如 '30%'),默认320px
|
||||
minWidth?: number | string // 最小宽度,支持像素数字(如 280)或百分比字符串(如 '20%'),默认280px
|
||||
maxWidth?: number | string // 最大宽度,支持像素数字(如 1160)或百分比字符串(如 '80%'),默认1160px
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认资源列表列配置
|
||||
*/
|
||||
export const DEFAULT_RESOURCE_LIST_COLUMNS: ResourceListColumnConfig[] = [
|
||||
{
|
||||
type: 'name',
|
||||
key: 'resourceName',
|
||||
label: '资源名称',
|
||||
cssClass: 'col-name',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
type: 'type',
|
||||
key: 'resourceType',
|
||||
label: '资源类型',
|
||||
cssClass: 'col-type',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
type: 'department',
|
||||
key: 'department',
|
||||
label: '部门',
|
||||
cssClass: 'col-department',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
type: 'utilization',
|
||||
key: 'utilization',
|
||||
label: '利用率',
|
||||
cssClass: 'col-utilization',
|
||||
visible: true,
|
||||
},
|
||||
]
|
||||
98
src/utils/resourceUtils.ts
Normal file
98
src/utils/resourceUtils.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Task } from '../models/classes/Task'
|
||||
import type { Resource } from '../models/classes/Resource'
|
||||
|
||||
/**
|
||||
* 检测两个任务的时间段是否重叠
|
||||
* @param task1 任务1
|
||||
* @param task2 任务2
|
||||
* @returns 是否重叠
|
||||
*/
|
||||
export function isTaskTimeOverlap(task1: Task, task2: Task): boolean {
|
||||
if (!task1.startDate || !task1.endDate || !task2.startDate || !task2.endDate) {
|
||||
return false
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// 时间段重叠判定:start1 < end2 && start2 < end1
|
||||
return start1 < end2 && start2 < end1
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测资源的任务列表中是否存在时间冲突
|
||||
* @param resource 资源对象
|
||||
* @returns 冲突的任务ID集合
|
||||
*/
|
||||
export function detectResourceConflicts(resource: Resource): Set<number> {
|
||||
const conflictTaskIds = 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++) {
|
||||
if (isTaskTimeOverlap(validTasks[i], validTasks[j])) {
|
||||
conflictTaskIds.add(validTasks[i].id)
|
||||
conflictTaskIds.add(validTasks[j].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflictTaskIds
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测所有资源的冲突情况
|
||||
* @param resources 资源列表
|
||||
* @returns Map<resourceId, Set<conflictTaskIds>>
|
||||
*/
|
||||
export function detectAllResourceConflicts(
|
||||
resources: Resource[],
|
||||
): Map<string | number, Set<number>> {
|
||||
const conflictsMap = new Map<string | number, Set<number>>()
|
||||
|
||||
resources.forEach(resource => {
|
||||
const conflicts = detectResourceConflicts(resource)
|
||||
if (conflicts.size > 0) {
|
||||
conflictsMap.set(resource.id, conflicts)
|
||||
}
|
||||
})
|
||||
|
||||
return conflictsMap
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算资源利用率(简化版:任务时长累加 / 时间窗口)
|
||||
* @param resource 资源对象
|
||||
* @param timeWindowDays 时间窗口(天数),默认30天
|
||||
* @returns 利用率百分比(0-100+)
|
||||
*/
|
||||
export function calculateResourceUtilization(
|
||||
resource: Resource,
|
||||
timeWindowDays = 30,
|
||||
): number {
|
||||
const tasks = resource.tasks || []
|
||||
if (tasks.length === 0) return 0
|
||||
|
||||
let totalTaskHours = 0
|
||||
|
||||
tasks.forEach(task => {
|
||||
if (task.startDate && task.endDate) {
|
||||
const start = new Date(task.startDate).getTime()
|
||||
const end = new Date(task.endDate).getTime()
|
||||
const durationMs = end - start
|
||||
const durationHours = durationMs / (1000 * 60 * 60)
|
||||
totalTaskHours += durationHours
|
||||
}
|
||||
})
|
||||
|
||||
// 假设每天工作8小时
|
||||
const availableHours = timeWindowDays * 8
|
||||
|
||||
return availableHours > 0 ? Math.round((totalTaskHours / availableHours) * 100) : 0
|
||||
}
|
||||
Reference in New Issue
Block a user