<template>
|
<div class="app-container combined-demo">
|
<PageHeader content="BOM与工艺路线联动演示">
|
<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="selection-panel" v-if="!showDemo">
|
<el-card shadow="hover">
|
<el-form label-width="100px">
|
<el-form-item label="选择BOM">
|
<el-select v-model="selectedBomId" placeholder="请选择BOM" filterable style="width: 100%">
|
<el-option v-for="item in bomList" :key="item.id" :label="`${item.bomNo} - ${item.productName}`" :value="item.id" />
|
</el-select>
|
</el-form-item>
|
<el-form-item label="选择工艺路线">
|
<el-select v-model="selectedRouteId" placeholder="请选择工艺路线" filterable style="width: 100%">
|
<el-option v-for="item in routeList" :key="item.id" :label="`${item.processRouteCode} - ${item.productName}`" :value="item.id" />
|
</el-select>
|
</el-form-item>
|
<el-form-item>
|
<el-button type="primary" :disabled="!selectedBomId || !selectedRouteId" @click="startDemo">
|
开始演示
|
</el-button>
|
</el-form-item>
|
</el-form>
|
</el-card>
|
</div>
|
|
<div class="demo-container" v-loading="loading" v-if="showDemo">
|
<div class="combined-visualization">
|
<div class="bom-panel">
|
<div class="panel-header">
|
<h4>BOM 物料结构</h4>
|
<el-button size="small" @click="toggleBomExpand">{{ bomExpanded ? '全部折叠' : '全部展开' }}</el-button>
|
</div>
|
<div class="tree-container">
|
<TreeNode
|
v-for="(item, index) in bomData"
|
:key="item.tempId || item.id || index"
|
:node="item"
|
:level="0"
|
:delay="0"
|
:animation-speed="animationSpeed"
|
:auto-expand="bomExpanded"
|
:show-lines="true"
|
:highlight-ids="highlightBomIds"
|
:active-id="activeBomId"
|
:animating="isAnimating"
|
@node-click="handleBomClick"
|
/>
|
</div>
|
</div>
|
|
<div class="connection-panel">
|
<svg class="connection-svg" ref="connectionSvg">
|
<!-- 动态绘制连接线 -->
|
</svg>
|
<div class="connection-info" v-if="connectionInfo">
|
<el-tag type="success" effect="plain">
|
{{ connectionInfo }}
|
</el-tag>
|
</div>
|
</div>
|
|
<div class="route-panel">
|
<div class="panel-header">
|
<h4>工艺路线流程</h4>
|
</div>
|
<div class="process-container">
|
<ProcessNode
|
v-for="(process, index) in processList"
|
:key="process.id || index"
|
:process="process"
|
:index="index"
|
:delay="index * 200"
|
:animation-speed="animationSpeed"
|
:active="activeProcessId === process.id"
|
:completed="completedProcesses.includes(process.id)"
|
:current="currentProcessId === process.id"
|
:animating="isAnimating && currentProcessId === process.id"
|
:show-arrow="index < processList.length - 1"
|
@click="handleProcessClick"
|
/>
|
</div>
|
</div>
|
</div>
|
|
<div class="demo-explanation">
|
<el-card shadow="hover">
|
<div class="explanation-content">
|
<h5>联动说明</h5>
|
<p>点击右侧的工序节点,左侧会高亮显示该工序消耗的物料。</p>
|
<p>点击左侧的物料节点,右侧会高亮显示消耗该物料的工序。</p>
|
<p>绿色连线表示物料在对应工序中被消耗的关系。</p>
|
</div>
|
</el-card>
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import { useRouter, useRoute } from 'vue-router'
|
import TreeNode from './components/TreeNode.vue'
|
import ProcessNode from './components/ProcessNode.vue'
|
import DemoControls from './components/DemoControls.vue'
|
import { listPage as getBomList } from '@/api/productionManagement/productBom.js'
|
import { listPage as getRouteList } from '@/api/productionManagement/processRoute.js'
|
import { queryList } from '@/api/productionManagement/productStructure.js'
|
import { findProcessRouteItemList } from '@/api/productionManagement/processRouteItem.js'
|
|
const router = useRouter()
|
const route = useRoute()
|
|
const loading = ref(false)
|
const showDemo = ref(false)
|
const bomList = ref([])
|
const routeList = ref([])
|
const selectedBomId = ref(null)
|
const selectedRouteId = ref(null)
|
const bomData = ref([])
|
const processList = ref([])
|
const controlsRef = ref(null)
|
const animationSpeed = ref(1)
|
const isAnimating = ref(false)
|
const bomExpanded = ref(true)
|
const highlightBomIds = ref([])
|
const activeBomId = ref(null)
|
const activeProcessId = ref(null)
|
const activeProcess = ref(null)
|
const completedProcesses = ref([])
|
const currentProcessId = ref(null)
|
const connectionInfo = ref('')
|
const playTimer = ref(null)
|
|
const demoSteps = [
|
{ title: 'BOM结构', description: '查看物料组成' },
|
{ title: '工艺路线', description: '查看工序流程' },
|
{ title: '物料消耗', description: '关联关系' },
|
{ title: '生产过程', description: '模拟流程' },
|
{ title: '联动演示', description: '交互体验' }
|
]
|
|
const demoExplanations = [
|
{
|
title: '第1步:认识BOM结构',
|
content: '左侧展示了产品的BOM结构,树形图显示了产品由哪些物料组成。根节点是成品,子节点是组成成品的零部件和原材料。'
|
},
|
{
|
title: '第2步:了解工艺路线',
|
content: '右侧展示了工艺路线,横向流程图显示了产品需要经过哪些工序。每个工序节点代表一个独立的加工步骤。'
|
},
|
{
|
title: '第3步:理解物料消耗关系',
|
content: 'BOM中的每个物料节点都有一个"消耗工序"属性,表示该物料在哪道工序中被使用。这是BOM与工艺路线的核心关联。'
|
},
|
{
|
title: '第4步:观看生产过程模拟',
|
content: '点击播放可以观看产品在各工序间流转的过程。随着工序的进行,BOM中的物料被逐步消耗。'
|
},
|
{
|
title: '第5步:体验联动交互',
|
content: '点击工序节点,BOM中对应物料会高亮;点击物料节点,对应工序会高亮。这种联动帮助理解物料在何时何地被消耗。'
|
}
|
]
|
|
const fetchBomList = async () => {
|
try {
|
const res = await getBomList({ current: 1, size: 100 })
|
bomList.value = res?.data?.records || []
|
// 如果路由有预设bomId,自动选择
|
if (route.query.bomId) {
|
selectedBomId.value = Number(route.query.bomId)
|
}
|
} catch (error) {
|
console.error('获取BOM列表失败:', error)
|
}
|
}
|
|
const fetchRouteList = async () => {
|
try {
|
const res = await getRouteList({ current: 1, size: 100 })
|
routeList.value = res?.data?.records || []
|
// 如果路由有预设routeId,自动选择
|
if (route.query.routeId) {
|
selectedRouteId.value = Number(route.query.routeId)
|
}
|
} catch (error) {
|
console.error('获取工艺路线列表失败:', error)
|
}
|
}
|
|
const fetchBomData = async () => {
|
if (!selectedBomId.value) return
|
try {
|
const res = await queryList(selectedBomId.value)
|
bomData.value = res?.data || []
|
normalizeBomData(bomData.value)
|
} catch (error) {
|
console.error('获取BOM数据失败:', error)
|
}
|
}
|
|
const fetchProcessList = async () => {
|
if (!selectedRouteId.value) return
|
try {
|
const res = await findProcessRouteItemList({ routeId: selectedRouteId.value })
|
processList.value = res?.data || []
|
processList.value.sort((a, b) => (a.dragSort || 0) - (b.dragSort || 0))
|
} catch (error) {
|
console.error('获取工艺路线数据失败:', error)
|
}
|
}
|
|
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 startDemo = async () => {
|
loading.value = true
|
try {
|
await Promise.all([fetchBomData(), fetchProcessList()])
|
showDemo.value = true
|
} finally {
|
loading.value = false
|
}
|
}
|
|
const goBack = () => {
|
router.push('/productionManagement/teachingDemo')
|
}
|
|
const toggleBomExpand = () => {
|
bomExpanded.value = !bomExpanded.value
|
}
|
|
const handleBomClick = (node) => {
|
activeBomId.value = node.tempId || node.id
|
// 找到消耗该物料的工序
|
const processName = node.processName
|
if (processName) {
|
const matchingProcess = processList.value.find(p =>
|
p.technologyOperationName === processName || p.operationName === processName
|
)
|
if (matchingProcess) {
|
activeProcessId.value = matchingProcess.id
|
connectionInfo.value = `${node.productName} 在 "${processName}" 工序中消耗`
|
}
|
}
|
}
|
|
const handleProcessClick = (process) => {
|
activeProcess.value = process
|
activeProcessId.value = process.id
|
// 找到该工序消耗的所有物料
|
const processName = process.technologyOperationName || process.operationName
|
highlightBomIds.value = findBomIdsByProcess(processName)
|
if (highlightBomIds.value.length > 0) {
|
connectionInfo.value = `"${processName}" 工序消耗了 ${highlightBomIds.value.length} 种物料`
|
}
|
}
|
|
const findBomIdsByProcess = (processName) => {
|
const ids = []
|
const traverse = (nodes) => {
|
for (const node of nodes) {
|
if (node.processName === processName) {
|
ids.push(node.tempId || node.id)
|
}
|
if (node.children) {
|
traverse(node.children)
|
}
|
}
|
}
|
traverse(bomData.value)
|
return ids
|
}
|
|
const handlePlay = () => {
|
isAnimating.value = true
|
bomExpanded.value = true
|
runAnimation()
|
}
|
|
const handlePause = () => {
|
isAnimating.value = false
|
if (playTimer.value) {
|
clearTimeout(playTimer.value)
|
}
|
}
|
|
const handleReset = () => {
|
isAnimating.value = false
|
highlightBomIds.value = []
|
activeBomId.value = null
|
activeProcessId.value = null
|
activeProcess.value = null
|
currentProcessId.value = null
|
completedProcesses.value = []
|
connectionInfo.value = ''
|
if (playTimer.value) {
|
clearTimeout(playTimer.value)
|
}
|
}
|
|
const handleStepChange = (step) => {
|
switch (step) {
|
case 0:
|
// BOM结构
|
break
|
case 1:
|
// 工艺路线
|
break
|
case 2:
|
// 物料消耗
|
if (processList.value.length > 0) {
|
const firstProcess = processList.value[0]
|
handleProcessClick(firstProcess)
|
}
|
break
|
case 3:
|
// 生产过程模拟
|
simulateProduction()
|
break
|
case 4:
|
// 联动演示
|
break
|
}
|
}
|
|
const handleSpeedChange = (speed) => {
|
animationSpeed.value = speed
|
}
|
|
const simulateProduction = () => {
|
completedProcesses.value = []
|
currentProcessId.value = null
|
let index = 0
|
const interval = 1500 / animationSpeed.value
|
|
playTimer.value = setInterval(() => {
|
if (index >= processList.value.length) {
|
clearInterval(playTimer.value)
|
currentProcessId.value = null
|
isAnimating.value = false
|
return
|
}
|
|
if (index > 0) {
|
completedProcesses.value.push(processList.value[index - 1].id)
|
}
|
|
currentProcessId.value = processList.value[index].id
|
activeProcessId.value = processList.value[index].id
|
|
// 高亮对应物料
|
const processName = processList.value[index].technologyOperationName || processList.value[index].operationName
|
highlightBomIds.value = findBomIdsByProcess(processName)
|
connectionInfo.value = `正在执行 "${processName}" 工序`
|
|
index++
|
}, interval)
|
}
|
|
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, 4000 / animationSpeed.value)
|
} else {
|
isAnimating.value = false
|
controlsRef.value?.setIsPlaying(false)
|
}
|
}
|
|
animate()
|
}
|
|
onMounted(() => {
|
fetchBomList()
|
fetchRouteList()
|
// 如果预设了数据,自动开始演示
|
if (route.query.bomId && route.query.routeId) {
|
nextTick(() => {
|
startDemo()
|
})
|
}
|
})
|
|
onUnmounted(() => {
|
if (playTimer.value) {
|
clearTimeout(playTimer.value)
|
}
|
})
|
</script>
|
|
<style scoped lang="scss">
|
.combined-demo {
|
.selection-panel {
|
margin-bottom: 20px;
|
}
|
|
.demo-container {
|
background: #f5f7fa;
|
padding: 20px;
|
border-radius: 12px;
|
min-height: 400px;
|
|
.combined-visualization {
|
display: flex;
|
gap: 16px;
|
min-height: 300px;
|
|
.bom-panel,
|
.route-panel {
|
flex: 1;
|
background: #fff;
|
padding: 20px;
|
border-radius: 12px;
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
|
.panel-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-bottom: 16px;
|
|
h4 {
|
color: #303133;
|
font-size: 16px;
|
font-weight: 600;
|
margin: 0;
|
}
|
}
|
|
.tree-container,
|
.process-container {
|
overflow: auto;
|
max-height: 400px;
|
}
|
|
.process-container {
|
display: flex;
|
align-items: center;
|
gap: 0;
|
padding: 10px 0;
|
}
|
}
|
|
.connection-panel {
|
width: 200px;
|
background: #fff;
|
padding: 20px;
|
border-radius: 12px;
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
display: flex;
|
flex-direction: column;
|
justify-content: center;
|
align-items: center;
|
|
.connection-svg {
|
width: 100%;
|
height: 100px;
|
}
|
|
.connection-info {
|
text-align: center;
|
margin-top: 16px;
|
}
|
}
|
}
|
|
.demo-explanation {
|
margin-top: 20px;
|
|
.explanation-content {
|
h5 {
|
color: #409eff;
|
margin-bottom: 12px;
|
}
|
|
p {
|
color: #666;
|
line-height: 1.8;
|
margin-bottom: 8px;
|
}
|
}
|
}
|
}
|
}
|
</style>
|