Files
datie-bom/web_ui/templates/bom_compare.html

626 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="10">
<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="10">
<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="4" style="display: flex; gap: 10px;">
<el-button type="primary" size="small" icon="el-icon-search" @click="fetchTreeData" :loading="loadingData">执行对比</el-button>
<el-button type="success" size="small" icon="el-icon-download" @click="exportExcel" :loading="exporting">导出 Excel</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,
exporting: false,
currentParentCode: null,
tableData: [],
originalTableData: [],
// 默认设定期间,方便测试
periodA_start: '2025-01-01',
periodA_end: '2025-12-31',
periodB_start: '2026-01-01',
periodB_end: '2026-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);
},
exportExcel() {
if (!this.currentParentCode) {
this.$message.warning('请先选择一个成品父件并执行对比');
return;
}
if (!this.periodA_start || !this.periodA_end || !this.periodB_start || !this.periodB_end) {
this.$message.warning('请完整选择期间A和期间B的时间范围');
return;
}
this.exporting = 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({
url: `/api/export_compare/${this.currentParentCode}?${params.toString()}`,
method: 'GET',
responseType: 'blob' // 重要:设置响应类型为 blob
})
.then(response => {
// 创建下载链接
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
// 从响应头中获取文件名,如果没有则使用默认文件名
const contentDisposition = response.headers['content-disposition'];
let fileName = `BOM成本对比_${this.currentParentCode}.xlsx`;
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename="?(.+)"?/);
if (fileNameMatch && fileNameMatch.length === 2) {
fileName = decodeURIComponent(fileNameMatch[1]);
}
}
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
this.exporting = false;
this.$message.success('导出成功');
})
.catch(error => {
this.exporting = false;
this.$message.error('导出失败,请重试');
});
}
}
})
</script>
{% include "global_log.html" %}
</body>
</html>