liding
13 小时以前 44cd199a79b5d9e7cc0900166340e3c4991c0fcf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
<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>