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>