<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="公司简称" prop="companyId">
<el-select v-model="queryParams.companyId" placeholder="请选择公司简称" clearable filterable
@change="handleCompanyChange" @focus="getCompanyList" :disabled="isBaseInfoDisabled">
<el-option v-for="item in companyOptions" :key="item.companyId" :label="item.companyName"
:value="item.companyId" />
</el-select>
</el-form-item>
<el-form-item label="项目名称" prop="projectId">
<el-select v-model="queryParams.projectId" placeholder="请选择项目名称" clearable filterable
:disabled="!queryParams.companyId || isBaseInfoDisabled" :loading="companyLoading" loading-text="加载中...">
<el-option v-for="item in projectOptions" :key="item.projectId" :label="item.projectName"
:value="item.projectId" />
<template #empty>
<div v-if="!companyLoading">暂无数据</div>
</template>
</el-select>
</el-form-item>
<el-form-item label="计划名称" prop="planName">
<el-input v-model="queryParams.planName" placeholder="请输入计划名称" clearable @keyup.enter="handleQuery"
:disabled="isViewModel" />
</el-form-item>
<el-form-item label="版本" prop="version">
<el-input v-model="queryParams.version" placeholder="" clearable @keyup.enter="handleQuery" disabled />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="queryParams.remark" placeholder="" clearable @keyup.enter="handleQuery"
:disabled="isViewModel" />
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8" v-if="!isViewModel">
<el-col :span="1.5">
<el-upload action="/dev-api/schedule/total/importProject" :before-upload="beforeUpload" :show-file-list="false"
:on-success="handleUploadSuccess" :on-error="handleUploadError" :headers="headerObj" :loading="uploadLoading">
<el-button type="primary" plain icon="Plus" v-hasPermi="['system:total:add']"
:loading="uploadLoading">导入project文件</el-button>
</el-upload>
</el-col>
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAddRow"
v-hasPermi="['system:total:add']">新增行</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="success" plain icon="Edit" :disabled="single" @click="handleAddSibling"
v-hasPermi="['system:total:edit']">增加同级节点</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="Download" @click="handleAddChild" :disabled="single"
v-hasPermi="['system:total:export']">增加子节点</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete"
v-hasPermi="['system:total:remove']">删除节点</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="visibleData" row-key="scheduleDetailId" ref="tableRef" height="400"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }" @selection-change="handleSelectionChange"
:check-strictly="true" virtual-scroll :row-height="50" :estimated-row-height="50">
<el-table-column type="selection" width="55" align="center" v-if="!isViewModel" />
<el-table-column label="主键" align="center" prop="scheduleDetailId" v-if="false" />
<el-table-column label="总计划id" align="center" prop="scheduleId" v-if="false" />
<el-table-column label="序号" align="center" prop="seqNum" />
<el-table-column label="等级" align="center">
<template #default="scope">
<span>{{ computeLevel(scope.row.seqNum) }}</span>
</template>
</el-table-column>
<el-table-column label="任务名称" align="center">
<template #default="scope">
<el-input v-model="scope.row.taskName" placeholder="请输入任务名称" :disabled="isViewModel" />
</template>
</el-table-column>
<el-table-column label="计划开始日期" align="center" width="180">
<template #default="scope">
<el-date-picker v-model="scope.row.planStartDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期"
style="width: 130px" :disabled="isViewModel" />
</template>
</el-table-column>
<el-table-column label="计划结束日期" align="center" width="180">
<template #default="scope">
<el-date-picker v-model="scope.row.planEndDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期"
style="width: 130px" :disabled="isViewModel" />
</template>
</el-table-column>
<el-table-column label="计划天数" align="center">
<template #default="scope">
<el-input v-model="scope.row.planDays" placeholder="请输入计划天数" :disabled="isViewModel" />
</template>
</el-table-column>
<el-table-column label="里程碑节点" align="center">
<template #default="scope">
<el-select v-model="scope.row.milestoneFlag" clearable placeholder="请选择" :disabled="isViewModel">
<el-option label="否" :value="0"></el-option>
<el-option label="是" :value="1"></el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="前置节点" align="center">
<template #default="scope">
<el-input v-model="scope.row.predecessorNodes" :disabled="isViewModel" />
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark">
<template #default="scope">
<el-input v-model="scope.row.remark" :disabled="isViewModel" />
</template>
</el-table-column>
</el-table>
<!-- 新增底部按钮区域 -->
<el-row :gutter="10" class="mt20" justify="center" v-if="!isViewModel">
<el-col :span="4">
<el-button type="info" plain icon="Back" @click="handleCancel">
返回
</el-button>
</el-col>
<el-col :span="4">
<el-button type="primary" plain icon="Upload" :loading="saveLoading" @click="handleSave">
保存数据
</el-button>
</el-col>
</el-row>
</div>
</template>
<script setup name="Total">
import { listTotal, getTotal, delTotal, addTotal, updateTotal } from "@/api/schedule/total"
import { queryCompanyInfo } from "@/api/project/info"
import { listCompany, listProject } from "@/api/base/base.js";
import { defineProps, defineEmits } from 'vue'
import { getToken } from '@/utils/auth'
import { computed } from 'vue'
// 新增虚拟滚动相关变量
const visibleData = ref([])
const startIndex = ref(0)
const visibleCount = ref(20) // 可视区域显示行数
// 新增变量控制父子节点选中状态是否关联
const checkStrictly = ref(true);
const scheduleId = ref(null)
const selectedRows = ref([]); // 存储选中的行对象
const tableRef = ref(null); // 表格引用
let lastFetchTime = 0
let isFetching = false;
const { proxy } = getCurrentInstance()
const totalList = ref([])
const open = ref(false)
const loading = ref(false)
const showSearch = ref(true)
const ids = ref([])
const single = ref(true)
const multiple = ref(true)
const total = ref(0)
const title = ref("")
const uploadLoading = ref(false)
// 定义props
const props = defineProps({
scheduleId: {
type: [Number, String],
default: null
},
type: {
type: String,
default: 'add'
},
viewModel: {
type: Boolean,
default: false
}
})
const state = ref()
//计算属性,判断是否为查看模式
const isViewModel = computed(() => {
return props.type === 'view' || props.viewModel
})
// 新增计算属性,判断基础信息是否可编辑
const isBaseInfoDisabled = computed(() => {
return props.type !== 'add'; // 只有新增模式可编辑
});
// 定义事件
const emit = defineEmits(['save-success', 'cancel'])
const data = reactive({
form: {},
queryParams: {
pageNum: 1,
pageSize: 10,
companyId: null,
projectId: null,
companyAbbr: null,
projectName: null,
planName: null,
version: null,
fileUrl: null,
documentStatus: null,
approvalStatus: null,
effectiveBy: null,
createdBy: null,
createdTime: null,
updatedBy: null,
updatedTime: null
},
rules: {
}
})
onMounted(() => {
// 初始化时解除父子节点选中关联
checkStrictly.value = false;
// 如果是新增模式,设置版本号为1
if (props.type === 'add') {
queryParams.value.version = 1;
}
if (props.scheduleId) {
scheduleId.value = props.scheduleId
loadDetailData()
}
})
// 获取token
const headerObj = ref({
Authorization: 'Bearer ' + getToken()
})
// 新增数据加载方法
const loadDetailData = async () => {
try {
loading.value = true;
const response = await getTotal(scheduleId.value);
const detailData = response.data;
// 新增字段映射转换
const mappedData = detailData.details.map(item => ({
...item,
scheduleDetailId: item.id, // 映射主键
scheduleId: detailData.scheduleId, // 补充计划ID
planStartDate: item.planStartDate ? item.planStartDate.split('T')[0] : '', // 转换日期
planEndDate: item.planEndDate ? item.planEndDate.split('T')[0] : ''
}));
totalList.value = mappedData;
// 填充基础信息
queryParams.value = {
companyId: detailData.companyId,
projectId: detailData.projectId,
planName: detailData.planName,
version: detailData.version,
remark: detailData.remark
};
companyOptions.value = [{
companyId: detailData.companyId,
companyName: detailData.companyName // 使用接口返回的公司名称
}];
projectOptions.value = [{
projectId: detailData.projectId,
projectName: detailData.projectName // 使用接口返回的项目名称
}];
// 填充计划明细
totalList.value = detailData.details || [];
// 关键修改:主动加载项目列表
if (detailData.companyId) {
// 先设置 companyId
queryParams.value.companyId = detailData.companyId;
// 再触发项目列表加载
await getProjectList(detailData.companyId);
// 最后设置 projectId(确保项目列表已加载)
queryParams.value.projectId = detailData.projectId;
}
} catch (error) {
proxy.$modal.msgError("加载详情数据失败");
} finally {
loading.value = false;
}
};
const { queryParams, form, rules } = toRefs(data)
// 新增公司列表相关状态
const companyOptions = ref([])
const companyLoading = ref(false)
const projectOptions = ref([])
//上传project文件
const beforeUpload = (file) => {
const isMPP = file.name.endsWith('.mpp');
debugger;
if (!isMPP) {
proxy.$modal.msgError('只能上传MPP格式文件!');
return false;
}
uploadLoading.value = true;
proxy.$modal.msgSuccess("请稍等,上传中...", { duration: 3000 })
return true;
};
const handleUploadSuccess = (response) => {
uploadLoading.value = false;
if (response.code === 200) {
queryParams.value.fileUrl = response.data.fileUrl;
// 正确更新ref值
totalList.value = processDataMapping(response.data.details);
// 强制触发视图更新
nextTick(() => {
tableRef.value?.clearSort();
});
proxy.$modal.msgSuccess("上传成功")
}
};
const formatDate = (dateString) => {
if (!dateString) return null;
// 处理ISO格式和短日期格式
return dateString.includes('T')
? dateString.split('T')[0]
: dateString;
};
// 处理数据映射
const processDataMapping = (data, parentSeq = "0", level = 1) => {
return data.map((item, index) => {
// 生成当前节点序号(基于父节点序号和当前索引)
const seqNum = parentSeq === "0"
? (index + 1).toString()
: `${parentSeq}.${index + 1}`;
const newItem = {
...item,
scheduleDetailId: item.scheduleDetailId,
planStartDate: formatDate(item.planStartDate),
planEndDate: formatDate(item.planEndDate),
// 添加序号和等级计算
seqNum,
priorityLevel: level, // 直接使用层级作为等级
// 添加计算等级字段(用于前端显示)
computedLevel: level
};
// 递归处理子节点
if (item.children?.length) {
newItem.children = processDataMapping(
item.children,
seqNum,
level + 1
);
}
return newItem;
});
};
const handleUploadError = (err, file, fileList) => {
uploadLoading.value = false;
proxy.$modal.msgError('上传失败: ' + err.message);
};
// 获取公司列表
async function getCompanyList() {
try {
companyLoading.value = true;
const response = await listCompany(); // 调用接口获取全部公司
companyOptions.value = response.data || [];
// 确保当前选中的公司存在选项中
if (queryParams.value.companyId && !companyOptions.value.some(c => c.companyId === queryParams.value.companyId)) {
companyOptions.value.push({
companyId: queryParams.value.companyId,
companyName: queryParams.value.companyName || '未知公司'
});
}
} catch (error) {
proxy.$modal.msgError("获取公司列表失败");
} finally {
companyLoading.value = false;
}
}
function handleCompanyChange(companyId) {
queryParams.value.projectId = null
queryParams.value.projectName = null
projectOptions.value = []
if (companyId) {
getProjectList(companyId)
}
}
// 获取公司下项目名称列表
async function getProjectList(companyId) {
if (Date.now() - lastFetchTime < 1000) return
if (isFetching) return
try {
isFetching = true
companyLoading.value = true
lastFetchTime = Date.now()
const response = await listProject({
companyId: companyId || queryParams.value.companyId
})
projectOptions.value = response.data || []
} catch (error) {
proxy.$modal.msgError("获取项目列表失败")
projectOptions.value = []
} finally {
isFetching = false
companyLoading.value = false
}
}
// 新增项目列表相关
const companyOption = ref([])
/** 查询总工期计划列表 */
function getList() {
loading.value = true
listTotal(queryParams.value).then(response => {
totalList.value = response.rows
total.value = response.total
loading.value = false
})
}
// 取消按钮
function cancel() {
open.value = false
reset()
}
// 表单重置
function reset() {
form.value = {
scheduleId: null,
companyId: null,
companyAbbr: null,
projectId: null,
planName: null,
version: null,
documentStatus: null,
approvalStatus: null,
effectiveBy: null,
remark: null,
createdBy: null,
createdTime: null,
updatedBy: null,
updatedTime: null
}
proxy.resetForm("totalRef")
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.value.pageNum = 1
getList()
}
/** 重置按钮操作 */
function resetQuery() {
proxy.resetForm("queryRef")
handleQuery()
}
// 处理选择事件
const handleSelect = (selection, row) => {
// 关键修改:确保只选中当前行,不选中子节点
if (selection.includes(row)) {
// 取消所有子节点的选中状态
if (row.children && row.children.length > 0) {
const toggleChildrenSelection = (children) => {
children.forEach(child => {
tableRef.value.toggleRowSelection(child, false);
if (child.children && child.children.length > 0) {
toggleChildrenSelection(child.children);
}
});
};
toggleChildrenSelection(row.children);
}
}
};
// 多选框选中数据
function handleSelectionChange(selection) {
ids.value = selection.map(item => item.scheduleId)
selectedRows.value = selection;
single.value = selection.length != 1
multiple.value = !selection.length
}
/** 新增按钮操作 */
function handleAdd() {
reset()
open.value = true
title.value = "添加总工期计划"
}
/** 修改按钮操作 */
function handleUpdate(row) {
reset()
const _scheduleId = row.scheduleId || ids.value
getTotal(_scheduleId).then(response => {
form.value = response.data
open.value = true
title.value = "修改总工期计划"
})
}
/** 提交按钮 */
function submitForm() {
proxy.$refs["totalRef"].validate(valid => {
if (valid) {
if (form.value.scheduleId != null) {
updateTotal(form.value).then(response => {
proxy.$modal.msgSuccess("修改成功")
open.value = false
getList()
})
} else {
addTotal(form.value).then(response => {
proxy.$modal.msgSuccess("新增成功")
open.value = false
getList()
})
}
}
})
}
/** 删除按钮操作 */
function handleDelete(row) {
if (!selectedRows.value.length) {
proxy.$modal.msgError("请选择要删除的节点");
return;
}
proxy.$modal.confirm('确认删除选中节点及其子节点?').then(() => {
// 创建新数组避免直接修改原数组
const newList = removeNodes(totalList.value, selectedRows.value);
// 更新列表数据
totalList.value = newList;
// 清空选中状态
selectedRows.value = [];
single.value = true;
multiple.value = true;
}).then(() => {
proxy.$modal.msgSuccess("删除成功");
// getList(); // 刷新数据保持前后端一致
}).catch(() => { });
}
/** 导出按钮操作 */
function handleExport() {
proxy.download('system/total/export', {
...queryParams.value
}, `total_${new Date().getTime()}.xlsx`)
}
// 添加计算等级的方法
const computeLevel = (seqNum) => {
if (!seqNum) return 1;
return (seqNum.split('.').length)
}
// 添加根节点
const handleAddRow = () => {
if (!queryParams.value.companyId) {
proxy.$modal.msgError("请先选择公司")
return
}
const newRow = {
scheduleDetailId: Date.now(),
scheduleId: queryParams.value.companyId,
seqNum: String(totalList.value.length + 1),
priorityLevel: '',
taskName: '',
planStartDate: '',
planEndDate: '',
planDays: '',
milestoneFlag: '',
predecessorNodes: '',
remark: '',
children: []
}
totalList.value.push(newRow)
totalList.value = [...totalList.value] // 触发响应式更新
}
// 添加子节点处理函数
const handleAddChild = () => {
if (!selectedRows.value.length) {
proxy.$modal.msgError("请选择一行父节点");
return;
}
const parent = selectedRows.value[0];
if (typeof parent.seqNum === 'undefined') {
proxy.$modal.msgError("父节点缺少序号信息");
return;
}
const newChild = {
scheduleDetailId: Date.now(),
scheduleId: parent.scheduleId,
seqNum: generateChildSeqNum(parent.seqNum, parent),
priorityLevel: '',
taskName: '',
planStartDate: '',
planEndDate: '',
planDays: '',
milestoneFlag: '',
predecessorNodes: '',
remark: '',
children: []
};
// 添加子节点到父节点
if (!parent.children) {
parent.children = [];
}
parent.children.push(newChild);
// 触发视图更新
totalList.value = [...totalList.value];
// 展开父节点
nextTick(() => {
if (tableRef.value) {
tableRef.value.toggleRowExpansion(parent, true);
} else {
console.error('表格组件引用未初始化');
// 备用方案:强制刷新表格
totalList.value = JSON.parse(JSON.stringify(totalList.value));
}
});
};
// 生成子节点序号
function generateChildSeqNum(parentSeq, parentNode) {
const seqStr = (parentSeq || '0').toString();
const parts = seqStr.split('.').map(Number);
// 获取父节点现有的子节点数量
const childCount = parentNode.children ? parentNode.children.length : 0;
// 在父级序号基础上追加新层级,序号基于现有子节点数量+1
return [...parts, childCount + 1].join('.');
}
/** 添加同级节点 */
const handleAddSibling = () => {
if (!selectedRows.value.length) {
proxy.$modal.msgError("请选择一行节点");
return;
}
const currentNode = selectedRows.value[0];
if (typeof currentNode.seqNum === 'undefined') {
proxy.$modal.msgError("节点缺少序号信息");
return;
}
// 查找父节点
const parentNode = findParentNode(currentNode, totalList.value);
// 生成同级序号
const newSeqNum = generateSiblingSeqNum(currentNode.seqNum);
// 创建新节点
const newNode = {
scheduleDetailId: Date.now(),
scheduleId: currentNode.scheduleId,
seqNum: newSeqNum,
priorityLevel: '',
taskName: '',
planStartDate: '',
planEndDate: '',
planDays: '',
milestoneFlag: '',
predecessorNodes: '',
remark: '',
children: []
};
// 添加到正确位置
if (parentNode) {
parentNode.children.push(newNode);
} else {
totalList.value.push(newNode);
}
// 触发视图更新
totalList.value = [...totalList.value];
// 展开父节点(如果有)
nextTick(() => {
if (parentNode && tableRef.value) {
tableRef.value.toggleRowExpansion(parentNode, true);
}
});
};
/** 查找父节点 */
function findParentNode(targetNode, nodes, parent = null) {
for (const node of nodes) {
if (node === targetNode) return parent;
if (node.children?.length) {
const found = findParentNode(targetNode, node.children, node);
if (found) return found;
}
}
return null;
}
function generateSiblingSeqNum(currentSeq) {
const seqStr = String(currentSeq || '0'); // 处理 undefined/null
const currentParts = seqStr.split('.').map(Number);
const parentParts = currentParts.slice(0, -1);
const level = currentParts.length;
let maxNum = 0;
// 递归遍历查找同级节点
const traverse = (nodes) => {
for (const node of nodes) {
const nodeParts = node.seqNum.split('.').map(Number);
const isSameParent = parentParts.every((v, i) => nodeParts[i] === v);
const isSameLevel = nodeParts.length === level;
if (isSameParent && isSameLevel) {
maxNum = Math.max(maxNum, nodeParts[level - 1]);
}
if (node.children?.length) {
traverse(node.children);
}
}
};
traverse(totalList.value);
return [...parentParts, maxNum + 1].join('.');
}
/**
* 递归删除节点及其子节点
* @param {Array} nodes 当前节点列表
* @param {Array} targets 要删除的节点列表
* @returns 新节点列表
*/
function removeNodes(nodes, targets) {
return nodes.filter(node => {
// 如果当前节点在删除列表中,跳过(即删除)
if (isNodeInTargets(node, targets)) {
return false;
}
// 如果有子节点,递归处理
if (node.children?.length) {
node.children = removeNodes(node.children, targets);
}
return true;
});
}
/**
* 检查节点是否在目标列表中
*/
function isNodeInTargets(node, targets) {
return targets.some(target =>
target.scheduleDetailId === node.scheduleDetailId
);
}
// 新增保存加载状态
const saveLoading = ref(false)
/** 保存按钮操作 */
const handleSave = async () => {
try {
saveLoading.value = true;
// 递归展开树形数据
const flattenTree = (nodes) => {
let result = [];
nodes.forEach(node => {
result.push({
scheduleDetailId: node.scheduleDetailId,
seqNum: node.seqNum,
priorityLevel: computeLevel(node.seqNum),
taskName: node.taskName,
planStartDate: node.planStartDate,
planEndDate: node.planEndDate,
planDays: node.planDays,
milestoneFlag: node.milestoneFlag,
predecessorNodes: node.predecessorNodes,
remark: node.remark
});
if (node.children?.length) {
result = result.concat(flattenTree(node.children));
}
});
return result;
};
// 构造提交数据
const submitData = {
scheduleId: Array.isArray(scheduleId.value) ? scheduleId.value[0] : scheduleId.value,
companyId: queryParams.value.companyId,
projectId: queryParams.value.projectId,
planName: queryParams.value.planName,
version: queryParams.value.version,
remark: queryParams.value.remark,
fileUrl: queryParams.value.fileUrl,
details: flattenTree(totalList.value) // 递归处理所有节点
};
if (props.type === 'add') {
await addTotal(submitData);
proxy.$modal.msgSuccess("保存成功");
} else if (props.type === 'edit') {
await updateTotal(submitData);
proxy.$modal.msgSuccess("修改成功");
}
emit('save-success')
} catch (error) {
console.error('保存失败:', error);
} finally {
saveLoading.value = false;
}
};
// 计算当前可视数据
const updateVisibleData = () => {
// 扁平化树形数据用于虚拟滚动
const flatData = flattenTree(totalList.value)
visibleData.value = flatData.slice(
startIndex.value,
startIndex.value + visibleCount.value
)
}
// 树形数据扁平化
const flattenTree = (nodes) => {
const result = []
const stack = [...nodes]
while (stack.length) {
const node = stack.shift()
result.push(node)
if (node.children && node.children.length) {
stack.unshift(...node.children) // 保持子节点顺序
}
}
return result
}
// 监听滚动事件更新数据
const handleScroll = ({ scrollTop }) => {
const rowHeight = 50
startIndex.value = Math.floor(scrollTop / rowHeight)
updateVisibleData()
}
/** 返回列表 */
const handleCancel = () => {
emit('cancel')
}
</script>
<style scoped>
.footer-buttons {
margin-top: 40px;
position: sticky;
bottom: 20px;
background: white;
padding: 10px 0;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
z-index: 100;
width: 100%;
}
</style>为什么一行数据都显示不出来了