新增项目综合看板功能,聚合展示项目、任务、进度主表和步骤数据
- 新增后端聚合接口 GET /oa/project/dashboard/{projectId}
- 新增前端看板页面,包含项目列表、任务表格和进度导图
- 优化思维导图组件,支持看板模式下的紧凑展示
- 新增进度明细表格视图和状态图例
- 实现任务与进度步骤的关联展示
- 添加项目模糊搜索功能
512 lines
17 KiB
Vue
512 lines
17 KiB
Vue
<template>
|
||
<div class="xmind-box" :class="{ 'xmind-box--dashboard': dashboardMode }">
|
||
<div class="action-panel">
|
||
<el-button type="primary" :size="dashboardMode ? 'mini' : 'small'" icon="el-icon-refresh" @click="handleRefresh">刷新</el-button>
|
||
<!-- <el-button type="primary" icon="el-icon-view" @click="handleRefresh">详情</el-button>
|
||
<el-button type="primary" icon="el-icon-edit" @click="handleRefresh">编辑</el-button>
|
||
<el-button type="primary" icon="el-icon-folder" @click="previewFiles(currentNode)">文件</el-button>
|
||
<el-button type="primary" icon="el-icon-picture" @click="previewImages(currentNode)">图片</el-button> -->
|
||
</div>
|
||
<div class="xmind-container" ref="chart" :style="containerStyle"></div>
|
||
<!-- 新增:三级节点点击弹窗-查看完整信息 -->
|
||
<el-dialog title="节点详情信息" :visible.sync="dialogVisible" width="1200px" center append-to-body>
|
||
<el-form>
|
||
<el-row :gutter="10">
|
||
<el-col :span="6">
|
||
<el-form-item label="进度类别" prop="name">
|
||
<el-input v-model="currentNode.tabNode" placeholder="请输入进度类型"></el-input>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-form-item label="一级分类" prop="firstLevelNode">
|
||
<el-input v-model="currentNode.firstLevelNode" placeholder="请输入一级分类"></el-input>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="步骤名称" prop="secondLevelNode">
|
||
<el-input v-model="currentNode.secondLevelNode" placeholder="请输入步骤名称"></el-input>
|
||
</el-form-item>
|
||
</el-col>
|
||
|
||
<el-col :span="12">
|
||
<el-form-item label="负责人" prop="nodeHeader">
|
||
<el-input v-model="currentNode.nodeHeader" placeholder="请输入负责人"></el-input>
|
||
</el-form-item>
|
||
</el-col>
|
||
|
||
<el-col :span="12">
|
||
<el-form-item label="规格需求" prop="specification">
|
||
<el-input v-model="currentNode.specification" placeholder="规格需求"></el-input>
|
||
</el-form-item>
|
||
</el-col>
|
||
|
||
<el-col :span="24">
|
||
<el-form-item label="成果资料" prop="relatedDocs">
|
||
<file-upload @success="handleFileSuccess" @delete="handleFileDelete" v-model="currentNode.relatedDocs"
|
||
placeholder="成果资料"></file-upload>
|
||
</el-form-item>
|
||
</el-col>
|
||
|
||
<el-col :span="24">
|
||
<el-form-item label="相关图片" prop="relatedImages">
|
||
<image-upload v-model="currentNode.relatedImages" placeholder="相关图片"></image-upload>
|
||
</el-form-item>
|
||
</el-col>
|
||
|
||
<el-col :span="24">
|
||
<el-form-item label="需求资料" prop="requirementFile">
|
||
<file-upload @success="handleFileSuccess" @delete="handleFileDelete" v-model="currentNode.requirementFile"
|
||
placeholder="需求资料"></file-upload>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
</el-form>
|
||
<!-- <div class="dialog-content">
|
||
<div class="node-title">{{ currentNode.name }}</div>
|
||
<div class="node-info-item" v-for="(val, key) in currentNode.value" :key="key">
|
||
<span class="label">{{ key }}:</span>
|
||
<span class="value">{{ val || '无' }}</span>
|
||
</div>
|
||
</div> -->
|
||
<template #footer>
|
||
<span class="dialog-footer">
|
||
<el-button type="primary" @click="handleSubmit">提交修改</el-button>
|
||
<el-button type="primary" @click="dialogVisible = false">关闭</el-button>
|
||
</span>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import { addFileOperationRecord } from '@/api/oa/fileOperationRecord';
|
||
import { updateProjectScheduleStep } from "@/api/oa/projectScheduleStep";
|
||
import * as echarts from 'echarts';
|
||
|
||
export default {
|
||
name: "Xmind",
|
||
props: {
|
||
list: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
/** 容器高度,综合看板等场景可传 100% 以撑满父级 */
|
||
height: {
|
||
type: String,
|
||
default: '800px'
|
||
},
|
||
/** 综合看板:紧凑、防重叠、三色状态、小圆点 */
|
||
dashboardMode: {
|
||
type: Boolean,
|
||
default: false
|
||
}
|
||
},
|
||
computed: {
|
||
containerStyle () {
|
||
return {
|
||
width: '100%',
|
||
height: this.height,
|
||
minHeight: this.dashboardMode ? '300px' : '240px'
|
||
}
|
||
}
|
||
},
|
||
data () {
|
||
return {
|
||
chartInstance: null, // 保存图表实例,用于后续重绘/销毁
|
||
dialogVisible: false, // 新增:弹窗显示隐藏开关
|
||
currentNode: {}, // 新增:存储当前点击的三级节点完整数据
|
||
clickEvent: null, // 新增:存储点击事件句柄,用于销毁解绑
|
||
users: [],
|
||
supplierList: [],
|
||
};
|
||
},
|
||
watch: {
|
||
// 监听列表数据变化,自动更新图表
|
||
list: {
|
||
deep: true,
|
||
handler () {
|
||
if (this.chartInstance) {
|
||
this.initChart();
|
||
}
|
||
}
|
||
}
|
||
},
|
||
mounted () {
|
||
this.initChart();
|
||
this.$nextTick(() => this.resizeChart());
|
||
window.addEventListener('resize', this.resizeChart);
|
||
},
|
||
beforeDestroy () {
|
||
window.removeEventListener('resize', this.resizeChart);
|
||
// 新增:解绑Echarts点击事件,防止内存泄漏
|
||
if (this.chartInstance && this.clickEvent) {
|
||
this.chartInstance.off('click', this.clickEvent);
|
||
}
|
||
// 销毁图表实例,防止内存泄漏
|
||
this.chartInstance?.dispose();
|
||
},
|
||
methods: {
|
||
// 优化:增加防抖处理-窗口自适应,避免频繁触发
|
||
resizeChart () {
|
||
this.chartInstance?.resize()
|
||
},
|
||
|
||
handleSubmit () {
|
||
updateProjectScheduleStep(this.currentNode).then(response => {
|
||
this.$modal.msgSuccess("修改成功");
|
||
this.dialogVisible = false;
|
||
this.handleRefresh();
|
||
});
|
||
},
|
||
|
||
handleRefresh () {
|
||
this.$emit('refresh')
|
||
},
|
||
|
||
handleFileSuccess (resList, res) {
|
||
addFileOperationRecord({
|
||
fileId: res.ossId,
|
||
fileName: res.name,
|
||
type: 1,
|
||
projectId: this.currentNode.projectId,
|
||
trackId: this.currentNode.trackId,
|
||
})
|
||
console.log(this.currentNode, this.currentNode.relatedDocs);
|
||
updateProjectScheduleStep({
|
||
...this.currentNode,
|
||
relatedDocs: this.currentNode.relatedDocs,
|
||
})
|
||
},
|
||
|
||
handleFileDelete (res) {
|
||
addFileOperationRecord({
|
||
fileId: res.ossId,
|
||
fileName: res.name,
|
||
type: 2,
|
||
projectId: this.currentNode.projectId,
|
||
trackId: this.currentNode.trackId,
|
||
})
|
||
console.log(this.currentNode, this.currentNode.relatedDocs);
|
||
updateProjectScheduleStep({
|
||
...this.currentNode,
|
||
relatedDocs: this.currentNode.relatedDocs,
|
||
})
|
||
},
|
||
|
||
/** 看板长标签按字折行(与折线图区域可读性一致) */
|
||
wrapLabelText (text, maxCharsPerLine) {
|
||
if (!text) return ''
|
||
const max = Math.max(4, maxCharsPerLine || 16)
|
||
if (text.length <= max) return text
|
||
const lines = []
|
||
for (let i = 0; i < text.length; i += max) {
|
||
lines.push(text.slice(i, i + max))
|
||
}
|
||
return lines.join('\n')
|
||
},
|
||
|
||
// Tab节点 → 一级节点 → 二级节点(叶子带业务数据)
|
||
transformToTreeData (list) {
|
||
if (!list.length) return { name: '暂无项目数据', children: [] };
|
||
|
||
const dm = this.dashboardMode
|
||
const projectName = list[0].projectName || '项目进度树图';
|
||
const tabMap = new Map();
|
||
|
||
list.forEach(item => {
|
||
const tab = item.tabNode || '默认分组';
|
||
const firstLevel = item.firstLevelNode || '未分类一级节点';
|
||
const secondLevel = item.secondLevelNode || item.stepName || '未命名节点';
|
||
const st = Number(item.status);
|
||
let statusText = '进行中';
|
||
let statusColor = '#E6A23C';
|
||
let lineToNode = { color: '#dcdfe6', width: 1.2 };
|
||
if (st === 2) {
|
||
statusText = '已完成';
|
||
statusColor = '#67C23A';
|
||
lineToNode = { color: '#67C23A', width: 1.8 };
|
||
} else if (st === 0) {
|
||
statusText = '未开始';
|
||
statusColor = '#909399';
|
||
} else if (st === 1) {
|
||
statusText = '待验收/进行中';
|
||
statusColor = '#E6A23C';
|
||
} else if (st === 3) {
|
||
statusText = '暂停';
|
||
statusColor = '#909399';
|
||
}
|
||
|
||
const nodeData = {
|
||
name: secondLevel,
|
||
/* 看板:小圆点 + 白边,贴近折线图主色风格 */
|
||
itemStyle: dm
|
||
? { color: statusColor, borderColor: '#ffffff', borderWidth: 1.25, shadowBlur: 3, shadowColor: 'rgba(0,0,0,0.12)' }
|
||
: { color: statusColor, borderColor: statusColor },
|
||
label: { color: statusColor },
|
||
lineStyle: lineToNode,
|
||
value: {
|
||
...item,
|
||
statusLabel: statusText
|
||
}
|
||
};
|
||
|
||
if (!tabMap.has(tab)) {
|
||
tabMap.set(tab, new Map());
|
||
}
|
||
const firstMap = tabMap.get(tab);
|
||
if (!firstMap.has(firstLevel)) {
|
||
firstMap.set(firstLevel, []);
|
||
}
|
||
firstMap.get(firstLevel).push(nodeData);
|
||
});
|
||
|
||
const treeChildren = Array.from(tabMap).map(([tabName, firstMap]) => ({
|
||
name: tabName,
|
||
itemStyle: dm
|
||
? { color: '#606266', borderColor: '#ffffff', borderWidth: 1, shadowBlur: 2, shadowColor: 'rgba(0,0,0,0.06)' }
|
||
: { color: '#606266', borderColor: '#dcdfe6' },
|
||
lineStyle: { color: '#c0c4cc', width: 1.2 },
|
||
children: Array.from(firstMap).map(([firstName, children]) => ({
|
||
name: firstName,
|
||
itemStyle: dm
|
||
? { color: '#303133', borderColor: '#ffffff', borderWidth: 1, shadowBlur: 2, shadowColor: 'rgba(0,0,0,0.06)' }
|
||
: { color: '#303133', borderColor: '#dcdfe6' },
|
||
lineStyle: { color: '#c0c4cc', width: 1.2 },
|
||
children
|
||
}))
|
||
}));
|
||
|
||
return {
|
||
name: projectName,
|
||
symbolSize: dm ? 7 : undefined,
|
||
itemStyle: dm
|
||
? { color: '#409eff', borderColor: '#ffffff', borderWidth: 1.5, shadowBlur: 4, shadowColor: 'rgba(64,158,255,0.35)' }
|
||
: { color: '#409eff', borderColor: '#409eff' },
|
||
lineStyle: { color: '#a0cfff', width: 1.5 },
|
||
label: dm ? { distance: 8, fontSize: 11 } : undefined,
|
||
children: treeChildren
|
||
};
|
||
},
|
||
|
||
// 初始化图表
|
||
initChart () {
|
||
// 初始化图表实例
|
||
if (!this.chartInstance) {
|
||
this.chartInstance = echarts.init(this.$refs.chart);
|
||
}
|
||
// 重要:先解绑已有点击事件,防止多次绑定导致弹窗多次触发
|
||
if (this.clickEvent) {
|
||
this.chartInstance.off('click', this.clickEvent);
|
||
}
|
||
// 转换数据格式
|
||
const treeData = this.transformToTreeData(this.list);
|
||
const dm = this.dashboardMode;
|
||
const escapeHtml = (s) => String(s == null ? '' : s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
const option = {
|
||
backgroundColor: dm ? 'transparent' : undefined,
|
||
tooltip: {
|
||
trigger: 'item',
|
||
enterable: true,
|
||
confine: true,
|
||
extraCssText: 'max-width:420px;white-space:normal;word-break:break-word;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.08);',
|
||
formatter: (params) => {
|
||
const data = params.data
|
||
const title = (data && data.name) != null ? data.name : (params.name || '')
|
||
let tip = `<div style="font-size:13px;font-weight:600;margin-bottom:6px;line-height:1.45;color:#303133">${escapeHtml(title)}</div>`
|
||
if (data && data.value) {
|
||
const v = data.value
|
||
tip += `<div style="font-size:12px;line-height:1.55;color:#606266">负责人:${v.nodeHeader != null && v.nodeHeader !== '' ? escapeHtml(v.nodeHeader) : '无'}</div>`
|
||
tip += `<div style="font-size:12px;line-height:1.55;color:#606266">规格需求:${v.specification != null && v.specification !== '' ? escapeHtml(v.specification) : '无'}</div>`
|
||
tip += `<div style="font-size:12px;line-height:1.55;color:#606266">状态:${v.statusLabel != null && v.statusLabel !== '' ? escapeHtml(v.statusLabel) : '无'}</div>`
|
||
tip += `<div style="font-size:12px;line-height:1.55;color:#606266">计划完成:${v.planEnd != null && v.planEnd !== '' ? escapeHtml(v.planEnd) : '无'}</div>`
|
||
}
|
||
return tip
|
||
}
|
||
},
|
||
series: [
|
||
{
|
||
type: 'tree',
|
||
data: [treeData],
|
||
/* 与折线图区域一致:留白、白底在容器上 */
|
||
...(dm ? { left: '1%', right: '5%', top: '2%', bottom: '2%' } : {}),
|
||
symbol: 'circle',
|
||
...(dm ? {} : { symbolSize: 6 }),
|
||
edgeShape: dm ? 'polyline' : 'curve',
|
||
edgeForkPosition: dm ? '74%' : '50%',
|
||
orient: 'LR',
|
||
initialTreeDepth: 4,
|
||
roam: true,
|
||
scaleLimit: dm ? { min: 0.22, max: 5 } : undefined,
|
||
label: {
|
||
show: true,
|
||
fontSize: dm ? 11 : 12,
|
||
fontWeight: 400,
|
||
position: 'left',
|
||
verticalAlign: 'middle',
|
||
...(dm ? { align: 'right' } : {}),
|
||
distance: 8,
|
||
overflow: 'none',
|
||
lineHeight: dm ? 15 : 14,
|
||
color: dm ? '#606266' : undefined
|
||
},
|
||
/*
|
||
* levels[i]:根下一层起 Tab / 分类 / 叶;缩小状态圆点,叶节点标签放右侧防挤压
|
||
*/
|
||
levels: dm
|
||
? [
|
||
{
|
||
symbolSize: 6,
|
||
itemStyle: { borderWidth: 1, borderColor: '#fff' },
|
||
label: {
|
||
position: 'left',
|
||
distance: 8,
|
||
fontSize: 11,
|
||
width: 118,
|
||
overflow: 'break',
|
||
lineHeight: 14,
|
||
padding: [2, 6, 2, 6]
|
||
}
|
||
},
|
||
{
|
||
symbolSize: 5,
|
||
itemStyle: { borderWidth: 1, borderColor: '#fff' },
|
||
label: {
|
||
position: 'left',
|
||
distance: 10,
|
||
fontSize: 11,
|
||
width: 160,
|
||
overflow: 'break',
|
||
lineHeight: 14,
|
||
padding: [2, 8, 2, 6]
|
||
}
|
||
},
|
||
{
|
||
symbolSize: 4,
|
||
itemStyle: { borderWidth: 1, borderColor: '#fff', shadowBlur: 2, shadowColor: 'rgba(0,0,0,0.1)' },
|
||
label: {
|
||
position: 'right',
|
||
verticalAlign: 'middle',
|
||
align: 'left',
|
||
distance: 12,
|
||
fontSize: 11,
|
||
width: 232,
|
||
overflow: 'break',
|
||
lineHeight: 15,
|
||
padding: [2, 8, 2, 8],
|
||
formatter: (p) => this.wrapLabelText(p.name, 17)
|
||
}
|
||
}
|
||
]
|
||
: [
|
||
{ symbolSize: 10 },
|
||
{ symbolSize: 7 },
|
||
{ symbolSize: 5 },
|
||
{ symbolSize: 3 }
|
||
],
|
||
lineStyle: {
|
||
width: dm ? 1 : 1.2,
|
||
curveness: dm ? 0.1 : 0.3,
|
||
color: dm ? '#e4e7ed' : '#ccc'
|
||
},
|
||
emphasis: {
|
||
focus: 'descendant',
|
||
lineStyle: { width: 2, color: '#409eff' },
|
||
itemStyle: dm ? { shadowBlur: 6, shadowColor: 'rgba(64,158,255,0.45)' } : undefined
|
||
},
|
||
expandAndCollapse: true,
|
||
animationDuration: 280
|
||
}
|
||
]
|
||
};
|
||
// 渲染图表
|
||
this.chartInstance?.setOption(option, true);
|
||
|
||
this.clickEvent = (params) => {
|
||
const data = params.data;
|
||
if (data && data.value && data.value.trackId) {
|
||
this.currentNode = { ...data.value };
|
||
this.dialogVisible = true;
|
||
}
|
||
};
|
||
// 绑定点击事件
|
||
this.chartInstance.on('click', this.clickEvent);
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
.xmind-box {
|
||
position: relative;
|
||
}
|
||
|
||
.action-panel {
|
||
position: absolute;
|
||
top: 10px;
|
||
right: 10px;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.xmind-container {
|
||
background: #fafafa;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.xmind-box--dashboard {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* 与综合看板折线图区域一致:白底、细边框、轻圆角 */
|
||
.xmind-box--dashboard .xmind-container {
|
||
flex: 1;
|
||
min-height: 0;
|
||
background: #fff;
|
||
border: 1px solid #ebeef5;
|
||
border-radius: 6px;
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
/* 新增:弹窗内部样式美化 */
|
||
:deep(.dialog-content) {
|
||
padding: 10px 0;
|
||
}
|
||
|
||
:deep(.node-title) {
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
color: #1890FF;
|
||
text-align: center;
|
||
padding: 8px 0;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
:deep(.node-info-item) {
|
||
display: flex;
|
||
padding: 6px 0;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
:deep(.label) {
|
||
width: 80px;
|
||
color: #666;
|
||
font-weight: 500;
|
||
}
|
||
|
||
:deep(.value) {
|
||
flex: 1;
|
||
color: #333;
|
||
}
|
||
|
||
:deep(.dialog-footer) {
|
||
text-align: center;
|
||
}
|
||
</style> |