Files
fad_oa/ruoyi-ui/src/views/oa/project/pace/components/xmind.vue
王文昊 50f3f15f48 feat(项目看板): 新增项目综合看板功能
新增项目综合看板功能,聚合展示项目、任务、进度主表和步骤数据
- 新增后端聚合接口 GET /oa/project/dashboard/{projectId}
- 新增前端看板页面,包含项目列表、任务表格和进度导图
- 优化思维导图组件,支持看板模式下的紧凑展示
- 新增进度明细表格视图和状态图例
- 实现任务与进度步骤的关联展示
- 添加项目模糊搜索功能
2026-04-15 17:19:56 +08:00

512 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
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>