<template>
|
<div class="app-container bom-demo">
|
<PageHeader :content="`BOM结构演示 - ${bomInfo.productName || ''}`">
|
<template #right-button>
|
<el-button @click="goBack">返回</el-button>
|
</template>
|
</PageHeader>
|
|
<DemoControls
|
ref="controlsRef"
|
:steps="demoSteps"
|
:explanations="demoExplanations"
|
@play="handlePlay"
|
@pause="handlePause"
|
@reset="handleReset"
|
@stepChange="handleStepChange"
|
@speedChange="handleSpeedChange"
|
/>
|
|
<div class="demo-container" v-loading="loading">
|
<div class="bom-visualization">
|
<div class="tree-container" v-if="bomData.length > 0">
|
<TreeNode
|
v-for="(item, index) in bomData"
|
:key="item.tempId || item.id || index"
|
:node="item"
|
:level="0"
|
:delay="0"
|
:animation-speed="animationSpeed"
|
:auto-expand="autoExpand"
|
:show-lines="true"
|
:highlight-ids="highlightIds"
|
:active-id="activeNodeId"
|
:animating="isAnimating"
|
@node-click="handleNodeClick"
|
@expand="handleExpand"
|
/>
|
</div>
|
|
<el-empty v-else description="暂无BOM数据" />
|
|
<div class="visualization-sidebar">
|
<el-card class="info-card" shadow="hover">
|
<template #header>
|
<span>当前节点信息</span>
|
</template>
|
<el-descriptions :column="1" border size="small" v-if="activeNode">
|
<el-descriptions-item label="产品名称">{{ activeNode.productName }}</el-descriptions-item>
|
<el-descriptions-item label="规格型号">{{ activeNode.model || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="单位用量">{{ activeNode.unitQuantity || 1 }}</el-descriptions-item>
|
<el-descriptions-item label="单位">{{ activeNode.unit || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="消耗工序">{{ activeNode.processName || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="层级深度">{{ getNodeDepth(activeNode) }}</el-descriptions-item>
|
<el-descriptions-item label="子项数量">{{ activeNode.children?.length || 0 }}</el-descriptions-item>
|
</el-descriptions>
|
<div v-else class="empty-info">点击节点查看详细信息</div>
|
</el-card>
|
|
<el-card class="legend-card" shadow="hover">
|
<template #header>
|
<span>图例说明</span>
|
</template>
|
<div class="legend-item">
|
<span class="legend-icon root"></span>
|
<span class="legend-text">根节点(成品)</span>
|
</div>
|
<div class="legend-item">
|
<span class="legend-icon child"></span>
|
<span class="legend-text">子节点(零部件)</span>
|
</div>
|
<div class="legend-item">
|
<span class="legend-icon leaf"></span>
|
<span class="legend-text">叶子节点(原材料)</span>
|
</div>
|
<div class="legend-item">
|
<span class="legend-icon highlight"></span>
|
<span class="legend-text">高亮状态</span>
|
</div>
|
<div class="legend-item">
|
<span class="legend-icon active"></span>
|
<span class="legend-text">选中状态</span>
|
</div>
|
</el-card>
|
</div>
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { useRouter, useRoute } from 'vue-router'
|
import TreeNode from './components/TreeNode.vue'
|
import DemoControls from './components/DemoControls.vue'
|
import { queryList } from '@/api/productionManagement/productStructure.js'
|
|
const router = useRouter()
|
const route = useRoute()
|
|
const loading = ref(false)
|
const bomData = ref([])
|
const controlsRef = ref(null)
|
const animationSpeed = ref(1)
|
const autoExpand = ref(false)
|
const isAnimating = ref(false)
|
const highlightIds = ref([])
|
const activeNodeId = ref(null)
|
const activeNode = ref(null)
|
const playTimer = ref(null)
|
|
const bomId = computed(() => route.query.id)
|
const bomInfo = computed(() => ({
|
bomNo: route.query.bomNo || '',
|
productName: route.query.productName || '',
|
productModelName: route.query.productModelName || ''
|
}))
|
|
const demoSteps = [
|
{ title: '根节点', description: '展示成品' },
|
{ title: '展开子项', description: '逐层展开' },
|
{ title: '物料组成', description: '显示关系' },
|
{ title: '用量信息', description: '数量关系' },
|
{ title: '工序关联', description: '消耗工序' }
|
]
|
|
const demoExplanations = [
|
{
|
title: '第1步:认识成品根节点',
|
content: 'BOM的根节点代表最终成品。这里是我们要生产的产品,所有子节点都是为了生产这个成品所需要的物料。'
|
},
|
{
|
title: '第2步:展开查看子项',
|
content: '点击节点可以展开查看其子项。子项代表组成父项的零部件或原材料。BOM是一个树形结构,体现了产品的组成层次。'
|
},
|
{
|
title: '第3步:理解物料组成关系',
|
content: '连线表示父子关系。每个子节点都是其父节点的组成部分。例如,一个电脑由主板、CPU、内存等组成。'
|
},
|
{
|
title: '第4步:查看用量信息',
|
content: '每个节点上的数字表示"单位产出所需数量",即生产一个父项需要多少子项。例如,生产一台电脑需要1块主板。'
|
},
|
{
|
title: '第5步:了解工序关联',
|
content: '子节点上的工序名称表示该物料在哪道工序中被消耗。这连接了BOM与工艺路线,说明物料在何时被使用。'
|
}
|
]
|
|
const fetchBomData = async () => {
|
if (!bomId.value) return
|
loading.value = true
|
try {
|
const res = await queryList(bomId.value)
|
bomData.value = res?.data || []
|
normalizeBomData(bomData.value)
|
} catch (error) {
|
console.error('获取BOM数据失败:', error)
|
} finally {
|
loading.value = false
|
}
|
}
|
|
const normalizeBomData = (data) => {
|
data.forEach(item => {
|
item.tempId = item.tempId || item.id || `${Date.now()}_${Math.random()}`
|
if (item.children && item.children.length > 0) {
|
normalizeBomData(item.children)
|
}
|
})
|
}
|
|
const goBack = () => {
|
router.push('/productionManagement/teachingDemo')
|
}
|
|
const handleNodeClick = (node) => {
|
activeNode.value = node
|
activeNodeId.value = node.tempId || node.id
|
highlightIds.value = getRelatedIds(node)
|
}
|
|
const handleExpand = (node, expanded) => {
|
// 处理展开事件
|
}
|
|
const getRelatedIds = (node) => {
|
const ids = []
|
// 添加所有父节点ID
|
const addParentIds = (currentNode, targetNode) => {
|
if (currentNode.children) {
|
for (const child of currentNode.children) {
|
if (child.tempId === targetNode.tempId || child.id === targetNode.id) {
|
ids.push(currentNode.tempId || currentNode.id)
|
return true
|
}
|
if (addParentIds(child, targetNode)) {
|
ids.push(currentNode.tempId || currentNode.id)
|
return true
|
}
|
}
|
}
|
return false
|
}
|
|
for (const root of bomData.value) {
|
addParentIds(root, node)
|
}
|
|
// 添加所有子节点ID
|
const addChildIds = (currentNode) => {
|
ids.push(currentNode.tempId || currentNode.id)
|
if (currentNode.children) {
|
for (const child of currentNode.children) {
|
addChildIds(child)
|
}
|
}
|
}
|
|
if (node.children) {
|
for (const child of node.children) {
|
addChildIds(child)
|
}
|
}
|
|
return ids
|
}
|
|
const getNodeDepth = (node) => {
|
let depth = 0
|
const findDepth = (currentNode, targetNode, currentDepth) => {
|
if (currentNode.tempId === targetNode.tempId || currentNode.id === targetNode.id) {
|
depth = currentDepth
|
return true
|
}
|
if (currentNode.children) {
|
for (const child of currentNode.children) {
|
if (findDepth(child, targetNode, currentDepth + 1)) {
|
return true
|
}
|
}
|
}
|
return false
|
}
|
|
for (const root of bomData.value) {
|
findDepth(root, node, 0)
|
}
|
|
return depth
|
}
|
|
const handlePlay = () => {
|
isAnimating.value = true
|
autoExpand.value = true
|
runAnimation()
|
}
|
|
const handlePause = () => {
|
isAnimating.value = false
|
autoExpand.value = false
|
if (playTimer.value) {
|
clearTimeout(playTimer.value)
|
}
|
}
|
|
const handleReset = () => {
|
isAnimating.value = false
|
autoExpand.value = false
|
activeNodeId.value = null
|
activeNode.value = null
|
highlightIds.value = []
|
if (playTimer.value) {
|
clearTimeout(playTimer.value)
|
}
|
}
|
|
const handleStepChange = (step) => {
|
// 根据步骤执行相应操作
|
switch (step) {
|
case 0:
|
// 展示根节点
|
if (bomData.value.length > 0) {
|
activeNode.value = bomData.value[0]
|
activeNodeId.value = bomData.value[0].tempId || bomData.value[0].id
|
}
|
break
|
case 1:
|
// 展开子项
|
autoExpand.value = true
|
break
|
case 2:
|
// 显示关系
|
if (activeNode.value) {
|
highlightIds.value = getRelatedIds(activeNode.value)
|
}
|
break
|
case 3:
|
// 用量信息
|
break
|
case 4:
|
// 工序关联
|
break
|
}
|
}
|
|
const handleSpeedChange = (speed) => {
|
animationSpeed.value = speed
|
}
|
|
const runAnimation = () => {
|
if (!isAnimating.value) return
|
|
const totalSteps = demoSteps.length
|
let currentStep = controlsRef.value?.currentStep || 0
|
|
const animate = () => {
|
if (!isAnimating.value) return
|
if (currentStep < totalSteps) {
|
controlsRef.value?.setStep(currentStep)
|
handleStepChange(currentStep)
|
currentStep++
|
playTimer.value = setTimeout(animate, 3000 / animationSpeed.value)
|
} else {
|
isAnimating.value = false
|
controlsRef.value?.setIsPlaying(false)
|
}
|
}
|
|
animate()
|
}
|
|
onMounted(() => {
|
fetchBomData()
|
})
|
|
onUnmounted(() => {
|
if (playTimer.value) {
|
clearTimeout(playTimer.value)
|
}
|
})
|
</script>
|
|
<style scoped lang="scss">
|
.bom-demo {
|
.demo-container {
|
background: #f5f7fa;
|
padding: 20px;
|
border-radius: 12px;
|
min-height: 400px;
|
|
.bom-visualization {
|
display: flex;
|
gap: 20px;
|
|
.tree-container {
|
flex: 1;
|
background: #fff;
|
padding: 30px;
|
border-radius: 12px;
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
overflow: auto;
|
max-height: 600px;
|
}
|
|
.visualization-sidebar {
|
width: 300px;
|
display: flex;
|
flex-direction: column;
|
gap: 16px;
|
|
.info-card {
|
.empty-info {
|
color: #909399;
|
text-align: center;
|
padding: 20px;
|
}
|
}
|
|
.legend-card {
|
.legend-item {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
margin-bottom: 8px;
|
|
.legend-icon {
|
width: 16px;
|
height: 16px;
|
border-radius: 4px;
|
border: 2px solid;
|
|
&.root {
|
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
|
border-color: #409eff;
|
}
|
|
&.child {
|
background: #fff;
|
border-color: #e4e7ed;
|
}
|
|
&.leaf {
|
background: #fff;
|
border-color: #67c23a;
|
}
|
|
&.highlight {
|
background: #fff;
|
border-color: #e6a23c;
|
box-shadow: 0 0 8px rgba(230, 162, 60, 0.4);
|
}
|
|
&.active {
|
background: #fff;
|
border-color: #409eff;
|
box-shadow: 0 0 12px rgba(64, 158, 255, 0.5);
|
}
|
}
|
|
.legend-text {
|
font-size: 12px;
|
color: #666;
|
}
|
}
|
}
|
}
|
}
|
}
|
}
|
</style>
|