570 lines
26 KiB
HTML
570 lines
26 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>
|
||
|
||
<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.latestUnitPrice > 0" style="color: #606266;">
|
||
¥<span v-text="scope.row.latestUnitPrice.toFixed(2)"></span>
|
||
</span>
|
||
<span v-else style="color: #C0C4CC;">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="期间 A 单价(¥)" align="center" min-width="120">
|
||
<template slot-scope="scope">
|
||
<span v-if="scope.row.periodAUnitPrice > 0" style="color: #606266;">
|
||
¥<span v-text="scope.row.periodAUnitPrice.toFixed(2)"></span>
|
||
</span>
|
||
<span v-else style="color: #C0C4CC;">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="期间 B 单价(¥)" align="center" min-width="120">
|
||
<template slot-scope="scope">
|
||
<span v-if="scope.row.periodBUnitPrice > 0" style="color: #606266;">
|
||
¥<span v-text="scope.row.periodBUnitPrice.toFixed(2)"></span>
|
||
</span>
|
||
<span v-else style="color: #C0C4CC;">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<!-- 总成本列 -->
|
||
<el-table-column label="历史基准总成本(¥)" align="center" min-width="130">
|
||
<template slot-scope="scope">
|
||
<span v-if="scope.row.totalLatest > 0">
|
||
<strong v-text="scope.row.totalLatest.toFixed(2)"></strong>
|
||
</span>
|
||
<span v-else style="color: #C0C4CC;">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="期间 A 总成本(¥)" align="center" min-width="150">
|
||
<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>
|
||
<strong v-if="scope.row.totalPeriodA > 0" v-text="scope.row.totalPeriodA.toFixed(2)"></strong>
|
||
<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="150">
|
||
<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>
|
||
<strong v-if="scope.row.totalPeriodB > 0" v-text="scope.row.totalPeriodB.toFixed(2)"></strong>
|
||
<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="140">
|
||
<template slot-scope="scope">
|
||
<span v-if="scope.row.totalPeriodB > 0">
|
||
<span v-if="scope.row.totalPeriodB > (scope.row.totalPeriodA || 0)" class="price-up">
|
||
<i class="el-icon-top"></i> <span v-text="(scope.row.totalPeriodB - (scope.row.totalPeriodA || 0)).toFixed(2)"></span>
|
||
</span>
|
||
<span v-else-if="scope.row.totalPeriodB < (scope.row.totalPeriodA || 0)" class="price-down">
|
||
<i class="el-icon-bottom"></i> <span v-text="((scope.row.totalPeriodA || 0) - 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> |