<template>
|
<div class="task-tree-container">
|
<!-- 任务树操作按钮 -->
|
<div class="tree-actions mb10">
|
<el-button type="primary" size="small" icon="Plus" @click="handleAddTask">添加任务</el-button>
|
<el-button type="success" size="small" icon="RefreshRight" @click="refreshTree">刷新</el-button>
|
<el-button type="info" size="small" icon="Filter" @click="toggleFilter">
|
{{ showFilter ? '隐藏筛选' : '显示筛选' }}
|
</el-button>
|
</div>
|
|
<!-- 筛选条件 -->
|
<div v-if="showFilter" class="filter-section mb10">
|
<el-form :inline="true" :model="filterParams">
|
<el-form-item label="任务状态">
|
<el-select v-model="filterParams.status" placeholder="全部" clearable style="width: 120px">
|
<el-option label="未开始" value="notStarted" />
|
<el-option label="进行中" value="inProgress" />
|
<el-option label="已完成" value="completed" />
|
<el-option label="已逾期" value="overdue" />
|
</el-select>
|
</el-form-item>
|
<el-form-item label="负责人">
|
<el-input v-model="filterParams.assignee" placeholder="输入负责人" clearable style="width: 150px" />
|
</el-form-item>
|
<el-form-item>
|
<el-button type="primary" size="small" @click="filterTree">筛选</el-button>
|
<el-button size="small" @click="resetFilter">重置</el-button>
|
</el-form-item>
|
</el-form>
|
</div>
|
|
<!-- 任务树 -->
|
<div class="tree-content">
|
<el-tree
|
v-loading="loading"
|
:data="taskTreeData"
|
:props="defaultProps"
|
:expand-on-click-node="false"
|
node-key="nodeId"
|
ref="treeRef"
|
@node-contextmenu="handleContextMenu"
|
@node-click="handleNodeClick"
|
>
|
<template #default="{ node, data }">
|
<!-- 节点内容 -->
|
<div class="tree-node-content" :class="{ 'phase-node': data.type === 'phase', 'task-node': data.type === 'task' }">
|
<!-- 节点图标 -->
|
<div class="node-icon">
|
<i v-if="data.type === 'phase'" class="el-icon-folder text-primary" />
|
<i v-else-if="data.status === 'completed'" class="el-icon-circle-check text-success" />
|
<i v-else-if="data.status === 'inProgress'" class="el-icon-circle-check text-primary" />
|
<i v-else-if="data.status === 'overdue'" class="el-icon-alarm-clock text-danger" />
|
<i v-else class="el-icon-circle-close text-gray-400" />
|
</div>
|
|
<!-- 节点标题和描述 -->
|
<div class="node-info">
|
<div class="node-title" :class="{ 'overdue-title': data.type === 'task' && data.status === 'overdue' }">
|
{{ node.label }}
|
<span v-if="data.type === 'task' && data.priority === 'high'" class="priority-tag">高优</span>
|
<span v-else-if="data.type === 'task' && data.priority === 'medium'" class="priority-tag medium">中优</span>
|
</div>
|
<div v-if="data.description" class="node-description">{{ data.description }}</div>
|
|
<!-- 任务元信息 -->
|
<div v-if="data.type === 'task'" class="task-meta">
|
<span class="meta-item">
|
<i class="el-icon-user"></i>
|
{{ data.assigneeName || '未分配' }}
|
</span>
|
<span class="meta-item">
|
<i class="el-icon-calendar"></i>
|
{{ formatDateRange(data.startDate, data.endDate) }}
|
</span>
|
</div>
|
</div>
|
|
<!-- 任务进度条 -->
|
<div v-if="data.type === 'task'" class="task-progress">
|
<el-progress :percentage="data.progress || 0" :stroke-width="4" :show-text="false" />
|
</div>
|
|
<!-- 操作按钮 -->
|
<div class="node-actions">
|
<el-button
|
v-if="data.type === 'task'"
|
type="text"
|
size="small"
|
icon="Edit"
|
@click.stop="handleEditTask(data)"
|
v-hasPermi="['oaSystem:task:edit']"
|
/>
|
<el-button
|
v-if="data.type === 'phase'"
|
type="text"
|
size="small"
|
icon="Plus"
|
@click.stop="handleAddTaskUnderPhase(data)"
|
v-hasPermi="['oaSystem:task:add']"
|
/>
|
<el-button
|
type="text"
|
size="small"
|
icon="Delete"
|
@click.stop="handleDeleteNode(data)"
|
v-hasPermi="['oaSystem:task:remove']"
|
/>
|
</div>
|
</div>
|
</template>
|
</el-tree>
|
</div>
|
|
<!-- 右键菜单 -->
|
<div v-if="showContextMenu" :style="contextMenuStyle" class="context-menu">
|
<el-menu @select="handleContextMenuSelect">
|
<el-menu-item v-if="selectedNode.type === 'task'" index="edit">编辑任务</el-menu-item>
|
<el-menu-item v-if="selectedNode.type === 'phase'" index="addTask">添加子任务</el-menu-item>
|
<el-menu-item index="delete">删除</el-menu-item>
|
<el-menu-item index="expandAll">展开全部</el-menu-item>
|
<el-menu-item index="collapseAll">收起全部</el-menu-item>
|
</el-menu>
|
</div>
|
|
<!-- 任务表单对话框 -->
|
<el-dialog :title="dialogTitle" v-model="dialogOpen" width="600px" append-to-body>
|
<el-form ref="taskFormRef" :model="taskForm" :rules="taskRules" label-width="80px">
|
<el-form-item label="任务名称" prop="taskName">
|
<el-input v-model="taskForm.taskName" placeholder="请输入任务名称" />
|
</el-form-item>
|
<el-form-item label="负责人" prop="assigneeId">
|
<el-input v-model="taskForm.assigneeId" placeholder="请输入负责人ID" />
|
</el-form-item>
|
<el-form-item label="开始日期" prop="startDate">
|
<el-date-picker
|
v-model="taskForm.startDate"
|
type="date"
|
placeholder="选择开始日期"
|
style="width: 100%"
|
/>
|
</el-form-item>
|
<el-form-item label="结束日期" prop="endDate">
|
<el-date-picker
|
v-model="taskForm.endDate"
|
type="date"
|
placeholder="选择结束日期"
|
style="width: 100%"
|
/>
|
</el-form-item>
|
<el-form-item label="优先级" prop="priority">
|
<el-select v-model="taskForm.priority" placeholder="选择优先级">
|
<el-option label="低" value="low" />
|
<el-option label="中" value="medium" />
|
<el-option label="高" value="high" />
|
</el-select>
|
</el-form-item>
|
<el-form-item label="进度" prop="progress">
|
<el-input-number v-model="taskForm.progress" :min="0" :max="100" style="width: 100%" />
|
</el-form-item>
|
<el-form-item label="描述" prop="description">
|
<el-input v-model="taskForm.description" type="textarea" placeholder="请输入任务描述" />
|
</el-form-item>
|
</el-form>
|
<template #footer>
|
<div class="dialog-footer">
|
<el-button @click="dialogOpen = false">取消</el-button>
|
<el-button type="primary" @click="submitTaskForm">确定</el-button>
|
</div>
|
</template>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, reactive, computed, watch, onMounted } from 'vue';
|
import { ElMessage, ElMessageBox, ElMenu, ElMenuItem } from 'element-plus';
|
// import { getProject, addTask, updateTask, deleteTask, deletePhase } from '@/api/oaSystem/projectManagement';
|
|
const props = defineProps({
|
projectId: {
|
type: String,
|
required: true
|
}
|
});
|
|
const emit = defineEmits(['refresh']);
|
|
// 组件状态
|
const loading = ref(false);
|
const treeRef = ref();
|
const showContextMenu = ref(false);
|
const contextMenuStyle = ref({});
|
const selectedNode = ref({});
|
const showFilter = ref(false);
|
const dialogOpen = ref(false);
|
const dialogTitle = ref('');
|
const taskFormRef = ref();
|
|
// 筛选参数
|
const filterParams = reactive({
|
status: '',
|
assignee: ''
|
});
|
|
// 任务表单数据
|
const taskForm = reactive({
|
taskId: undefined,
|
taskName: '',
|
description: '',
|
startDate: '',
|
endDate: '',
|
assigneeId: '',
|
assigneeName: '',
|
status: 'notStarted',
|
progress: 0,
|
priority: 'medium',
|
phaseId: '',
|
projectId: props.projectId
|
});
|
|
// 表单验证规则
|
const taskRules = {
|
taskName: [
|
{ required: true, message: '任务名称不能为空', trigger: 'blur' },
|
{ min: 2, max: 50, message: '任务名称长度在 2 到 50 个字符', trigger: 'blur' }
|
],
|
startDate: [
|
{ required: true, message: '开始日期不能为空', trigger: 'change' }
|
],
|
endDate: [
|
{ required: true, message: '结束日期不能为空', trigger: 'change' }
|
],
|
assigneeId: [
|
{ required: true, message: '负责人不能为空', trigger: 'blur' }
|
],
|
progress: [
|
{ required: true, message: '进度不能为空', trigger: 'blur' },
|
{ type: 'number', min: 0, max: 100, message: '进度必须在 0 到 100 之间', trigger: 'blur' }
|
]
|
};
|
|
// 任务树数据
|
const rawTaskTreeData = ref([]);
|
|
// 模拟任务数据
|
const mockTaskData = {
|
'PRJ2023001': [
|
{
|
phaseId: 'PHASE001',
|
phaseName: '需求分析',
|
startDate: '2023-11-01',
|
endDate: '2023-11-15',
|
status: 'completed',
|
tasks: [
|
{
|
taskId: 'TASK001',
|
taskName: '需求调研',
|
description: '调研用户需求和业务流程',
|
startDate: '2023-11-01',
|
endDate: '2023-11-05',
|
assigneeId: 'USER001',
|
assigneeName: '张三',
|
status: 'completed',
|
progress: 100,
|
priority: 'medium'
|
},
|
{
|
taskId: 'TASK002',
|
taskName: '需求文档编写',
|
description: '编写详细的需求规格说明书',
|
startDate: '2023-11-06',
|
endDate: '2023-11-15',
|
assigneeId: 'USER002',
|
assigneeName: '李四',
|
status: 'completed',
|
progress: 100,
|
priority: 'high'
|
}
|
]
|
},
|
{
|
phaseId: 'PHASE002',
|
phaseName: '系统设计',
|
startDate: '2023-11-16',
|
endDate: '2023-12-10',
|
status: 'completed',
|
tasks: [
|
{
|
taskId: 'TASK003',
|
taskName: '系统架构设计',
|
description: '设计系统整体架构',
|
startDate: '2023-11-16',
|
endDate: '2023-11-25',
|
assigneeId: 'USER003',
|
assigneeName: '王五',
|
status: 'completed',
|
progress: 100,
|
priority: 'high'
|
},
|
{
|
taskId: 'TASK004',
|
taskName: '数据库设计',
|
description: '设计数据库表结构和关系',
|
startDate: '2023-11-26',
|
endDate: '2023-12-10',
|
assigneeId: 'USER004',
|
assigneeName: '赵六',
|
status: 'completed',
|
progress: 100,
|
priority: 'medium'
|
}
|
]
|
},
|
{
|
phaseId: 'PHASE003',
|
phaseName: '开发实现',
|
startDate: '2023-12-11',
|
endDate: '2024-01-31',
|
status: 'inProgress',
|
tasks: [
|
{
|
taskId: 'TASK005',
|
taskName: '前端开发',
|
description: '开发用户界面和交互逻辑',
|
startDate: '2023-12-11',
|
endDate: '2024-01-15',
|
assigneeId: 'USER005',
|
assigneeName: '钱七',
|
status: 'inProgress',
|
progress: 70,
|
priority: 'high'
|
},
|
{
|
taskId: 'TASK006',
|
taskName: '后端开发',
|
description: '开发业务逻辑和API接口',
|
startDate: '2023-12-11',
|
endDate: '2024-01-20',
|
assigneeId: 'USER006',
|
assigneeName: '孙八',
|
status: 'inProgress',
|
progress: 60,
|
priority: 'high'
|
}
|
]
|
}
|
],
|
// 默认数据
|
default: [
|
{
|
phaseId: 'PHASE_DEFAULT1',
|
phaseName: '准备阶段',
|
startDate: '2023-01-01',
|
endDate: '2023-03-31',
|
status: 'completed',
|
tasks: [
|
{
|
taskId: 'TASK_DEFAULT1',
|
taskName: '项目启动',
|
description: '召开项目启动会议',
|
startDate: '2023-01-01',
|
endDate: '2023-01-05',
|
assigneeId: 'USER_DEFAULT1',
|
assigneeName: '负责人A',
|
status: 'completed',
|
progress: 100,
|
priority: 'high'
|
}
|
]
|
},
|
{
|
phaseId: 'PHASE_DEFAULT2',
|
phaseName: '执行阶段',
|
startDate: '2023-04-01',
|
endDate: '2023-09-30',
|
status: 'inProgress',
|
tasks: [
|
{
|
taskId: 'TASK_DEFAULT2',
|
taskName: '核心功能开发',
|
description: '开发系统核心功能模块',
|
startDate: '2023-04-01',
|
endDate: '2023-06-30',
|
assigneeId: 'USER_DEFAULT2',
|
assigneeName: '负责人B',
|
status: 'inProgress',
|
progress: 50,
|
priority: 'high'
|
}
|
]
|
}
|
]
|
};
|
|
const taskTreeData = computed(() => {
|
// 应用筛选条件
|
if (!showFilter.value || (!filterParams.status && !filterParams.assignee)) {
|
return rawTaskTreeData.value;
|
}
|
|
// 深拷贝原始数据以避免修改
|
const filteredData = JSON.parse(JSON.stringify(rawTaskTreeData.value));
|
|
// 递归筛选节点
|
const filterNodes = (nodes) => {
|
const result = [];
|
|
nodes.forEach(node => {
|
// 对于阶段节点,检查其子任务是否符合筛选条件
|
if (node.type === 'phase' && node.children) {
|
const filteredChildren = filterNodes(node.children);
|
if (filteredChildren.length > 0) {
|
// 保留至少有一个子任务符合条件的阶段
|
node.children = filteredChildren;
|
result.push(node);
|
}
|
}
|
// 对于任务节点,直接应用筛选条件
|
else if (node.type === 'task') {
|
const statusMatch = !filterParams.status || node.status === filterParams.status;
|
const assigneeMatch = !filterParams.assignee ||
|
(node.assigneeName && node.assigneeName.includes(filterParams.assignee));
|
|
if (statusMatch && assigneeMatch) {
|
result.push(node);
|
}
|
}
|
});
|
|
return result;
|
};
|
|
return filterNodes(filteredData);
|
});
|
|
// 树节点配置
|
const defaultProps = {
|
children: 'children',
|
label: (data) => {
|
if (data.type === 'phase') {
|
return `${data.phaseName}`;
|
} else {
|
return `${data.taskName}`;
|
}
|
}
|
};
|
|
// 加载任务树数据
|
const loadTaskTree = async () => {
|
loading.value = true;
|
// try {
|
// const { data } = await getProject(props.projectId);
|
// rawTaskTreeData.value = buildTaskTree(data.phases || []);
|
// } catch (error) {
|
// ElMessage.error('加载任务树失败');
|
// console.error('加载任务树失败:', error);
|
// } finally {
|
// loading.value = false;
|
// }
|
try {
|
// 模拟网络延迟
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
// 使用模拟数据替代API请求
|
const phases = mockTaskData[props.projectId] || mockTaskData.default;
|
rawTaskTreeData.value = buildTaskTree(phases);
|
} catch (error) {
|
ElMessage.error('加载任务树失败');
|
console.error('加载任务树失败:', error);
|
} finally {
|
loading.value = false;
|
}
|
};
|
|
// 构建任务树
|
const buildTaskTree = (phases) => {
|
return phases.map(phase => ({
|
nodeId: phase.phaseId,
|
phaseId: phase.phaseId,
|
phaseName: phase.phaseName,
|
type: 'phase',
|
children: (phase.tasks || []).map(task => ({
|
nodeId: task.taskId,
|
taskId: task.taskId,
|
taskName: task.taskName,
|
description: task.description,
|
startDate: task.startDate,
|
endDate: task.endDate,
|
assigneeId: task.assigneeId,
|
assigneeName: task.assigneeName,
|
status: task.status,
|
progress: task.progress,
|
priority: task.priority,
|
phaseId: task.phaseId,
|
projectId: props.projectId,
|
type: 'task'
|
}))
|
}));
|
};
|
|
// 格式化日期范围
|
const formatDateRange = (startDate, endDate) => {
|
if (!startDate || !endDate) return '';
|
return `${startDate} - ${endDate}`;
|
};
|
|
// 刷新树
|
const refreshTree = () => {
|
loadTaskTree();
|
// 通知父组件刷新数据
|
emit('refresh');
|
};
|
|
// 切换筛选面板
|
const toggleFilter = () => {
|
showFilter.value = !showFilter.value;
|
};
|
|
// 应用筛选
|
const filterTree = () => {
|
// 筛选逻辑已经在computed中实现
|
};
|
|
// 重置筛选
|
const resetFilter = () => {
|
filterParams.status = '';
|
filterParams.assignee = '';
|
};
|
|
// 处理节点点击
|
const handleNodeClick = (data, node) => {
|
// 切换展开/收起状态
|
if (data.type === 'phase') {
|
node.expanded = !node.expanded;
|
}
|
};
|
|
// 处理右键菜单
|
const handleContextMenu = (event, data) => {
|
event.preventDefault();
|
selectedNode.value = data;
|
contextMenuStyle.value = {
|
position: 'fixed',
|
left: `${event.clientX}px`,
|
top: `${event.clientY}px`,
|
zIndex: 1000
|
};
|
showContextMenu.value = true;
|
};
|
|
// 处理右键菜单选择
|
const handleContextMenuSelect = (index) => {
|
showContextMenu.value = false;
|
switch (index) {
|
case 'edit':
|
if (selectedNode.value.type === 'task') {
|
handleEditTask(selectedNode.value);
|
}
|
break;
|
case 'addTask':
|
if (selectedNode.value.type === 'phase') {
|
handleAddTaskUnderPhase(selectedNode.value);
|
}
|
break;
|
case 'delete':
|
handleDeleteNode(selectedNode.value);
|
break;
|
case 'expandAll':
|
treeRef.value?.expandAll();
|
break;
|
case 'collapseAll':
|
treeRef.value?.collapseAll();
|
break;
|
}
|
};
|
|
// 添加任务
|
const handleAddTask = () => {
|
resetTaskForm();
|
dialogTitle.value = '添加任务';
|
dialogOpen.value = true;
|
};
|
|
// 在指定阶段下添加任务
|
const handleAddTaskUnderPhase = (phase) => {
|
resetTaskForm();
|
taskForm.phaseId = phase.phaseId;
|
dialogTitle.value = '添加子任务';
|
dialogOpen.value = true;
|
};
|
|
// 编辑任务
|
const handleEditTask = (task) => {
|
resetTaskForm();
|
Object.assign(taskForm, { ...task });
|
dialogTitle.value = '编辑任务';
|
dialogOpen.value = true;
|
};
|
|
// 删除节点
|
const handleDeleteNode = async (node) => {
|
const confirmMessage = node.type === 'phase'
|
? `确定要删除阶段 "${node.phaseName}" 及其所有子任务吗?`
|
: `确定要删除任务 "${node.taskName}" 吗?`;
|
|
await ElMessageBox.confirm(confirmMessage, '确认操作', {
|
confirmButtonText: '确定',
|
cancelButtonText: '取消',
|
type: 'warning'
|
}).catch(() => {
|
throw new Error('取消删除');
|
});
|
|
try {
|
if (node.type === 'phase') {
|
await deletePhase(node.phaseId);
|
} else {
|
await deleteTask(node.taskId);
|
}
|
ElMessage.success('删除成功');
|
refreshTree();
|
} catch (error) {
|
if (error.message !== '取消删除') {
|
ElMessage.error('删除失败');
|
console.error('删除失败:', error);
|
}
|
}
|
};
|
|
// 重置任务表单
|
const resetTaskForm = () => {
|
taskForm.taskId = undefined;
|
taskForm.taskName = '';
|
taskForm.description = '';
|
taskForm.startDate = '';
|
taskForm.endDate = '';
|
taskForm.assigneeId = '';
|
taskForm.assigneeName = '';
|
taskForm.status = 'notStarted';
|
taskForm.progress = 0;
|
taskForm.priority = 'medium';
|
taskForm.phaseId = '';
|
taskForm.projectId = props.projectId;
|
|
if (taskFormRef.value) {
|
taskFormRef.value.resetFields();
|
}
|
};
|
|
// 提交任务表单
|
const submitTaskForm = async () => {
|
try {
|
await taskFormRef.value.validate();
|
|
if (taskForm.taskId) {
|
await updateTask(taskForm);
|
ElMessage.success('修改任务成功');
|
} else {
|
await addTask(taskForm);
|
ElMessage.success('添加任务成功');
|
}
|
|
dialogOpen.value = false;
|
refreshTree();
|
} catch (error) {
|
console.error('提交表单失败:', error);
|
}
|
};
|
|
// 点击其他区域关闭右键菜单
|
document.addEventListener('click', () => {
|
if (showContextMenu.value) {
|
showContextMenu.value = false;
|
}
|
});
|
|
// 监听项目ID变化
|
watch(() => props.projectId, (newProjectId) => {
|
if (newProjectId) {
|
loadTaskTree();
|
}
|
});
|
|
// 初始化
|
onMounted(() => {
|
loadTaskTree();
|
});
|
</script>
|
|
<style scoped>
|
.task-tree-container {
|
padding: 10px;
|
}
|
|
.tree-actions {
|
display: flex;
|
gap: 10px;
|
align-items: center;
|
}
|
|
.filter-section {
|
background: #f5f7fa;
|
padding: 10px;
|
border-radius: 4px;
|
}
|
|
.tree-content {
|
background: #fff;
|
border: 1px solid #ebeef5;
|
border-radius: 4px;
|
padding: 10px;
|
max-height: 600px;
|
overflow-y: auto;
|
}
|
|
.tree-node-content {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
padding: 5px 0;
|
min-height: 40px;
|
}
|
|
.phase-node {
|
font-weight: bold;
|
color: #409eff;
|
}
|
|
.task-node {
|
color: #606266;
|
}
|
|
.node-icon {
|
display: flex;
|
align-items: center;
|
width: 20px;
|
}
|
|
.node-info {
|
flex: 1;
|
min-width: 0;
|
}
|
|
.node-title {
|
font-weight: 500;
|
white-space: nowrap;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
margin-bottom: 2px;
|
}
|
|
.overdue-title {
|
color: #f56c6c;
|
font-weight: bold;
|
}
|
|
.priority-tag {
|
background: #f56c6c;
|
color: white;
|
font-size: 10px;
|
padding: 1px 4px;
|
border-radius: 2px;
|
margin-left: 5px;
|
}
|
|
.priority-tag.medium {
|
background: #e6a23c;
|
}
|
|
.node-description {
|
font-size: 12px;
|
color: #909399;
|
white-space: nowrap;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
}
|
|
.task-meta {
|
display: flex;
|
gap: 15px;
|
font-size: 12px;
|
color: #909399;
|
margin-top: 2px;
|
}
|
|
.meta-item {
|
display: flex;
|
align-items: center;
|
gap: 3px;
|
}
|
|
.task-progress {
|
width: 120px;
|
margin: 0 10px;
|
}
|
|
.node-actions {
|
display: flex;
|
gap: 5px;
|
opacity: 0;
|
transition: opacity 0.3s;
|
}
|
|
.tree-node-content:hover .node-actions {
|
opacity: 1;
|
}
|
|
.context-menu {
|
background: white;
|
border: 1px solid #ebeef5;
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
border-radius: 4px;
|
}
|
|
.context-menu .el-menu {
|
min-width: 120px;
|
border: none;
|
}
|
|
.context-menu .el-menu-item {
|
padding: 0 15px;
|
height: 36px;
|
line-height: 36px;
|
}
|
|
.context-menu .el-menu-item:hover {
|
background-color: #f5f7fa;
|
}
|
|
.text-gray-400 {
|
color: #c0c4cc;
|
}
|
</style>
|