Files
fad_oa/ruoyi-ui/src/views/oa/project/pace/components/xmind.vue

512 lines
17 KiB
Vue
Raw Normal View History

2026-04-13 17:04:38 +08:00
<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>
2026-04-13 17:04:38 +08:00
<!-- <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>
2026-04-13 17:04:38 +08:00
<!-- 新增三级节点点击弹窗-查看完整信息 -->
<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'
}
2026-04-13 17:04:38 +08:00
}
},
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());
2026-04-13 17:04:38 +08:00
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节点 → 一级节点 → 二级节点(叶子带业务数据)
2026-04-13 17:04:38 +08:00
transformToTreeData (list) {
if (!list.length) return { name: '暂无项目数据', children: [] };
const dm = this.dashboardMode
2026-04-13 17:04:38 +08:00
const projectName = list[0].projectName || '项目进度树图';
const tabMap = new Map();
2026-04-13 17:04:38 +08:00
list.forEach(item => {
const tab = item.tabNode || '默认分组';
2026-04-13 17:04:38 +08:00
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';
}
2026-04-13 17:04:38 +08:00
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 },
2026-04-13 17:04:38 +08:00
label: { color: statusColor },
lineStyle: lineToNode,
2026-04-13 17:04:38 +08:00
value: {
...item,
statusLabel: statusText
2026-04-13 17:04:38 +08:00
}
};
if (!tabMap.has(tab)) {
tabMap.set(tab, new Map());
}
const firstMap = tabMap.get(tab);
if (!firstMap.has(firstLevel)) {
firstMap.set(firstLevel, []);
2026-04-13 17:04:38 +08:00
}
firstMap.get(firstLevel).push(nodeData);
2026-04-13 17:04:38 +08:00
});
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
}))
2026-04-13 17:04:38 +08:00
}));
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,
2026-04-13 17:04:38 +08:00
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;')
2026-04-13 17:04:38 +08:00
const option = {
backgroundColor: dm ? 'transparent' : undefined,
2026-04-13 17:04:38 +08:00
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>`
2026-04-13 17:04:38 +08:00
}
return tip
2026-04-13 17:04:38 +08:00
}
},
series: [
{
type: 'tree',
2026-04-13 17:04:38 +08:00
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,
2026-04-13 17:04:38 +08:00
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
2026-04-13 17:04:38 +08:00
},
/*
* 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 }
],
2026-04-13 17:04:38 +08:00
lineStyle: {
width: dm ? 1 : 1.2,
curveness: dm ? 0.1 : 0.3,
color: dm ? '#e4e7ed' : '#ccc'
2026-04-13 17:04:38 +08:00
},
emphasis: {
focus: 'descendant',
lineStyle: { width: 2, color: '#409eff' },
itemStyle: dm ? { shadowBlur: 6, shadowColor: 'rgba(64,158,255,0.45)' } : undefined
2026-04-13 17:04:38 +08:00
},
expandAndCollapse: true,
animationDuration: 280
2026-04-13 17:04:38 +08:00
}
]
};
// 渲染图表
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;
2026-04-13 17:04:38 +08:00
}
};
// 绑定点击事件
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);
}
2026-04-13 17:04:38 +08:00
/* 新增:弹窗内部样式美化 */
: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>