This commit is contained in:
Jimmy
2026-04-27 15:24:41 +08:00
parent 29954a7af0
commit 0cea74ad97
8 changed files with 2123 additions and 0 deletions

View File

@@ -0,0 +1,366 @@
<!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>