371 lines
17 KiB
HTML
371 lines
17 KiB
HTML
<!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="info" plain icon="el-icon-back" @click="goBack" size="small">返回主控台</el-button>
|
||
<el-button type="primary" @click="goToCompare" plain size="small">切换至 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: {
|
||
goBack() {
|
||
window.location.href = '/';
|
||
},
|
||
goToReceipts() {
|
||
window.location.href = '/receipts';
|
||
},
|
||
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>
|
||
{% include "global_log.html" %}
|
||
</body>
|
||
</html> |