Files
datie-bom/web_ui/templates/bom_tree.html
2026-04-27 15:24:41 +08:00

366 lines
17 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BOM 成本动态雷达图 - 数据展示看板</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<!-- 引入 ECharts -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
body {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f7fa;
height: 100vh; /* 严格锁定整个页面的高度为屏幕高度 */
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden; /* 禁止整个网页出现最外层大滚动条 */
}
#app {
display: flex;
flex-direction: column;
height: 100%; /* 关键:让 Vue 根节点也撑满 100vh */
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #ebeef5;
flex-shrink: 0; /* 防止头部被压缩 */
}
.header h2 { margin: 0; color: #303133; }
.main-container {
display: flex;
flex: 1; /* 占据剩下的所有垂直空间 */
gap: 20px;
min-height: 0; /* Flex 布局核心:防止子元素把父容器撑爆 */
}
.left-sidebar {
width: 300px;
background: #fff;
padding: 15px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
height: 100%; /* 左侧菜单撑满容器 */
box-sizing: border-box;
}
.parent-list {
flex: 1; /* 占据左侧剩下的空间 */
overflow-y: auto; /* 关键:让左侧列表自己出现滚动条 */
margin-top: 15px;
border-top: 1px solid #ebeef5;
}
.right-content {
flex: 1;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
position: relative;
height: 100%; /* 右侧内容区严格撑满,不再随着网页滚动 */
overflow: hidden; /* 防止 ECharts 画布撑爆右侧容器 */
}
#echarts-container {
width: 100%;
height: 100%; /* 完全填满 right-content */
position: absolute; /* 让 ECharts 完全接管这块区域,解决高度坍塌 */
top: 0;
left: 0;
}
/* 悬浮信息卡片(必须要保留,不然卡片样式全乱) */
.info-card {
position: absolute;
top: 20px;
right: 20px;
width: 320px;
z-index: 10;
}
.parent-item {
padding: 12px 10px;
border-bottom: 1px solid #ebeef5;
cursor: pointer;
transition: all 0.3s;
}
.parent-item:hover { background-color: #f0f9eb; }
.parent-item.active {
background-color: #ecf5ff;
border-left: 4px solid #409EFF;
}
.parent-code { font-size: 12px; color: #909399; margin-bottom: 4px; }
.parent-name { font-size: 14px; color: #303133; font-weight: 500; }
.price-highlight {
font-size: 24px;
color: #F56C6C;
font-weight: bold;
margin: 10px 0;
}
</style>
</head>
<body>
<div id="app">
<div class="header">
<h2>🕸️ ERP 动态 BOM 成本核算雷达图</h2>
<div>
<el-button type="primary" @click="goToCompare" plain>切换至 BOM 期间成本对比</el-button>
<el-button type="text" @click="goToReceipts">返回收货明细</el-button>
</div>
</div>
<div class="main-container">
<!-- 左侧:父件选择列表 -->
<div class="left-sidebar">
<el-input
placeholder="搜索成品/父件"
v-model="searchKeyword"
class="input-with-select"
clearable
@keyup.enter.native="searchParents"
@clear="searchParents">
<el-button slot="append" icon="el-icon-search" @click="searchParents"></el-button>
</el-input>
<div class="parent-list" v-loading="loadingParents">
<div v-if="parents.length === 0" style="text-align: center; color: #909399; padding: 20px;">
没有找到成品数据
</div>
<div
v-for="item in parents"
:key="item.parent_material_code"
class="parent-item"
:class="{ active: currentParentCode === item.parent_material_code }"
@click="loadBomTree(item.parent_material_code, item.parent_material_name)">
<div class="parent-code" v-text="item.parent_material_code"></div>
<div class="parent-name" v-text="item.parent_material_name"></div>
</div>
</div>
</div>
<!-- 右侧ECharts 树图展示区 -->
<div class="right-content" v-loading="loadingTree">
<div id="echarts-container"></div>
<!-- 悬浮的最新价格信息卡片 -->
<el-card class="info-card" v-show="currentNode">
<div slot="header" class="clearfix">
<span style="font-weight: bold;" v-text="currentNode ? currentNode.materialName : ''"></span>
</div>
<div v-if="currentNode">
<div style="font-size: 13px; color: #606266; margin-bottom: 15px;">
代码: <span v-text="currentNode.materialCode"></span><br>
层级: <el-tag size="mini" type="info"><span v-text="currentNode.bomLevel"></span></el-tag><br>
用量: <span v-text="currentNode.usageQty"></span>
</div>
<div v-loading="loadingPrice">
<!-- 展示组件/成品的汇总成本 -->
<div v-if="currentNode.children && currentNode.children.length > 0">
<div style="color: #909399; font-size: 12px;">该组件累加总成本 (其下所有子件之和)</div>
<div class="price-highlight" style="color: #E6A23C;" v-pre>¥ </div><span class="price-highlight" style="color: #E6A23C;" v-text="currentNode.totalCost.toFixed(2)"></span>
</div>
<!-- 展示单个物料的最新采购价 -->
<div v-if="!currentNode.children || currentNode.children.length === 0 || currentNode.ownPrice > 0" style="margin-top: 15px;">
<div style="color: #909399; font-size: 12px;">自身最新收货单价</div>
<div class="price-highlight" v-pre>¥ </div><span class="price-highlight" v-text="currentNode.ownPrice.toFixed(2)"></span>
</div>
<el-divider></el-divider>
<div style="font-size: 12px; color: #606266; line-height: 1.8;">
<div><strong>采购单号:</strong> <span v-text="currentNode.poCode"></span></div>
<div><strong>供应商:</strong> <span v-text="currentNode.supplierName"></span></div>
<div><strong>收货时间:</strong> <span v-text="currentNode.receiptTime"></span></div>
</div>
</div>
</div>
</el-card>
</div>
</div>
</div>
<script>
new Vue({
el: '#app',
data: function() {
return {
searchKeyword: '',
parents: [],
loadingParents: false,
loadingTree: false,
loadingPrice: false,
currentParentCode: null,
myChart: null,
currentNode: null,
latestPrice: null
}
},
mounted() {
this.searchParents();
// 初始化 ECharts
this.myChart = echarts.init(document.getElementById('echarts-container'));
// 监听 ECharts 节点的点击事件
this.myChart.on('click', (params) => {
if (params.componentType === 'series' && params.data) {
this.handleNodeClick(params.data);
}
});
// 窗口大小改变时重绘图表
window.addEventListener('resize', () => {
this.myChart.resize();
});
},
methods: {
goToReceipts() {
window.location.href = '/';
},
goToCompare() {
window.location.href = '/compare';
},
searchParents() {
this.loadingParents = true;
axios.get(`/api/bom_parents?keyword=${this.searchKeyword}`)
.then(response => {
this.parents = response.data;
this.loadingParents = false;
})
.catch(error => {
this.$message.error('加载父件列表失败');
this.loadingParents = false;
});
},
loadBomTree(code, name) {
this.currentParentCode = code;
this.currentNode = null; // 切换树时清空卡片
this.loadingTree = true;
axios.get(`/api/bom_tree/${code}`)
.then(response => {
const treeData = response.data;
this.renderECharts(treeData);
this.loadingTree = false;
})
.catch(error => {
this.$message.error('加载 BOM 树结构失败');
this.loadingTree = false;
});
},
handleNodeClick(nodeData) {
// 当点击圆圈节点时触发
this.currentNode = nodeData;
// 我们已经在后端计算好了所有价格信息并附带在 nodeData 中,
// 所以不再需要单独发起网络请求了!实现真正的秒级响应。
this.loadingPrice = false;
},
renderECharts(treeData) {
const option = {
tooltip: {
trigger: 'item',
triggerOn: 'mousemove',
// 让提示框紧紧跟随鼠标,避免遮挡目标
position: 'right',
formatter: function(params) {
// 鼠标悬浮时,除了名称也显示它的成本
return params.data.name.replace('\n', '<br/>') + '<br/>用量: ' + params.data.usageQty + '<br/>总成本: ¥ ' + params.data.totalCost.toFixed(2);
}
},
series: [
{
type: 'tree',
data: [treeData],
// 留出足够的内边距,方便缩放拖拽
top: '15%',
left: '10%',
bottom: '15%',
right: '10%',
// 恢复小圆圈,放弃把 10 位数长编码强塞进圆圈的执念,否则必定重叠
symbolSize: 22,
// 恢复为正圆
symbol: 'circle',
// 【核心修改 1改变布局方向为从上到下】
orient: 'TB', // Top to Bottom
// 【核心修改 2美化连线为优雅的贝塞尔曲线】
edgeShape: 'curve',
// 【核心修改 3真正的自由画布】
roam: true,
// 【核心修改 4解决极端拥挤问题】
// 如果某个父件下面挂了上百个零件,原来的间距还是不够用
nodePadding: 200, // 暴力拉开!兄弟节点之间的横向距离强制设定为 200px
layerPadding: 180, // 父子层级间距
// 默认只展开第一层(直接点开的话只看到顶级成品和它的一级子件)
// 这样用户可以自己一层一层慢慢点开,绝不会一上来就满屏爆炸
initialTreeDepth: 1,
// 我们把物料名称放到圆圈的外面(比如正下方),
// 物料编码隐藏,只在点击后的右上角卡片或悬浮 tooltip 中显示
label: {
position: 'bottom', // 统一放在圆圈下方
distance: 10, // 距离圆圈 10px
verticalAlign: 'middle',
align: 'center',
// 只返回物料名称
formatter: function(params) {
return params.data.materialName;
},
color: '#606266', // 名称用深灰色
fontSize: 12,
backgroundColor: 'transparent', // 彻底变透明
padding: [4, 6],
// 如果名称太长,用省略号截断(最大宽度 100px
width: 100,
overflow: 'truncate'
},
// 关键修改:只让圆圈本体响应事件,让长长的标签变透明
// 避免鼠标还没碰到圆圈,就被旁边的长名字抢了焦点
triggerEvent: false,
// 如果同级子件太多,为了防止下面的牌子打架,我们让它们错位或倾斜一点点
leaves: {
label: {
position: 'bottom',
distance: 10,
rotate: -25 // 叶子节点倾斜 25 度,完美错开
}
},
emphasis: {
focus: 'descendant'
},
expandAndCollapse: true,
animationDuration: 550,
animationDurationUpdate: 750
}
]
};
this.myChart.setOption(option);
}
}
})
</script>
</body>
</html>