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,539 @@
<!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>
<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%;
}
.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;
}
.left-sidebar {
width: 250px;
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;
flex-shrink: 0;
}
.parent-list {
flex: 1;
overflow-y: auto;
margin-top: 15px;
border-top: 1px solid #ebeef5;
}
.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; }
.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%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px;
box-sizing: border-box;
}
.toolbar {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px dashed #ebeef5;
}
.table-container {
flex: 1;
overflow: hidden;
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
}
.dot-normal, .dot-missing { display: none; }
.dot-fallback { background-color: #F56C6C; } /* 红色:沿用历史价 */
.price-up { color: #F56C6C; }
.price-down { color: #67C23A; }
/* 紧凑型表格样式 */
.el-table--small td, .el-table--small th, .el-table--mini td, .el-table--mini th {
padding: 0px !important;
}
.el-table .cell {
line-height: 1.1 !important;
padding-top: 0px !important;
padding-bottom: 0px !important;
font-size: 15px !important; /* 字体放大 */
display: flex;
align-items: center;
}
/* 修复 flex 布局导致 el-table-column 的 align 属性失效的问题 */
.el-table td.is-center .cell {
justify-content: center;
}
.el-table td.is-right .cell {
justify-content: flex-end;
}
/* 修复表头由于 display: flex 导致的错位问题 */
.el-table th .cell {
display: block; /* 恢复表头原有的显示方式 */
}
/* 针对左侧树形结构特殊处理,让前面的箭头和内容对齐 */
.el-table__expand-icon {
height: 16px !important;
line-height: 16px !important;
margin-right: 5px; /* 让箭头与文字间距正常 */
}
/* 表格斑马纹样式:隔行换色(白色和非常淡的蓝色) */
.el-table__row--striped td {
background-color: #F4F9FF !important;
}
/* 鼠标悬停时的颜色覆盖(稍微加深一点点) */
.el-table__body tr:hover > td {
background-color: #ecf5ff !important;
}
/* 过滤隐藏行的样式 */
.filtered-out {
display: none !important;
}
</style>
</head>
<body>
<div id="app">
<div class="header">
<h2>📊 BOM 成本期间对比分析表</h2>
<div>
<el-button type="text" @click="goToBomTree">返回雷达图</el-button>
<el-button type="text" @click="goToReceipts">返回收货明细</el-button>
</div>
</div>
<div class="main-container">
<!-- 左侧 1/4父件选择列表 -->
<div class="left-sidebar">
<el-input
placeholder="搜索成品/父件"
v-model="searchKeyword"
size="small"
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="loadCompareData(item.parent_material_code)">
<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>
<!-- 右侧 3/4树形表格展示区 -->
<div class="right-content">
<!-- 顶部期间选择器 -->
<div class="toolbar" v-if="currentParentCode">
<!-- 第一行:期间选择 -->
<el-row :gutter="20" type="flex" align="middle" style="flex-wrap: wrap; margin-bottom: 10px;">
<el-col :span="11">
<span style="font-size: 14px; font-weight: bold; margin-right: 10px;">期间 A (基准期)</span>
<el-date-picker
v-model="periodA_start"
type="date"
size="small"
value-format="yyyy-MM-dd"
placeholder="开始日期"
style="width: 140px;">
</el-date-picker>
<span style="margin: 0 5px; color: #909399;"></span>
<el-date-picker
v-model="periodA_end"
type="date"
size="small"
value-format="yyyy-MM-dd"
placeholder="结束日期"
style="width: 140px;">
</el-date-picker>
</el-col>
<el-col :span="11">
<span style="font-size: 14px; font-weight: bold; margin-right: 10px;">期间 B (对比期)</span>
<el-date-picker
v-model="periodB_start"
type="date"
size="small"
value-format="yyyy-MM-dd"
placeholder="开始日期"
style="width: 140px;">
</el-date-picker>
<span style="margin: 0 5px; color: #909399;"></span>
<el-date-picker
v-model="periodB_end"
type="date"
size="small"
value-format="yyyy-MM-dd"
placeholder="结束日期"
style="width: 140px;">
</el-date-picker>
</el-col>
<el-col :span="2">
<el-button type="primary" size="small" icon="el-icon-search" @click="fetchTreeData" :loading="loadingData">执行对比</el-button>
</el-col>
</el-row>
<!-- 第二行:子件搜索与图例 -->
<el-row :gutter="20" type="flex" align="middle" justify="space-between">
<el-col :span="12">
<el-input
placeholder="输入物料名称搜索子件"
v-model="filterName"
size="small"
clearable
style="width: 200px; margin-right: 10px;"
@input="filterNode">
</el-input>
<el-input
placeholder="输入物料代码搜索子件"
v-model="filterCode"
size="small"
clearable
style="width: 200px;"
@input="filterNode">
</el-input>
</el-col>
<el-col :span="12" style="text-align: right; font-size: 12px; color: #909399;">
图例说明:
<span class="status-dot dot-fallback" style="display:inline-block"></span> 期间内无数据,已回溯取历史最近价
</el-col>
</el-row>
</div>
<!-- 核心树形表格 -->
<div class="table-container" v-if="currentParentCode">
<el-table
v-loading="loadingData"
:data="tableData"
size="mini"
stripe
style="width: 100%; height: 100%;"
row-key="id"
border
default-expand-all
height="100%"
:tree-props="{children: 'children', hasChildren: 'hasChildren'}">
<el-table-column prop="materialName" label="BOM 结构 (物料代码 - 物料名称)" min-width="350" show-overflow-tooltip>
<template slot-scope="scope">
<div :class="{'filtered-out': !scope.row.visible}" style="display: flex; align-items: center;">
<span style="font-family: monospace; font-size: 16px; color: #409EFF; margin-right: 10px; flex-shrink: 0;" v-text="scope.row.materialCode"></span>
<strong style="font-size: 16px;" v-text="scope.row.materialName"></strong>
</div>
</template>
</el-table-column>
<el-table-column label="BOM" align="center" min-width="80">
<template slot-scope="scope">
<span v-text="scope.row.usageQty + (scope.row.unitName || '')"></span>
</template>
</el-table-column>
<el-table-column label="铸件" align="center" min-width="80">
<template slot-scope="scope">
<span v-if="scope.row.castingWeight" v-text="scope.row.castingWeight + 'KG'"></span>
<span v-else style="color: #C0C4CC;">-</span>
</template>
</el-table-column>
<el-table-column label="历史最新基准价 (¥)" align="center" min-width="120">
<template slot-scope="scope">
<span v-if="scope.row.totalLatest > 0" v-text="scope.row.totalLatest.toFixed(2)"></span>
<span v-else style="color: #C0C4CC;">-</span>
</template>
</el-table-column>
<el-table-column label="期间 A 最新价 (¥)" align="center" min-width="140">
<template slot-scope="scope">
<el-tooltip effect="dark" :content="getStatusText(scope.row.showAStatus)" placement="top" :disabled="scope.row.showAStatus !== 'fallback'">
<span style="display: flex; justify-content: center; align-items: center;">
<span class="status-dot" :class="'dot-' + scope.row.showAStatus"></span>
<span v-if="scope.row.totalPeriodA > 0" v-text="scope.row.totalPeriodA.toFixed(2)"></span>
<span v-else style="color: #C0C4CC; margin-left: 2px;">-</span>
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="期间 B 最新价 (¥)" align="center" min-width="140">
<template slot-scope="scope">
<el-tooltip effect="dark" :content="getStatusText(scope.row.showBStatus)" placement="top" :disabled="scope.row.showBStatus !== 'fallback'">
<span style="display: flex; justify-content: center; align-items: center;">
<span class="status-dot" :class="'dot-' + scope.row.showBStatus"></span>
<span v-if="scope.row.totalPeriodB > 0" v-text="scope.row.totalPeriodB.toFixed(2)"></span>
<span v-else style="color: #C0C4CC; margin-left: 2px;">-</span>
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="差异对比 (B - A)" align="center" min-width="120">
<template slot-scope="scope">
<span v-if="scope.row.totalPeriodA > 0 && scope.row.totalPeriodB > 0">
<span v-if="scope.row.totalPeriodB > scope.row.totalPeriodA" class="price-up">
<i class="el-icon-top"></i> <span v-text="(scope.row.totalPeriodB - scope.row.totalPeriodA).toFixed(2)"></span>
</span>
<span v-else-if="scope.row.totalPeriodB < scope.row.totalPeriodA" class="price-down">
<i class="el-icon-bottom"></i> <span v-text="(scope.row.totalPeriodA - scope.row.totalPeriodB).toFixed(2)"></span>
</span>
<span v-else style="color: #909399;">持平</span>
</span>
<span v-else style="color: #C0C4CC;">-</span>
</template>
</el-table-column>
</el-table>
</div>
<div v-else style="flex: 1; display: flex; justify-content: center; align-items: center; color: #909399;">
<i class="el-icon-mouse" style="font-size: 24px; margin-right: 10px;"></i> 请在左侧选择一个成品父件进行分析
</div>
</div>
</div>
</div>
<script>
new Vue({
el: '#app',
data: function() {
return {
searchKeyword: '',
filterName: '',
filterCode: '',
parents: [],
loadingParents: false,
loadingData: false,
currentParentCode: null,
tableData: [],
originalTableData: [],
// 默认设定期间,方便测试
periodA_start: '2023-01-01',
periodA_end: '2023-12-31',
periodB_start: '2024-01-01',
periodB_end: '2024-12-31'
}
},
mounted() {
this.searchParents();
},
methods: {
goToReceipts() { window.location.href = '/'; },
goToBomTree() { window.location.href = '/bom'; },
getStatusText(status) {
if (status === 'fallback') return '期间内无数据,已取历史最近价';
return '';
},
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;
});
},
loadCompareData(code) {
this.currentParentCode = code;
this.fetchTreeData();
},
fetchTreeData() {
if (!this.currentParentCode) return;
if (!this.periodA_start || !this.periodA_end || !this.periodB_start || !this.periodB_end) {
this.$message.warning('请完整选择期间A和期间B的时间范围');
return;
}
this.loadingData = true;
const params = new URLSearchParams({
start_a: this.periodA_start,
end_a: this.periodA_end,
start_b: this.periodB_start,
end_b: this.periodB_end
});
axios.get(`/api/bom_tree_compare/${this.currentParentCode}?${params.toString()}`)
.then(response => {
// 初始化所有节点的可见性
const initVisible = (nodes) => {
nodes.forEach(node => {
node.visible = true;
if (node.children && node.children.length > 0) {
initVisible(node.children);
}
});
};
initVisible(response.data);
this.originalTableData = JSON.parse(JSON.stringify(response.data));
this.tableData = response.data;
this.loadingData = false;
// 如果有残留的搜索条件,加载完自动应用过滤
if (this.filterName || this.filterCode) {
this.filterNode();
}
})
.catch(error => {
this.$message.error('执行成本对比计算失败');
this.loadingData = false;
});
},
filterNode() {
if (!this.filterName && !this.filterCode) {
// 恢复全部可见
this.tableData = JSON.parse(JSON.stringify(this.originalTableData));
return;
}
const nameKeyword = this.filterName.toLowerCase();
const codeKeyword = this.filterCode.toLowerCase();
// 深度克隆一份原始数据进行过滤
let newData = JSON.parse(JSON.stringify(this.originalTableData));
const checkNode = (node) => {
let isMatch = true;
if (nameKeyword && !node.materialName.toLowerCase().includes(nameKeyword)) {
isMatch = false;
}
if (codeKeyword && !node.materialCode.toLowerCase().includes(codeKeyword)) {
isMatch = false;
}
node.visible = isMatch;
let hasVisibleChild = false;
if (node.children && node.children.length > 0) {
for (let i = 0; i < node.children.length; i++) {
if (checkNode(node.children[i])) {
hasVisibleChild = true;
}
}
}
// 如果子节点有匹配的,父节点也必须可见,否则树形结构会断裂
if (hasVisibleChild) {
node.visible = true;
return true;
}
return node.visible;
};
newData.forEach(rootNode => checkNode(rootNode));
// Element UI 树形表格不支持直接隐藏节点,需要从数据结构中过滤掉
const filterInvisibleNodes = (nodes) => {
return nodes.filter(node => {
if (!node.visible) return false;
if (node.children && node.children.length > 0) {
node.children = filterInvisibleNodes(node.children);
}
return true;
});
};
this.tableData = filterInvisibleNodes(newData);
}
}
})
</script>
</body>
</html>

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>

220
web_ui/templates/home.html Normal file
View File

@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ERP 成本与数据看板 - 主控台</title>
<!-- 引入 ElementUI 样式 -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<!-- 引入 Vue.js -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
<!-- 引入 ElementUI 组件库 -->
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<!-- 引入 axios -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<style>
body {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f0f2f5;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.dashboard-container {
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 80%;
max-width: 900px;
text-align: center;
}
.header-title {
color: #303133;
font-size: 28px;
margin-bottom: 10px;
font-weight: 600;
}
.header-subtitle {
color: #909399;
font-size: 16px;
margin-bottom: 40px;
}
.nav-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 30px;
}
.nav-card {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 30px 20px;
cursor: pointer;
transition: all 0.3s cubic-bezier(.25,.8,.25,1);
background-color: #fafafa;
display: flex;
flex-direction: column;
align-items: center;
}
.nav-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.08);
border-color: #409EFF;
background-color: #ecf5ff;
}
.nav-card i {
font-size: 48px;
color: #409EFF;
margin-bottom: 15px;
}
.nav-card h3 {
margin: 0 0 10px 0;
color: #303133;
font-size: 20px;
}
.nav-card p {
margin: 0;
color: #606266;
font-size: 14px;
line-height: 1.5;
}
/* 特定卡片的颜色定制 */
.card-receipt:hover { border-color: #67C23A; background-color: #f0f9eb; }
.card-receipt i { color: #67C23A; }
.card-bom-tree:hover { border-color: #409EFF; background-color: #ecf5ff; }
.card-bom-tree i { color: #409EFF; }
.card-bom-compare:hover { border-color: #E6A23C; background-color: #fdf6ec; }
.card-bom-compare i { color: #E6A23C; }
.action-group {
margin-top: 40px;
padding-top: 30px;
border-top: 1px dashed #ebeef5;
display: flex;
justify-content: center;
gap: 20px;
}
</style>
</head>
<body>
<div class="dashboard-container" id="app">
<div class="header-title">ERP 数据分析与成本管控台</div>
<div class="header-subtitle">请选择您需要进入的业务模块</div>
<div class="nav-grid">
<!-- 卡片 1: 收货明细表 -->
<div class="nav-card card-receipt" onclick="window.location.href='/receipts'">
<i class="el-icon-document"></i>
<h3>收货明细报表</h3>
<p>查看、搜索所有历史收货记录及详细价格数据。</p>
</div>
<!-- 卡片 2: BOM 雷达图 -->
<div class="nav-card card-bom-tree" onclick="window.location.href='/bom'">
<i class="el-icon-share"></i>
<h3>BOM 成本雷达图</h3>
<p>以关系图谱形式可视化展示产品的层级结构与汇总成本。</p>
</div>
<!-- 卡片 3: 期间成本对比 -->
<div class="nav-card card-bom-compare" onclick="window.location.href='/compare'">
<i class="el-icon-data-line"></i>
<h3>期间成本对比分析表</h3>
<p>跨时间段核算 BOM 最新价差异,支持虚拟件过滤与历史价回溯。</p>
</div>
</div>
<div class="action-group">
<el-button
type="success"
:icon="syncing ? '' : 'el-icon-refresh'"
:loading="syncing"
@click="syncReceipts"
round>
<span v-text="syncing ? '正在后台同步增量数据,请稍候...' : '读取最新收货明细报表'"></span>
</el-button>
<el-button
type="primary"
:icon="syncingBom ? '' : 'el-icon-refresh-right'"
:loading="syncingBom"
@click="syncBom"
round>
<span v-text="syncingBom ? '正在后台抓取 BOM 树,耗时较长,请稍候...' : '读取最新 BOM 表'"></span>
</el-button>
</div>
</div>
<script>
new Vue({
el: '#app',
data() {
return {
syncing: false,
syncingBom: false
}
},
methods: {
syncReceipts() {
this.syncing = true;
axios.post('/api/sync_receipts')
.then(res => {
if (res.data.success) {
this.$message.success('明细同步成功!' + res.data.message);
} else {
this.$message.error('同步失败:' + res.data.message);
}
})
.catch(err => {
this.$message.error('请求发生异常,请检查后端日志。');
console.error(err);
})
.finally(() => {
this.syncing = false;
});
},
syncBom() {
this.syncingBom = true;
axios.post('/api/sync_bom')
.then(res => {
if (res.data.success) {
this.$message.success('BOM 同步成功!' + res.data.message);
} else {
this.$message.error('同步失败:' + res.data.message);
}
})
.catch(err => {
this.$message.error('请求发生异常,这可能需要较长时间,请检查控制台日志。');
console.error(err);
})
.finally(() => {
this.syncingBom = false;
});
}
}
});
</script>
</body>
</html>

186
web_ui/templates/index.html Normal file
View File

@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>收货明细报表 - 数据展示看板</title>
<!-- 引入 ElementUI (Vue的经典UI库) 样式 -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<!-- 引入 Vue.js -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
<!-- 引入 ElementUI 组件库 -->
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<!-- 引入 axios 用于发送 HTTP 请求 -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<style>
body {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f7fa;
}
.header {
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #ebeef5;
}
.header h2 {
margin: 0;
color: #303133;
}
.card {
background: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
</style>
</head>
<body>
<div id="app">
<div class="header">
<h2>📦 ERP 数据展示看板 - 收货明细报表</h2>
<el-button type="primary" @click="goToBomTree" icon="el-icon-data-analysis">切换至 BOM 成本雷达图</el-button>
</div>
<div class="card">
<!-- 搜索工具栏 -->
<div style="margin-bottom: 20px;">
<el-form :inline="true" :model="searchForm" class="demo-form-inline">
<el-form-item label="供应商名称">
<el-input v-model="searchForm.supplier_name" placeholder="支持模糊搜索" clearable></el-input>
</el-form-item>
<el-form-item label="物料名称">
<el-input v-model="searchForm.material_name" placeholder="支持模糊搜索" clearable></el-input>
</el-form-item>
<el-form-item label="采购订单号">
<el-input v-model="searchForm.po_code" placeholder="支持模糊搜索" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" icon="el-icon-search">查询</el-button>
<el-button @click="resetSearch" icon="el-icon-refresh">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 数据表格 -->
<el-table
:data="tableData"
v-loading="loading"
border
stripe
style="width: 100%">
<el-table-column prop="receipt_time" label="收货时间" width="160" sortable></el-table-column>
<el-table-column prop="purchase_order_code" label="采购订单号" width="130"></el-table-column>
<el-table-column prop="row_no" label="行号" width="60" align="center"></el-table-column>
<el-table-column prop="material_code" label="物料代码" width="130"></el-table-column>
<el-table-column prop="material_name" label="物料名称" min-width="150" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="material_specification" label="规格" min-width="150" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="warehouse_name" label="收货仓库" width="120"></el-table-column>
<el-table-column prop="supplier_name" label="供应商名称" min-width="200" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="receive_price" label="收货单价" width="100" align="right">
<template slot-scope="scope">
<span style="color: #F56C6C; font-weight: bold;" v-pre>¥ </span><span style="color: #F56C6C; font-weight: bold;" v-text="scope.row.receive_price.toFixed(2)"></span>
</template>
</el-table-column>
</el-table>
<!-- 分页器 -->
<div class="pagination-container">
<el-pagination
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="currentPage"
:page-sizes="[20, 50, 100, 500]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="totalRows">
</el-pagination>
</div>
</div>
</div>
<script>
new Vue({
el: '#app',
data: function() {
return {
tableData: [],
loading: false,
currentPage: 1,
pageSize: 50,
totalRows: 0,
searchForm: {
supplier_name: '',
material_name: '',
po_code: ''
}
}
},
mounted() {
// 页面加载完成后立即拉取第一页数据
this.fetchData();
},
methods: {
goToBomTree() {
window.location.href = '/bom';
},
fetchData() {
this.loading = true;
// 构造带有搜索参数的请求 URL
const params = new URLSearchParams({
page: this.currentPage,
limit: this.pageSize,
supplier_name: this.searchForm.supplier_name,
material_name: this.searchForm.material_name,
po_code: this.searchForm.po_code
});
axios.get(`/api/receipts?${params.toString()}`)
.then(response => {
this.tableData = response.data.rows;
this.totalRows = response.data.total;
this.loading = false;
})
.catch(error => {
console.error("数据加载失败:", error);
this.$message.error('抱歉,获取数据失败了!');
this.loading = false;
});
},
handleSearch() {
this.currentPage = 1;
this.fetchData();
},
resetSearch() {
this.searchForm = {
supplier_name: '',
material_name: '',
po_code: ''
};
this.currentPage = 1;
this.fetchData();
},
handleSizeChange(val) {
this.pageSize = val;
this.fetchData();
},
handleCurrentChange(val) {
this.currentPage = val;
this.fetchData();
}
}
})
</script>
</body>
</html>